Compare commits

...

13 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
2d0acad161 Bust web player iframe cache 2026-04-01 11:41:14 +08:00
48dc6c7b26 Add web HTTP-FLV playback path 2026-04-01 11:30:52 +08:00
01b25883e1 Added MIT license 2026-03-26 13:47:08 +08:00
6710aa0624 fix: resolve connectivity issues for Android and Web builds
- Android: Added INTERNET permission and enabled cleartext traffic in AndroidManifest.xml.
- Web: Implemented and registered CORSMiddleware in backend to allow cross-origin requests.
- Flutter: Updated SettingsProvider to use 10.0.2.2 as default for Android Emulator for easier local testing.
2026-03-26 13:37:34 +08:00
a0c5e7590d feat: implement chat history, theme customization, and password management
- Added chat history persistence for active rooms with auto-cleanup on stream end.
- Overhauled Settings page with user profile, theme color picker, and password change.
- Added backend API for user password updates.
- Integrated flutter_launcher_icons and updated app icon to 'H' logo.
- Fixed 'Duplicate keys' bug in danmaku by using UniqueKey and filtering historical messages.
- Updated version to 1.0.0-beta3.5 and author info.
2026-03-25 11:48:39 +08:00
b2a27f7801 Updated APP name 2026-03-23 16:44:23 +08:00
53 changed files with 4143 additions and 311 deletions

3
.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
@@ -50,4 +51,4 @@ frontend/web/robots.txt
frontend/web/icons/Icon-192.png frontend/web/icons/Icon-192.png
frontend/web/icons/Icon-512.png frontend/web/icons/Icon-512.png
frontend/web/icons/Icon-maskable-192.png frontend/web/icons/Icon-maskable-192.png
frontend/web/icons/Icon-maskable-512.png frontend/web/icons/Icon-maskable-512.png

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 CGH0S7
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

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()
@@ -18,19 +18,21 @@ func main() {
// Initialize Chat WebSocket Hub // Initialize Chat WebSocket Hub
chat.InitChat() chat.InitChat()
srv := stream.NewRTMPServer()
// 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() 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")
srv := stream.NewRTMPServer()
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"
@@ -20,6 +22,11 @@ type LoginRequest struct {
Password string `json:"password" binding:"required"` Password string `json:"password" binding:"required"`
} }
type ChangePasswordRequest struct {
OldPassword string `json:"old_password" binding:"required"`
NewPassword string `json:"new_password" binding:"required"`
}
func Register(c *gin.Context) { func Register(c *gin.Context) {
var req RegisterRequest var req RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
@@ -27,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 {
@@ -45,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"})
@@ -84,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
@@ -93,5 +113,52 @@ 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,
}) })
} }
func ChangePassword(c *gin.Context) {
userID, _ := c.Get("user_id")
var req ChangePasswordRequest
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.First(&user, userID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
// Verify old password
if !utils.CheckPasswordHash(req.OldPassword, user.Password) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid old password"})
return
}
// Hash new password
hashedPassword, err := utils.HashPassword(req.NewPassword)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"})
return
}
// Update user
if err := db.DB.Model(&user).Update("password", hashedPassword).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update password"})
return
}
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,42 +1,136 @@
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 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token"}) switch {
case errors.Is(err, errMissingToken):
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization is required"})
case errors.Is(err, errInvalidToken):
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token"})
case errors.Is(err, errUserNotFound):
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not found"})
case errors.Is(err, errDisabledAccount):
c.JSON(http.StatusForbidden, gin.H{"error": "Account is disabled"})
default:
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication failed"})
}
c.Abort() 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()
}
}
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
func CORSMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With")
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next() c.Next()
} }
} }

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

@@ -2,14 +2,20 @@ package api
import ( import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"hightube/internal/stream"
) )
// SetupRouter configures the Gin router and defines API endpoints // SetupRouter configures the Gin router and defines API endpoints
func SetupRouter() *gin.Engine { func SetupRouter(streamServer *stream.RTMPServer) *gin.Engine {
// 设置为发布模式,消除 "[WARNING] Running in debug mode" 警告 // 设置为发布模式,消除 "[WARNING] Running in debug mode" 警告
gin.SetMode(gin.ReleaseMode) gin.SetMode(gin.ReleaseMode)
r := gin.Default() r := gin.Default()
BindAdminDependencies(streamServer)
// Use CORS middleware to allow web access
r.Use(CORSMiddleware(), RequestMetricsMiddleware())
// 清除代理信任警告 "[WARNING] You trusted all proxies" // 清除代理信任警告 "[WARNING] You trusted all proxies"
r.SetTrustedProxies(nil) r.SetTrustedProxies(nil)
@@ -17,16 +23,37 @@ func SetupRouter() *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)
// 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)
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

@@ -16,10 +16,11 @@ const (
) )
type Message struct { type Message struct {
Type string `json:"type"` // "chat", "system", "danmaku" Type string `json:"type"` // "chat", "system", "danmaku"
Username string `json:"username"` Username string `json:"username"`
Content string `json:"content"` Content string `json:"content"`
RoomID string `json:"room_id"` RoomID string `json:"room_id"`
IsHistory bool `json:"is_history"`
} }
type Client struct { type Client struct {
@@ -31,19 +32,27 @@ type Client struct {
} }
type Hub struct { type Hub struct {
rooms map[string]map[*Client]bool rooms map[string]map[*Client]bool
broadcast chan Message roomsHistory map[string][]Message
register chan *Client broadcast chan Message
unregister chan *Client register chan *Client
mutex sync.RWMutex unregister chan *Client
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),
register: make(chan *Client), register: make(chan *Client),
unregister: make(chan *Client), unregister: make(chan *Client),
rooms: make(map[string]map[*Client]bool), rooms: make(map[string]map[*Client]bool),
roomsHistory: make(map[string][]Message),
} }
} }
@@ -56,6 +65,20 @@ func (h *Hub) Run() {
h.rooms[client.RoomID] = make(map[*Client]bool) h.rooms[client.RoomID] = make(map[*Client]bool)
} }
h.rooms[client.RoomID][client] = true h.rooms[client.RoomID][client] = true
// Send existing history to the newly joined client
if history, ok := h.roomsHistory[client.RoomID]; ok {
for _, msg := range history {
msg.IsHistory = true
msgBytes, _ := json.Marshal(msg)
// Use select to avoid blocking if client's send channel is full
select {
case client.Send <- msgBytes:
default:
// If send fails, we could potentially log or ignore
}
}
}
h.mutex.Unlock() h.mutex.Unlock()
case client := <-h.unregister: case client := <-h.unregister:
@@ -64,6 +87,10 @@ func (h *Hub) Run() {
if _, ok := rooms[client]; ok { if _, ok := rooms[client]; ok {
delete(rooms, client) delete(rooms, client)
close(client.Send) close(client.Send)
// We no longer delete the room from h.rooms here if we want history to persist
// even if everyone leaves (as long as it's active in DB).
// But we should clean up if the room is empty and we want to save memory.
// However, the history is what matters.
if len(rooms) == 0 { if len(rooms) == 0 {
delete(h.rooms, client.RoomID) delete(h.rooms, client.RoomID)
} }
@@ -72,7 +99,16 @@ func (h *Hub) Run() {
h.mutex.Unlock() h.mutex.Unlock()
case message := <-h.broadcast: case message := <-h.broadcast:
h.mutex.RLock() h.mutex.Lock()
// Only store "chat" and "danmaku" messages in history
if message.Type == "chat" || message.Type == "danmaku" {
h.roomsHistory[message.RoomID] = append(h.roomsHistory[message.RoomID], message)
// Limit history size to avoid memory leak (e.g., last 100 messages)
if len(h.roomsHistory[message.RoomID]) > 100 {
h.roomsHistory[message.RoomID] = h.roomsHistory[message.RoomID][1:]
}
}
clients := h.rooms[message.RoomID] clients := h.rooms[message.RoomID]
if clients != nil { if clients != nil {
msgBytes, _ := json.Marshal(message) msgBytes, _ := json.Marshal(message)
@@ -85,11 +121,18 @@ func (h *Hub) Run() {
} }
} }
} }
h.mutex.RUnlock() h.mutex.Unlock()
} }
} }
} }
// ClearRoomHistory removes history for a room, should be called when stream ends
func (h *Hub) ClearRoomHistory(roomID string) {
h.mutex.Lock()
defer h.mutex.Unlock()
delete(h.roomsHistory, roomID)
}
func (h *Hub) RegisterClient(c *Client) { func (h *Hub) RegisterClient(c *Client) {
h.register <- c h.register <- c
} }
@@ -158,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
@@ -19,10 +21,10 @@ func InitDB() {
newLogger := logger.New( newLogger := logger.New(
log.New(os.Stdout, "\r\n", log.LstdFlags), // io writer log.New(os.Stdout, "\r\n", log.LstdFlags), // io writer
logger.Config{ logger.Config{
SlowThreshold: time.Second, // Slow SQL threshold SlowThreshold: time.Second, // Slow SQL threshold
LogLevel: logger.Warn, // Log level LogLevel: logger.Warn, // Log level
IgnoreRecordNotFoundError: true, // Ignore ErrRecordNotFound error for logger IgnoreRecordNotFoundError: true, // Ignore ErrRecordNotFound error for logger
Colorful: true, // Disable color Colorful: true, // Disable color
}, },
) )
@@ -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,18 +1,28 @@
package stream package stream
import ( import (
"context"
"crypto/rand"
"encoding/hex"
"fmt" "fmt"
"io" "io"
"net/http"
"os/exec"
"strings" "strings"
"sync" "sync"
"time"
"github.com/gin-gonic/gin"
"github.com/nareix/joy4/av/avutil" "github.com/nareix/joy4/av/avutil"
"github.com/nareix/joy4/av/pubsub" "github.com/nareix/joy4/av/pubsub"
"github.com/nareix/joy4/format" "github.com/nareix/joy4/format"
"github.com/nareix/joy4/format/flv"
"github.com/nareix/joy4/format/rtmp" "github.com/nareix/joy4/format/rtmp"
"hightube/internal/chat"
"hightube/internal/db" "hightube/internal/db"
"hightube/internal/model" "hightube/internal/model"
"hightube/internal/monitor"
) )
func init() { func init() {
@@ -22,68 +32,117 @@ func init() {
// RTMPServer manages all active live streams // RTMPServer manages all active live streams
type RTMPServer struct { type RTMPServer struct {
server *rtmp.Server server *rtmp.Server
channels map[string]*pubsub.Queue channels map[string]*pubsub.Queue
mutex sync.RWMutex transcoders map[string][]*variantTranscoder
internalPublishKey string
mutex sync.RWMutex
}
type variantTranscoder struct {
quality string
cancel context.CancelFunc
cmd *exec.Cmd
}
type qualityProfile struct {
scale string
videoBitrate string
audioBitrate string
}
var qualityOrder = []string{"source", "720p", "480p"}
var supportedQualities = map[string]qualityProfile{
"720p": {
scale: "1280:-2",
videoBitrate: "2500k",
audioBitrate: "128k",
},
"480p": {
scale: "854:-2",
videoBitrate: "1200k",
audioBitrate: "96k",
},
}
type writeFlusher struct {
httpFlusher http.Flusher
io.Writer
}
func (w writeFlusher) Flush() error {
w.httpFlusher.Flush()
return nil
} }
// NewRTMPServer creates and initializes a new media server // NewRTMPServer creates and initializes a new media server
func NewRTMPServer() *RTMPServer { func NewRTMPServer() *RTMPServer {
s := &RTMPServer{ s := &RTMPServer{
channels: make(map[string]*pubsub.Queue), channels: make(map[string]*pubsub.Queue),
server: &rtmp.Server{}, transcoders: make(map[string][]*variantTranscoder),
internalPublishKey: generateInternalPublishKey(),
server: &rtmp.Server{},
} }
// Triggered when a broadcaster (e.g., OBS) starts publishing // Triggered when a broadcaster (e.g., OBS) starts publishing
s.server.HandlePublish = func(conn *rtmp.Conn) { s.server.HandlePublish = func(conn *rtmp.Conn) {
streamPath := conn.URL.Path // Expected format: /live/{stream_key} streamPath := conn.URL.Path // Expected format: /live/{stream_key} or /variant/{room_id}/{quality}/{token}
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}) if isSource {
fmt.Printf("[INFO] Publishing ended for Room ID: %d\n", room.ID) s.stopVariantTranscoders(roomID)
roomIDUint := parseRoomID(roomID)
if roomIDUint != 0 {
db.DB.Model(&model.Room{}).Where("id = ?", roomIDUint).Updates(map[string]interface{}{"is_active": false})
}
chat.MainHub.ClearRoomHistory(roomID)
monitor.Infof("Publishing ended for room_id=%s", roomID)
} else {
monitor.Infof("Variant publishing ended for room_id=%s path=%s", roomID, channelPath)
}
}() }()
// 4. Continuously copy data packets to our broadcast queue // 4. Continuously copy data packets to our broadcast queue
@@ -93,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()
@@ -101,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
} }
@@ -111,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)
@@ -119,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)
} }
} }
} }
@@ -132,6 +191,210 @@ 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.
func (s *RTMPServer) HandleHTTPFLV(c *gin.Context) {
streamPath := fmt.Sprintf("/live/%s", c.Param("room_id"))
if quality := normalizeQuality(c.Query("quality")); quality != "" {
streamPath = fmt.Sprintf("%s/%s", streamPath, quality)
}
s.mutex.RLock()
q, ok := s.channels[streamPath]
s.mutex.RUnlock()
if !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "Stream not found or inactive"})
return
}
flusher, ok := c.Writer.(http.Flusher)
if !ok {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Streaming is not supported by the current server"})
return
}
c.Header("Content-Type", "video/x-flv")
c.Header("Transfer-Encoding", "chunked")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("Access-Control-Allow-Origin", "*")
c.Status(http.StatusOK)
flusher.Flush()
muxer := flv.NewMuxerWriteFlusher(writeFlusher{
httpFlusher: flusher,
Writer: c.Writer,
})
cursor := q.Latest()
if err := avutil.CopyFile(muxer, cursor); err != nil && err != io.EOF {
errStr := err.Error()
if strings.Contains(errStr, "broken pipe") || strings.Contains(errStr, "connection reset by peer") {
monitor.Infof("HTTP-FLV viewer disconnected: %s", streamPath)
return
}
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

@@ -1,4 +1,4 @@
# frontend # Hightube
A new Flutter project. A new Flutter project.

View File

@@ -6,22 +6,22 @@ plugins {
} }
android { android {
namespace = "com.example.frontend" namespace = "com.example.hightube"
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion ndkVersion = flutter.ndkVersion
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_17 sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_1_8
} }
kotlinOptions { kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString() jvmTarget = JavaVersion.VERSION_1_8.toString()
} }
defaultConfig { defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.example.frontend" applicationId = "com.example.hightube"
// You can update the following values to match your application needs. // You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config. // For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion minSdk = flutter.minSdkVersion

View File

@@ -1,8 +1,10 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application <application
android:label="frontend" android:label="Hightube"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/launcher_icon"
android:usesCleartextTraffic="true">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"

View File

@@ -1,4 +1,4 @@
package com.example.frontend package com.example.hightube
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity

Binary file not shown.

After

Width:  |  Height:  |  Size: 948 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 681 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 882 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

View File

@@ -8,7 +8,7 @@ import 'pages/login_page.dart';
void main() { void main() {
fvp.registerWith(); fvp.registerWith();
runApp( runApp(
MultiProvider( MultiProvider(
providers: [ providers: [
@@ -21,28 +21,30 @@ 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>();
final settings = context.watch<SettingsProvider>();
return MaterialApp( return MaterialApp(
title: 'Hightube', title: 'Hightube',
// 设置深色主题为主,更符合视频类应用的审美
theme: ThemeData( theme: ThemeData(
useMaterial3: true, useMaterial3: true,
colorScheme: ColorScheme.fromSeed( colorScheme: ColorScheme.fromSeed(
seedColor: Colors.deepPurple, seedColor: settings.themeColor,
brightness: Brightness.light, brightness: Brightness.light,
), ),
), ),
darkTheme: ThemeData( darkTheme: ThemeData(
useMaterial3: true, useMaterial3: true,
colorScheme: ColorScheme.fromSeed( colorScheme: ColorScheme.fromSeed(
seedColor: Colors.deepPurple, seedColor: settings.themeColor,
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

@@ -33,12 +33,22 @@ class _HomePageState extends State<HomePage> {
if (isWide) if (isWide)
NavigationRail( NavigationRail(
selectedIndex: _selectedIndex, selectedIndex: _selectedIndex,
onDestinationSelected: (int index) => setState(() => _selectedIndex = index), onDestinationSelected: (int index) =>
setState(() => _selectedIndex = index),
labelType: NavigationRailLabelType.all, labelType: NavigationRailLabelType.all,
destinations: const [ destinations: const [
NavigationRailDestination(icon: Icon(Icons.explore), label: Text('Explore')), NavigationRailDestination(
NavigationRailDestination(icon: Icon(Icons.videocam), label: Text('Console')), icon: Icon(Icons.explore),
NavigationRailDestination(icon: Icon(Icons.settings), label: Text('Settings')), label: Text('Explore'),
),
NavigationRailDestination(
icon: Icon(Icons.videocam),
label: Text('Console'),
),
NavigationRailDestination(
icon: Icon(Icons.settings),
label: Text('Settings'),
),
], ],
), ),
Expanded(child: _pages[_selectedIndex]), Expanded(child: _pages[_selectedIndex]),
@@ -47,11 +57,21 @@ class _HomePageState extends State<HomePage> {
bottomNavigationBar: !isWide bottomNavigationBar: !isWide
? NavigationBar( ? NavigationBar(
selectedIndex: _selectedIndex, selectedIndex: _selectedIndex,
onDestinationSelected: (int index) => setState(() => _selectedIndex = index), onDestinationSelected: (int index) =>
setState(() => _selectedIndex = index),
destinations: const [ destinations: const [
NavigationDestination(icon: Icon(Icons.explore), label: 'Explore'), NavigationDestination(
NavigationDestination(icon: Icon(Icons.videocam), label: 'Console'), icon: Icon(Icons.explore),
NavigationDestination(icon: Icon(Icons.settings), label: 'Settings'), label: 'Explore',
),
NavigationDestination(
icon: Icon(Icons.videocam),
label: 'Console',
),
NavigationDestination(
icon: Icon(Icons.settings),
label: 'Settings',
),
], ],
) )
: null, : null,
@@ -100,7 +120,10 @@ class _ExploreViewState extends State<_ExploreView> {
if (mounted) setState(() => _activeRooms = data['active_rooms'] ?? []); if (mounted) setState(() => _activeRooms = data['active_rooms'] ?? []);
} }
} catch (e) { } catch (e) {
if (!isAuto && mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Failed to load rooms"))); if (!isAuto && mounted)
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text("Failed to load rooms")));
} finally { } finally {
if (!isAuto && mounted) setState(() => _isLoading = false); if (!isAuto && mounted) setState(() => _isLoading = false);
} }
@@ -114,8 +137,14 @@ class _ExploreViewState extends State<_ExploreView> {
appBar: AppBar( appBar: AppBar(
title: Text("Explore", style: TextStyle(fontWeight: FontWeight.bold)), title: Text("Explore", style: TextStyle(fontWeight: FontWeight.bold)),
actions: [ actions: [
IconButton(icon: Icon(Icons.refresh), onPressed: () => _refreshRooms()), IconButton(
IconButton(icon: Icon(Icons.logout), onPressed: () => context.read<AuthProvider>().logout()), icon: Icon(Icons.refresh),
onPressed: () => _refreshRooms(),
),
IconButton(
icon: Icon(Icons.logout),
onPressed: () => context.read<AuthProvider>().logout(),
),
], ],
), ),
floatingActionButton: FloatingActionButton.extended( floatingActionButton: FloatingActionButton.extended(
@@ -128,33 +157,42 @@ class _ExploreViewState extends State<_ExploreView> {
child: _isLoading && _activeRooms.isEmpty child: _isLoading && _activeRooms.isEmpty
? Center(child: CircularProgressIndicator()) ? Center(child: CircularProgressIndicator())
: _activeRooms.isEmpty : _activeRooms.isEmpty
? ListView(children: [ ? ListView(
Padding( children: [
padding: EdgeInsets.only(top: 100), Padding(
child: Column( padding: EdgeInsets.only(top: 100),
mainAxisAlignment: MainAxisAlignment.center, child: Column(
children: [ mainAxisAlignment: MainAxisAlignment.center,
Icon(Icons.live_tv_outlined, size: 80, color: Colors.grey), children: [
SizedBox(height: 16), Icon(
Text("No active rooms. Be the first!", style: TextStyle(color: Colors.grey, fontSize: 16)), Icons.live_tv_outlined,
], size: 80,
), color: Colors.grey,
) ),
]) SizedBox(height: 16),
: GridView.builder( Text(
padding: EdgeInsets.all(12), "No active rooms. Be the first!",
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( style: TextStyle(color: Colors.grey, fontSize: 16),
maxCrossAxisExtent: 400, ),
childAspectRatio: 1.2, ],
crossAxisSpacing: 12,
mainAxisSpacing: 12,
), ),
itemCount: _activeRooms.length,
itemBuilder: (context, index) {
final room = _activeRooms[index];
return _buildRoomCard(room, settings);
},
), ),
],
)
: GridView.builder(
padding: EdgeInsets.all(12),
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 400,
childAspectRatio: 1.2,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
itemCount: _activeRooms.length,
itemBuilder: (context, index) {
final room = _activeRooms[index];
return _buildRoomCard(room, settings);
},
),
), ),
); );
} }
@@ -166,13 +204,13 @@ class _ExploreViewState extends State<_ExploreView> {
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: InkWell( child: InkWell(
onTap: () { onTap: () {
final rtmpUrl = "${settings.rtmpUrl}/${room['room_id']}"; final playbackUrl = settings.playbackUrl(room['room_id'].toString());
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (_) => PlayerPage( builder: (_) => PlayerPage(
title: room['title'], title: room['title'],
rtmpUrl: rtmpUrl, playbackUrl: playbackUrl,
roomId: room['room_id'].toString(), roomId: room['room_id'].toString(),
), ),
), ),
@@ -188,18 +226,37 @@ class _ExploreViewState extends State<_ExploreView> {
children: [ children: [
Container( Container(
color: Theme.of(context).colorScheme.primaryContainer, color: Theme.of(context).colorScheme.primaryContainer,
child: Center(child: Icon(Icons.live_tv, size: 50, color: Theme.of(context).colorScheme.primary)), child: Center(
child: Icon(
Icons.live_tv,
size: 50,
color: Theme.of(context).colorScheme.primary,
),
),
), ),
Positioned( Positioned(
top: 8, left: 8, top: 8,
left: 8,
child: Container( child: Container(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4), padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(color: Colors.red, borderRadius: BorderRadius.circular(4)), decoration: BoxDecoration(
child: Row(children: [ color: Colors.red,
Icon(Icons.circle, size: 8, color: Colors.white), borderRadius: BorderRadius.circular(4),
SizedBox(width: 4), ),
Text("LIVE", style: TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold)), child: Row(
]), children: [
Icon(Icons.circle, size: 8, color: Colors.white),
SizedBox(width: 4),
Text(
"LIVE",
style: TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
],
),
), ),
), ),
], ],
@@ -208,18 +265,35 @@ class _ExploreViewState extends State<_ExploreView> {
Expanded( Expanded(
flex: 1, flex: 1,
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
child: Row( child: Row(
children: [ children: [
CircleAvatar(radius: 16, child: Text(room['user_id'].toString().substring(0, 1))), CircleAvatar(
radius: 16,
child: Text(room['user_id'].toString().substring(0, 1)),
),
SizedBox(width: 12), SizedBox(width: 12),
Expanded( Expanded(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(room['title'], maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)), Text(
Text("Host ID: ${room['user_id']}", style: TextStyle(fontSize: 12, color: Colors.grey)), room['title'],
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
Text(
"Host ID: ${room['user_id']}",
style: TextStyle(fontSize: 12, color: Colors.grey),
),
], ],
), ),
), ),

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,30 +108,46 @@ 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),
// Login Button // Login Button
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
@@ -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,59 +1,142 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
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';
class PlayerPage extends StatefulWidget { class PlayerPage extends StatefulWidget {
final String title; final String title;
final String rtmpUrl; final String playbackUrl;
final String roomId; final String roomId;
const PlayerPage({ const PlayerPage({
Key? key, super.key,
required this.title, required this.title,
required this.rtmpUrl, 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> {
late VideoPlayerController _controller; VideoPlayerController? _controller;
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();
_initializePlayer(); _loadPlaybackOptions();
if (!kIsWeb) {
_initializePlayer();
}
_initializeChat(); _initializeChat();
_showControls();
} }
void _initializePlayer() async { Future<void> _initializePlayer() async {
_controller = VideoPlayerController.networkUrl(Uri.parse(widget.rtmpUrl)); final playbackUrl = _currentPlaybackUrl();
_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) setState(() { _isError = true; _errorMessage = e.toString(); }); if (mounted) {
setState(() {
_isError = true;
_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() {
final settings = context.read<SettingsProvider>(); final settings = context.read<SettingsProvider>();
final auth = context.read<AuthProvider>(); final auth = context.read<AuthProvider>();
// 使用真实用户名建立连接 // 使用真实用户名建立连接
final currentUsername = auth.username ?? "Guest_${widget.roomId}"; final currentUsername = auth.username ?? "Guest_${widget.roomId}";
_chatService.connect(settings.baseUrl, widget.roomId, currentUsername); _chatService.connect(settings.baseUrl, widget.roomId, currentUsername);
@@ -62,8 +145,10 @@ class _PlayerPageState extends State<PlayerPage> {
if (mounted) { if (mounted) {
setState(() { setState(() {
_messages.insert(0, msg); _messages.insert(0, msg);
if (msg.type == "chat" || msg.type == "danmaku") { if (!msg.isHistory && (msg.type == "chat" || msg.type == "danmaku")) {
_addDanmaku(msg.content); if (_showDanmaku) {
_addDanmaku(msg.content);
}
} }
}); });
} }
@@ -71,32 +156,187 @@ class _PlayerPageState extends State<PlayerPage> {
} }
void _addDanmaku(String text) { void _addDanmaku(String text) {
final id = DateTime.now().millisecondsSinceEpoch; final key = UniqueKey();
final top = 20.0 + (id % 6) * 30.0; final lane = DateTime.now().millisecondsSinceEpoch % 8;
final topFactor = 0.06 + lane * 0.045;
final danmaku = _DanmakuItem(
key: ValueKey(id),
text: text,
top: top,
onFinished: () {
if (mounted) setState(() => _danmakus.removeWhere((w) => w.key == ValueKey(id)));
},
);
setState(() => _danmakus.add(danmaku)); setState(() {
_danmakus.add(
_DanmakuEntry(
key: key,
text: text,
topFactor: topFactor,
onFinished: () {
if (mounted) {
setState(() => _danmakus.removeWhere((w) => w.key == key));
}
},
),
);
});
} }
void _sendMsg() { void _sendMsg() {
if (_msgController.text.isNotEmpty) { if (_msgController.text.isNotEmpty) {
final auth = context.read<AuthProvider>(); final auth = context.read<AuthProvider>();
_chatService.sendMessage(_msgController.text, auth.username ?? "Anonymous", widget.roomId); _chatService.sendMessage(
_msgController.text,
auth.username ?? "Anonymous",
widget.roomId,
);
_msgController.clear(); _msgController.clear();
} }
} }
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() {
_controller.dispose(); if (!kIsWeb && _isFullscreen) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
SystemChrome.setPreferredOrientations(DeviceOrientation.values);
}
_controlsHideTimer?.cancel();
_controller?.dispose();
_chatService.dispose(); _chatService.dispose();
_msgController.dispose(); _msgController.dispose();
super.dispose(); super.dispose();
@@ -107,29 +347,36 @@ 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,
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border(left: BorderSide(color: Theme.of(context).dividerColor)), border: Border(
left: BorderSide(color: Theme.of(context).dividerColor),
),
), ),
child: _buildChatSection(), child: _buildChatSection(),
), ),
@@ -141,38 +388,210 @@ 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 Stack( return LayoutBuilder(
children: [ builder: (context, constraints) {
Center( return Stack(
child: _isError children: [
? Text("Error: $_errorMessage", style: TextStyle(color: Colors.white)) Center(
: _controller.value.isInitialized child: _isError
? Text(
"Error: $_errorMessage",
style: TextStyle(color: Colors.white),
)
: kIsWeb
? WebStreamPlayer(
key: ValueKey('web-player-$_playerVersion'),
streamUrl: _currentPlaybackUrl(),
)
: _controller != null && _controller!.value.isInitialized
? AspectRatio( ? AspectRatio(
aspectRatio: _controller.value.aspectRatio, aspectRatio: _controller!.value.aspectRatio,
child: VideoPlayer(_controller), child: VideoPlayer(_controller!),
) )
: CircularProgressIndicator(), : CircularProgressIndicator(),
),
if (_showDanmaku)
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),
],
), ),
// 弹幕层使用 ClipRect 裁剪,防止飘出视频区域 ),
ClipRect( );
child: Stack(children: _danmakus), }
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),
),
); );
} }
@@ -182,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),
@@ -198,21 +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),
],
),
),
);
}, },
), ),
), ),
@@ -226,13 +631,24 @@ class _PlayerPageState extends State<PlayerPage> {
controller: _msgController, controller: _msgController,
decoration: InputDecoration( decoration: InputDecoration(
hintText: "Send a message...", hintText: "Send a message...",
border: OutlineInputBorder(borderRadius: BorderRadius.circular(20)), border: OutlineInputBorder(
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), borderRadius: BorderRadius.circular(20),
),
contentPadding: EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
), ),
onSubmitted: (_) => _sendMsg(), onSubmitted: (_) => _sendMsg(),
), ),
), ),
IconButton(icon: Icon(Icons.send, color: Theme.of(context).colorScheme.primary), onPressed: _sendMsg), IconButton(
icon: Icon(
Icons.send,
color: Theme.of(context).colorScheme.primary,
),
onPressed: _sendMsg,
),
], ],
), ),
), ),
@@ -241,29 +657,59 @@ class _PlayerPageState extends State<PlayerPage> {
} }
} }
class _DanmakuItem extends StatefulWidget { class _DanmakuEntry {
final Key key;
final String text; final String text;
final double top; final double topFactor;
final VoidCallback onFinished; final VoidCallback onFinished;
const _DanmakuItem({Key? key, required this.text, required this.top, required this.onFinished}) : super(key: key); const _DanmakuEntry({
required this.key,
required this.text,
required this.topFactor,
required this.onFinished,
});
}
class _DanmakuItem extends StatefulWidget {
final String text;
final double topFactor;
final double containerWidth;
final double containerHeight;
final VoidCallback onFinished;
const _DanmakuItem({
super.key,
required this.text,
required this.topFactor,
required this.containerWidth,
required this.containerHeight,
required this.onFinished,
});
@override @override
__DanmakuItemState createState() => __DanmakuItemState(); __DanmakuItemState createState() => __DanmakuItemState();
} }
class __DanmakuItemState extends State<_DanmakuItem> with SingleTickerProviderStateMixin { class __DanmakuItemState extends State<_DanmakuItem>
with SingleTickerProviderStateMixin {
late AnimationController _animationController; late AnimationController _animationController;
late Animation<double> _animation; late Animation<double> _animation;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_animationController = AnimationController(duration: const Duration(seconds: 10), vsync: this); _animationController = AnimationController(
duration: const Duration(seconds: 10),
vsync: this,
);
// 使用相对位置:从右向左 // 使用相对位置:从右向左
_animation = Tween<double>(begin: 1.0, end: -0.5).animate(_animationController); _animation = Tween<double>(
begin: 1.0,
end: -0.5,
).animate(_animationController);
_animationController.forward().then((_) => widget.onFinished()); _animationController.forward().then((_) => widget.onFinished());
} }
@@ -279,16 +725,21 @@ class __DanmakuItemState extends State<_DanmakuItem> with SingleTickerProviderSt
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(
color: Colors.white, color: Colors.white,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 18, fontSize: 18,
shadows: [Shadow(blurRadius: 4, color: Colors.black, offset: Offset(1, 1))], shadows: [
Shadow(
blurRadius: 4,
color: Colors.black,
offset: Offset(1, 1),
),
],
), ),
), ),
); );

View File

@@ -1,55 +1,134 @@
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/auth_provider.dart';
import '../providers/settings_provider.dart'; import '../providers/settings_provider.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();
} }
class _SettingsPageState extends State<SettingsPage> { class _SettingsPageState extends State<SettingsPage> {
late TextEditingController _urlController; late TextEditingController _urlController;
final TextEditingController _oldPasswordController = TextEditingController();
final TextEditingController _newPasswordController = TextEditingController();
final List<Color> _availableColors = [
Colors.blue,
Colors.deepPurple,
Colors.red,
Colors.green,
Colors.orange,
Colors.teal,
Colors.pink,
];
@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
void dispose() {
_urlController.dispose();
_oldPasswordController.dispose();
_newPasswordController.dispose();
super.dispose();
}
void _handleChangePassword() async {
final settings = context.read<SettingsProvider>();
final auth = context.read<AuthProvider>();
final api = ApiService(settings, auth.token);
if (_oldPasswordController.text.isEmpty ||
_newPasswordController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Please fill in both password fields")),
);
return;
}
try {
final resp = await api.changePassword(
_oldPasswordController.text,
_newPasswordController.text,
);
if (!mounted) {
return;
}
final data = jsonDecode(resp.body);
if (resp.statusCode == 200) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Password updated successfully")),
);
_oldPasswordController.clear();
_newPasswordController.clear();
} else {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text("Error: ${data['error']}")));
}
} catch (e) {
if (!mounted) {
return;
}
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text("Failed to connect to server")));
}
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final auth = context.watch<AuthProvider>();
final settings = context.watch<SettingsProvider>();
final isAuthenticated = auth.isAuthenticated;
return Scaffold( return Scaffold(
appBar: AppBar(title: Text("Settings", style: TextStyle(fontWeight: FontWeight.bold))), appBar: AppBar(
title: Text("Settings", style: TextStyle(fontWeight: FontWeight.bold)),
centerTitle: true,
),
body: SingleChildScrollView( body: SingleChildScrollView(
padding: const EdgeInsets.all(24.0), padding: const EdgeInsets.all(24.0),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( if (isAuthenticated) ...[
"Network Configuration", _buildProfileSection(auth),
style: Theme.of(context).textTheme.titleMedium?.copyWith( const SizedBox(height: 32),
color: Theme.of(context).colorScheme.primary, ],
fontWeight: FontWeight.bold, _buildSectionTitle("Network Configuration"),
), const SizedBox(height: 16),
),
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(
helperText: "Restarting stream may be required after change", borderRadius: BorderRadius.circular(12),
),
), ),
), ),
SizedBox(height: 24), const SizedBox(height: 12),
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
height: 50,
child: ElevatedButton.icon( child: ElevatedButton.icon(
onPressed: () { onPressed: () {
context.read<SettingsProvider>().setBaseUrl(_urlController.text); context.read<SettingsProvider>().setBaseUrl(
_urlController.text,
);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text("Server URL Updated"), content: Text("Server URL Updated"),
@@ -58,25 +137,249 @@ class _SettingsPageState extends State<SettingsPage> {
); );
}, },
icon: Icon(Icons.save), icon: Icon(Icons.save),
label: Text("Save Configuration"), label: Text("Save Network Settings"),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), backgroundColor: Theme.of(
context,
).colorScheme.primaryContainer,
foregroundColor: Theme.of(
context,
).colorScheme.onPrimaryContainer,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
), ),
), ),
), ),
SizedBox(height: 40), const SizedBox(height: 32),
Divider(),
SizedBox(height: 20), _buildSectionTitle("Theme Customization"),
const SizedBox(height: 16),
Text( Text(
"About Hightube", "Appearance Mode",
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.grey), style: Theme.of(context).textTheme.labelLarge,
), ),
SizedBox(height: 10), const SizedBox(height: 12),
Text("Version: 1.0.0-MVP"), SegmentedButton<ThemeMode>(
Text("Status: Phase 3.5 (UI Refinement)"), 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(
spacing: 12,
runSpacing: 12,
children: _availableColors.map((color) {
final isSelected =
settings.themeColor.toARGB32() == color.toARGB32();
return GestureDetector(
onTap: () => settings.setThemeColor(color),
child: Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: isSelected
? Border.all(
color: Theme.of(context).colorScheme.onSurface,
width: 3,
)
: null,
boxShadow: [
BoxShadow(
color: Colors.black26,
blurRadius: 4,
offset: Offset(0, 2),
),
],
),
child: isSelected
? Icon(Icons.check, color: Colors.white)
: null,
),
);
}).toList(),
),
if (isAuthenticated) ...[
const SizedBox(height: 32),
_buildSectionTitle("Security"),
const SizedBox(height: 16),
TextField(
controller: _oldPasswordController,
obscureText: true,
decoration: InputDecoration(
labelText: "Old Password",
prefixIcon: const Icon(Icons.lock_outline),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
const SizedBox(height: 12),
TextField(
controller: _newPasswordController,
obscureText: true,
decoration: InputDecoration(
labelText: "New Password",
prefixIcon: const Icon(Icons.lock_reset),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _handleChangePassword,
icon: const Icon(Icons.update),
label: const Text("Change Password"),
style: OutlinedButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
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),
const Divider(),
const SizedBox(height: 20),
Center(
child: Column(
children: [
Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: settings.themeColor,
borderRadius: BorderRadius.circular(12),
),
child: Center(
child: Text(
"H",
style: TextStyle(
color: Colors.white,
fontSize: 32,
fontWeight: FontWeight.bold,
),
),
),
),
SizedBox(height: 12),
Text(
"Hightube",
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),
Text(
"© 2026 Hightube Project",
style: TextStyle(fontSize: 12, color: Colors.grey),
),
],
),
),
const SizedBox(height: 40),
], ],
), ),
), ),
); );
} }
Widget _buildSectionTitle(String title) {
return Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
);
}
Widget _buildProfileSection(AuthProvider auth) {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
),
child: Row(
children: [
CircleAvatar(
radius: 35,
backgroundColor: Theme.of(context).colorScheme.primary,
child: Text(
(auth.username ?? "U")[0].toUpperCase(),
style: TextStyle(
fontSize: 32,
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 20),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
auth.username ?? "Unknown User",
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
),
Text(
"Self-hosted Streamer",
style: TextStyle(
color: Theme.of(context).colorScheme.outline,
),
),
],
),
),
],
),
);
}
} }

View File

@@ -1,12 +1,21 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
class SettingsProvider with ChangeNotifier { class SettingsProvider with ChangeNotifier {
// Default server address for local development. // Use 10.0.2.2 for Android emulator to access host's localhost
// Using 10.0.2.2 for Android emulator or localhost for Desktop. static String get _defaultUrl =>
String _baseUrl = "http://localhost:8080"; (defaultTargetPlatform == TargetPlatform.android && !kIsWeb)
? "http://10.0.2.2:8080"
: "http://localhost:8080";
String _baseUrl = _defaultUrl;
Color _themeColor = Colors.blue;
ThemeMode _themeMode = ThemeMode.system;
String get baseUrl => _baseUrl; String get baseUrl => _baseUrl;
Color get themeColor => _themeColor;
ThemeMode get themeMode => _themeMode;
SettingsProvider() { SettingsProvider() {
_loadSettings(); _loadSettings();
@@ -15,6 +24,14 @@ class SettingsProvider with ChangeNotifier {
void _loadSettings() async { void _loadSettings() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
_baseUrl = prefs.getString('baseUrl') ?? _baseUrl; _baseUrl = prefs.getString('baseUrl') ?? _baseUrl;
final colorValue = prefs.getInt('themeColor');
if (colorValue != null) {
_themeColor = Color(colorValue);
}
final savedThemeMode = prefs.getString('themeMode');
if (savedThemeMode != null) {
_themeMode = _themeModeFromString(savedThemeMode);
}
notifyListeners(); notifyListeners();
} }
@@ -24,10 +41,57 @@ class SettingsProvider with ChangeNotifier {
await prefs.setString('baseUrl', url); await prefs.setString('baseUrl', url);
notifyListeners(); notifyListeners();
} }
void setThemeColor(Color color) async {
_themeColor = color;
final prefs = await SharedPreferences.getInstance();
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();
}
// Also provide the RTMP URL based on the same hostname // Also provide the RTMP URL based on the same hostname
String get rtmpUrl { String get rtmpUrl {
final uri = Uri.parse(_baseUrl); final uri = Uri.parse(_baseUrl);
return "rtmp://${uri.host}:1935/live"; return "rtmp://${uri.host}:1935/live";
} }
String playbackUrl(String roomId, {String? quality}) {
final uri = Uri.parse(_baseUrl);
final normalizedQuality = quality?.trim().toLowerCase();
if (kIsWeb) {
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/$normalizedQuality";
}
ThemeMode _themeModeFromString(String value) {
switch (value) {
case 'light':
return ThemeMode.light;
case 'dark':
return ThemeMode.dark;
default:
return ThemeMode.system;
}
}
} }

View File

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

View File

@@ -7,8 +7,15 @@ class ChatMessage {
final String username; final String username;
final String content; final String content;
final String roomId; final String roomId;
final bool isHistory;
ChatMessage({required this.type, required this.username, required this.content, required this.roomId}); ChatMessage({
required this.type,
required this.username,
required this.content,
required this.roomId,
this.isHistory = false,
});
factory ChatMessage.fromJson(Map<String, dynamic> json) { factory ChatMessage.fromJson(Map<String, dynamic> json) {
return ChatMessage( return ChatMessage(
@@ -16,6 +23,7 @@ class ChatMessage {
username: json['username'] ?? 'Anonymous', username: json['username'] ?? 'Anonymous',
content: json['content'] ?? '', content: json['content'] ?? '',
roomId: json['room_id'] ?? '', roomId: json['room_id'] ?? '',
isHistory: json['is_history'] ?? false,
); );
} }
@@ -24,6 +32,7 @@ class ChatMessage {
'username': username, 'username': username,
'content': content, 'content': content,
'room_id': roomId, 'room_id': roomId,
'is_history': isHistory,
}; };
} }

View File

@@ -0,0 +1,2 @@
export 'web_stream_player_stub.dart'
if (dart.library.html) 'web_stream_player_web.dart';

View File

@@ -0,0 +1,21 @@
import 'package:flutter/material.dart';
class WebStreamPlayer extends StatelessWidget {
final String streamUrl;
final int? refreshToken;
const WebStreamPlayer({
super.key,
required this.streamUrl,
this.refreshToken,
});
@override
Widget build(BuildContext context) {
return const Text(
'Web playback is unavailable on this platform.',
style: TextStyle(color: Colors.white),
textAlign: TextAlign.center,
);
}
}

View File

@@ -0,0 +1,45 @@
import 'dart:html' as html;
import 'dart:ui_web' as ui_web;
import 'package:flutter/material.dart';
class WebStreamPlayer extends StatefulWidget {
final String streamUrl;
final int? refreshToken;
const WebStreamPlayer({
super.key,
required this.streamUrl,
this.refreshToken,
});
@override
State<WebStreamPlayer> createState() => _WebStreamPlayerState();
}
class _WebStreamPlayerState extends State<WebStreamPlayer> {
late final String _viewType;
@override
void initState() {
super.initState();
final cacheBuster = DateTime.now().microsecondsSinceEpoch;
_viewType = 'flv-player-$cacheBuster';
ui_web.platformViewRegistry.registerViewFactory(_viewType, (int viewId) {
final iframe = html.IFrameElement()
..src =
'flv_player.html?v=$cacheBuster&src=${Uri.encodeComponent(widget.streamUrl)}'
..style.border = '0'
..style.width = '100%'
..style.height = '100%'
..allow = 'autoplay; fullscreen';
return iframe;
});
}
@override
Widget build(BuildContext context) {
return HtmlElementView(viewType: _viewType);
}
}

View File

@@ -4,10 +4,10 @@ project(runner LANGUAGES CXX)
# The name of the executable created for the application. Change this to change # The name of the executable created for the application. Change this to change
# the on-disk name of your application. # the on-disk name of your application.
set(BINARY_NAME "frontend") set(BINARY_NAME "hightube")
# The unique GTK application identifier for this application. See: # The unique GTK application identifier for this application. See:
# https://wiki.gnome.org/HowDoI/ChooseApplicationID # https://wiki.gnome.org/HowDoI/ChooseApplicationID
set(APPLICATION_ID "com.example.frontend") set(APPLICATION_ID "com.example.hightube")
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent # Explicitly opt in to modern CMake behaviors to avoid warnings with recent
# versions of CMake. # versions of CMake.

View File

@@ -45,11 +45,11 @@ static void my_application_activate(GApplication* application) {
if (use_header_bar) { if (use_header_bar) {
GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
gtk_widget_show(GTK_WIDGET(header_bar)); gtk_widget_show(GTK_WIDGET(header_bar));
gtk_header_bar_set_title(header_bar, "frontend"); gtk_header_bar_set_title(header_bar, "Hightube");
gtk_header_bar_set_show_close_button(header_bar, TRUE); gtk_header_bar_set_show_close_button(header_bar, TRUE);
gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
} else { } else {
gtk_window_set_title(window, "frontend"); gtk_window_set_title(window, "Hightube");
} }
gtk_window_set_default_size(window, 1280, 720); gtk_window_set_default_size(window, 1280, 720);

View File

@@ -1,6 +1,22 @@
# Generated by pub # Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile # See https://dart.dev/tools/pub/glossary#lockfile
packages: packages:
archive:
dependency: transitive
description:
name: archive
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
url: "https://pub.dev"
source: hosted
version: "4.0.9"
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async: async:
dependency: transitive dependency: transitive
description: description:
@@ -25,6 +41,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.1" version: "1.4.1"
checked_yaml:
dependency: transitive
description:
name: checked_yaml
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
url: "https://pub.dev"
source: hosted
version: "2.0.4"
cli_util:
dependency: transitive
description:
name: cli_util
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
url: "https://pub.dev"
source: hosted
version: "0.4.2"
clock: clock:
dependency: transitive dependency: transitive
description: description:
@@ -102,6 +134,14 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_launcher_icons:
dependency: "direct dev"
description:
name: flutter_launcher_icons
sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea"
url: "https://pub.dev"
source: hosted
version: "0.13.1"
flutter_lints: flutter_lints:
dependency: "direct dev" dependency: "direct dev"
description: description:
@@ -168,6 +208,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.1.2" version: "4.1.2"
image:
dependency: transitive
description:
name: image
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
url: "https://pub.dev"
source: hosted
version: "4.8.0"
json_annotation:
dependency: transitive
description:
name: json_annotation
sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8
url: "https://pub.dev"
source: hosted
version: "4.11.0"
leak_tracker: leak_tracker:
dependency: transitive dependency: transitive
description: description:
@@ -312,6 +368,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.0" version: "2.3.0"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
url: "https://pub.dev"
source: hosted
version: "7.0.2"
platform: platform:
dependency: transitive dependency: transitive
description: description:
@@ -328,6 +392,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.8" version: "2.1.8"
posix:
dependency: transitive
description:
name: posix
sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07"
url: "https://pub.dev"
source: hosted
version: "6.5.0"
provider: provider:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -549,6 +621,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.0" version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
url: "https://pub.dev"
source: hosted
version: "6.6.1"
yaml: yaml:
dependency: transitive dependency: transitive
description: description:

View File

@@ -1,5 +1,5 @@
name: frontend name: hightube
description: "A new Flutter project." description: "Open Source Live Platform"
# The following line prevents the package from being accidentally published to # The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages. # pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev publish_to: 'none' # Remove this line if you wish to publish to pub.dev
@@ -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+1 version: 1.0.0-beta4.1
environment: environment:
sdk: ^3.11.1 sdk: ^3.11.1
@@ -44,6 +44,7 @@ dependencies:
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
flutter_launcher_icons: ^0.13.1
# The "flutter_lints" package below contains a set of recommended lints to # The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is # encourage good coding practices. The lint set provided by the package is
@@ -52,6 +53,21 @@ dev_dependencies:
# rules and activating additional ones. # rules and activating additional ones.
flutter_lints: ^6.0.0 flutter_lints: ^6.0.0
flutter_launcher_icons:
android: "launcher_icon"
ios: false
image_path: "assets/icon/app_icon.png"
min_sdk_android: 21
web:
generate: true
image_path: "assets/icon/app_icon.png"
background_color: "#ffffff"
theme_color: "#2196F3"
windows:
generate: true
image_path: "assets/icon/app_icon.png"
icon_size: 256
# For information on the generic Dart part of this file, see the # For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec # following page: https://dart.dev/tools/pub/pubspec

View File

@@ -8,12 +8,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:frontend/main.dart'; import 'package:hightube/main.dart';
void main() { void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async { testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame. // Build our app and trigger a frame.
await tester.pumpWidget(const MyApp()); await tester.pumpWidget(HightubeApp());
// Verify that our counter starts at 0. // Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget); expect(find.text('0'), findsOneWidget);

10
frontend/web/flv.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,99 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta content="width=device-width, initial-scale=1.0" name="viewport">
<title>Hightube FLV Player</title>
<style>
html, body {
margin: 0;
width: 100%;
height: 100%;
background: #000;
overflow: hidden;
}
body {
display: flex;
align-items: center;
justify-content: center;
font-family: sans-serif;
color: #fff;
}
#player,
#message {
width: 100%;
height: 100%;
}
#player {
display: none;
object-fit: contain;
background: #000;
}
#message {
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 24px;
box-sizing: border-box;
}
</style>
<script src="flv.min.js"></script>
</head>
<body>
<video id="player" controls autoplay muted playsinline></video>
<div id="message">Loading live stream...</div>
<script>
const params = new URLSearchParams(window.location.search);
const streamUrl = params.get('src');
const video = document.getElementById('player');
const message = document.getElementById('message');
function showMessage(text) {
video.style.display = 'none';
message.style.display = 'flex';
message.textContent = text;
}
if (!streamUrl) {
showMessage('Missing stream URL.');
} else if (typeof flvjs === 'undefined') {
showMessage('flv.js failed to load. Check network access and reload.');
} else if (!flvjs.isSupported()) {
showMessage('This browser does not support FLV playback.');
} else {
const player = flvjs.createPlayer({
type: 'flv',
url: streamUrl,
isLive: true,
}, {
enableWorker: false,
stashInitialSize: 128,
});
player.attachMediaElement(video);
player.load();
player.play().catch(() => {});
player.on(flvjs.Events.ERROR, function(errorType, detail, info) {
const parts = ['Live stream failed to load.'];
if (errorType) parts.push('type=' + errorType);
if (detail) parts.push('detail=' + detail);
if (info && info.msg) parts.push('msg=' + info.msg);
showMessage(parts.join(' '));
});
video.style.display = 'block';
message.style.display = 'none';
window.addEventListener('beforeunload', function() {
player.destroy();
});
}
</script>
</body>
</html>

View File

@@ -1,10 +1,10 @@
# Project-level configuration. # Project-level configuration.
cmake_minimum_required(VERSION 3.14) cmake_minimum_required(VERSION 3.14)
project(frontend LANGUAGES CXX) project(hightube LANGUAGES CXX)
# The name of the executable created for the application. Change this to change # The name of the executable created for the application. Change this to change
# the on-disk name of your application. # the on-disk name of your application.
set(BINARY_NAME "frontend") set(BINARY_NAME "hightube")
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent # Explicitly opt in to modern CMake behaviors to avoid warnings with recent
# versions of CMake. # versions of CMake.

View File

@@ -90,12 +90,12 @@ BEGIN
BLOCK "040904e4" BLOCK "040904e4"
BEGIN BEGIN
VALUE "CompanyName", "com.example" "\0" VALUE "CompanyName", "com.example" "\0"
VALUE "FileDescription", "frontend" "\0" VALUE "FileDescription", "Hightube" "\0"
VALUE "FileVersion", VERSION_AS_STRING "\0" VALUE "FileVersion", VERSION_AS_STRING "\0"
VALUE "InternalName", "frontend" "\0" VALUE "InternalName", "Hightube" "\0"
VALUE "LegalCopyright", "Copyright (C) 2026 com.example. All rights reserved." "\0" VALUE "LegalCopyright", "Copyright (C) 2026 com.example. All rights reserved." "\0"
VALUE "OriginalFilename", "frontend.exe" "\0" VALUE "OriginalFilename", "Hightube.exe" "\0"
VALUE "ProductName", "frontend" "\0" VALUE "ProductName", "Hightube" "\0"
VALUE "ProductVersion", VERSION_AS_STRING "\0" VALUE "ProductVersion", VERSION_AS_STRING "\0"
END END
END END

View File

@@ -27,7 +27,7 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
FlutterWindow window(project); FlutterWindow window(project);
Win32Window::Point origin(10, 10); Win32Window::Point origin(10, 10);
Win32Window::Size size(1280, 720); Win32Window::Size size(1280, 720);
if (!window.Create(L"frontend", origin, size)) { if (!window.Create(L"Hightube", origin, size)) {
return EXIT_FAILURE; return EXIT_FAILURE;
} }
window.SetQuitOnClose(true); window.SetQuitOnClose(true);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB