Compare commits

22 Commits

Author SHA1 Message Date
Z
804adf94a3 feat: add Linux disk space monitoring support- add disk_linux.go using syscall.Statfs to retrieve disk info- update disk_other.go build tag to exclude both Windows and Linux 2026-06-27 15:03:21 +08:00
b0e3d6069a README updated 2026-06-27 14:38:00 +08:00
80d4f692e0 fix: 完善直播结束通知与 WebSocket 连接 2026-06-25 10:50:35 +08:00
2281c98b1b fix: 兼容 Web 同源部署地址 2026-06-24 21:10:21 +08:00
7cb51e70a3 updated website icons and docker server 2026-06-24 16:18:20 +08:00
44318e0e4d updated website style 2026-06-24 10:44:38 +08:00
da577299a0 website updated 2026-06-24 10:25:09 +08:00
ddb2a01d93 添加产品介绍网页 2026-06-23 19:01:54 +08:00
ebded5057f chore: 发布 v1.0.1 并支持端口参数 2026-06-23 17:01:25 +08:00
ae8fe7f31b 修复flutter analyze提示,更新版本至v1.0.0-Beta4.8 2026-06-21 21:20:29 +08:00
2de7d5269e updated go.mod 2026-06-15 15:35:03 +08:00
8715c7bb3d perf: 提升后端高并发承载能力 2026-06-15 15:33:49 +08:00
63c954da55 gitignore updated 2026-06-15 15:08:58 +08:00
e0a6923984 perf: 优化后端性能与直播延迟,包含数据库WAL模式、流媒体写缓冲、转码 Preset 优化、聊天锁和指标采集优化,以及前端自动追帧功能 2026-06-15 15:08:07 +08:00
261b1ab169 feat(frontend): add multi-language support (en, zh-Hans, zh-Hant, ja) 2026-05-25 11:49:53 +08:00
1539e495e6 Make playback options endpoint public 2026-05-11 13:19:51 +08:00
c5b7451fc6 Add Android quick streaming and logout confirmation 2026-04-22 11:44:15 +08:00
b07f243c88 Add live room preview thumbnails 2026-04-22 11:01:32 +08:00
425ea363f8 Unify web player controls and add volume control 2026-04-22 10:45:46 +08:00
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
52 changed files with 7306 additions and 715 deletions

2
.gitignore vendored
View File

@@ -7,6 +7,8 @@ docs/
# --- Backend (Go) ---
backend/hightube.db
backend/hightube.db-shm
backend/hightube.db-wal
backend/server
backend/*.exe
backend/*.out

View File

@@ -45,6 +45,8 @@ go run cmd/server/main.go
- **API 服务**: `http://localhost:8080`
- **RTMP 服务**: `rtmp://localhost:1935`
最新版本服务端已经支持命令行参数修改指定端口,例如`-api-port 8081 -rtmp-port 1935`
### 3. 测试推流
1. 调用 `/api/register` 注册账号。
2. 调用 `/api/login` 获取 Token。
@@ -56,10 +58,15 @@ go run cmd/server/main.go
- [x] **Phase 1**: 基础 RTMP 推拉流功能实现。
- [x] **Phase 2**: 数据库集成、用户鉴权与推流密钥校验。
- [ ] **Phase 3**: Flutter 客户端基础架构与直播列表展示。
- [ ] **Phase 4**: 实时评论系统 (WebSocket) 与弹幕功能。
- [ ] **Phase 5**: 客户端原生推流支持与 UI/UX 优化。
- [x] **Phase 3**: Flutter 客户端基础架构与直播列表展示。
- [x] **Phase 4**: 实时评论系统 (WebSocket) 与弹幕功能。
- [x] **Phase 5**: 客户端原生推流支持与 UI/UX 优化。
## 测试说明
[Hightube项目网站](https://hightube.nudt.space)
我们提供[在线试用](https://stream.nudt.space)以及release里预构建的多种客户端和服务端发行试用时可在设置处将服务器地址设置为`https://stream.nudt.space`
## 📜 许可证
本项目采用 MIT 许可证开源。

View File

@@ -1,6 +1,12 @@
package main
import (
"flag"
"fmt"
"net/http"
"os"
"time"
"hightube/internal/api"
"hightube/internal/chat"
"hightube/internal/db"
@@ -8,9 +14,16 @@ import (
"hightube/internal/stream"
)
type serverConfig struct {
apiPort int
rtmpPort int
}
func main() {
cfg := parseFlags()
monitor.Init(2000)
monitor.Infof("Starting Hightube Server v1.0.0-Beta3.7")
monitor.Infof("Starting Hightube Server v1.0.1")
// Initialize Database and run auto-migrations
db.InitDB()
@@ -18,20 +31,52 @@ func main() {
// Initialize Chat WebSocket Hub
chat.InitChat()
srv := stream.NewRTMPServer()
srv := stream.NewRTMPServer(fmt.Sprintf("%d", cfg.rtmpPort))
// Start the API server in a goroutine so it doesn't block the RTMP server
go func() {
apiAddr := fmt.Sprintf(":%d", cfg.apiPort)
r := api.SetupRouter(srv)
monitor.Infof("API server listening on :8080")
if err := r.Run(":8080"); err != nil {
httpServer := &http.Server{
Addr: apiAddr,
Handler: r,
ReadHeaderTimeout: 5 * time.Second,
IdleTimeout: 60 * time.Second,
MaxHeaderBytes: 1 << 20,
}
monitor.Infof("API server listening on %s", apiAddr)
monitor.Infof("Web console listening on %s/admin", apiAddr)
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
monitor.Errorf("Failed to start API server: %v", err)
}
}()
// Setup and start the RTMP server
rtmpAddr := fmt.Sprintf(":%d", cfg.rtmpPort)
monitor.Infof("Ready to receive RTMP streams from OBS")
if err := srv.Start(":1935"); err != nil {
if err := srv.Start(rtmpAddr); err != nil {
monitor.Errorf("Failed to start RTMP server: %v", err)
}
}
func parseFlags() serverConfig {
cfg := serverConfig{}
flag.IntVar(&cfg.apiPort, "api-port", 8080, "API/Web console listen port")
flag.IntVar(&cfg.rtmpPort, "rtmp-port", 1935, "RTMP listen port")
flag.Parse()
if !validPort(cfg.apiPort) {
fmt.Fprintf(os.Stderr, "invalid --api-port %d: port must be between 1 and 65535\n", cfg.apiPort)
os.Exit(2)
}
if !validPort(cfg.rtmpPort) {
fmt.Fprintf(os.Stderr, "invalid --rtmp-port %d: port must be between 1 and 65535\n", cfg.rtmpPort)
os.Exit(2)
}
return cfg
}
func validPort(port int) bool {
return port >= 1 && port <= 65535
}

View File

@@ -23,7 +23,7 @@ require (
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/gorilla/websocket v1.5.3 // direct
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
@@ -41,7 +41,7 @@ require (
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/sys v0.41.0 // direct
golang.org/x/text v0.34.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
)

View File

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

View File

@@ -1,9 +1,13 @@
package api
import (
"errors"
"net/http"
"os"
"strings"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"hightube/internal/db"
"hightube/internal/model"
@@ -32,11 +36,19 @@ func Register(c *gin.Context) {
return
}
req.Username = strings.TrimSpace(req.Username)
if strings.EqualFold(req.Username, bootstrapAdminUsername()) {
c.JSON(http.StatusForbidden, gin.H{"error": "This username is reserved"})
return
}
// Check if user exists
var existingUser model.User
if err := db.DB.Where("username = ?", req.Username).First(&existingUser).Error; err == nil {
if _, err := db.LoadUserByUsername(req.Username); err == nil {
c.JSON(http.StatusConflict, gin.H{"error": "Username already exists"})
return
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to validate username"})
return
}
// Hash password
@@ -53,20 +65,13 @@ func Register(c *gin.Context) {
Role: "user",
Enabled: true,
}
if err := db.DB.Create(&user).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
return
}
// Create a default live room for the new user
room := model.Room{
UserID: user.ID,
Title: user.Username + "'s Live Room",
StreamKey: utils.GenerateStreamKey(),
IsActive: false,
}
if err := db.DB.Create(&room).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create room for user"})
if err := db.CreateUserAndRoom(&user, &room); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
return
}
@@ -79,9 +84,10 @@ func Login(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
req.Username = strings.TrimSpace(req.Username)
var user model.User
if err := db.DB.Where("username = ?", req.Username).First(&user).Error; err != nil {
user, err := db.LoadUserByUsername(req.Username)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid username or password"})
return
}
@@ -119,8 +125,8 @@ func ChangePassword(c *gin.Context) {
return
}
var user model.User
if err := db.DB.First(&user, userID).Error; err != nil {
user, err := db.LoadUserByID(userID.(uint))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
@@ -139,10 +145,18 @@ func ChangePassword(c *gin.Context) {
}
// Update user
if err := db.DB.Model(&user).Update("password", hashedPassword).Error; err != nil {
if err := db.UpdateUserPassword(user.ID, hashedPassword); 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

@@ -12,6 +12,8 @@ import (
)
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
return true // Allow all connections
},

View File

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

View File

@@ -6,15 +6,14 @@ import (
"github.com/gin-gonic/gin"
"hightube/internal/db"
"hightube/internal/model"
)
// GetMyRoom returns the room details for the currently authenticated user
func GetMyRoom(c *gin.Context) {
userID, _ := c.Get("user_id")
var room model.Room
if err := db.DB.Where("user_id = ?", userID).First(&room).Error; err != nil {
room, err := db.LoadRoomByUserID(userID.(uint))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Room not found"})
return
}
@@ -29,9 +28,8 @@ func GetMyRoom(c *gin.Context) {
// GetActiveRooms returns a list of all currently active live rooms
func GetActiveRooms(c *gin.Context) {
var rooms []model.Room
// Fetch rooms where is_active is true
if err := db.DB.Where("is_active = ?", true).Find(&rooms).Error; err != nil {
rooms, err := db.ListActiveRooms()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch active rooms"})
return
}
@@ -48,3 +46,18 @@ func GetActiveRooms(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"active_rooms": result})
}
func GetRoomPlaybackOptions(c *gin.Context) {
roomID := c.Param("room_id")
qualities := []string{"source"}
if adminRTMP != nil {
if available := adminRTMP.AvailablePlaybackQualities(roomID); len(available) > 0 {
qualities = available
}
}
c.JSON(http.StatusOK, gin.H{
"room_id": roomID,
"qualities": qualities,
})
}

View File

@@ -11,7 +11,8 @@ func SetupRouter(streamServer *stream.RTMPServer) *gin.Engine {
// 设置为发布模式,消除 "[WARNING] Running in debug mode" 警告
gin.SetMode(gin.ReleaseMode)
r := gin.Default()
r := gin.New()
r.Use(gin.Recovery())
BindAdminDependencies(streamServer)
// Use CORS middleware to allow web access
@@ -23,13 +24,16 @@ func SetupRouter(streamServer *stream.RTMPServer) *gin.Engine {
// Public routes
r.POST("/api/register", Register)
r.POST("/api/login", Login)
r.POST("/api/admin/login", AdminLogin)
r.GET("/api/rooms/active", GetActiveRooms)
r.GET("/api/rooms/:room_id/playback-options", GetRoomPlaybackOptions)
r.GET("/api/rooms/:room_id/thumbnail", streamServer.HandleThumbnail)
r.GET("/live/:room_id", streamServer.HandleHTTPFLV)
// WebSocket endpoint for live chat
r.GET("/api/ws/room/:room_id", WSHandler)
r.GET("/admin", AdminPage)
r.GET("/api/admin/logs/stream", StreamAdminLogs)
r.GET("/api/admin/logs/stream", AuthMiddleware(), AdminMiddleware(), StreamAdminLogs)
// Protected routes (require JWT)
authGroup := r.Group("/api")
@@ -41,9 +45,11 @@ func SetupRouter(streamServer *stream.RTMPServer) *gin.Engine {
adminGroup := authGroup.Group("/admin")
adminGroup.Use(AdminMiddleware())
{
adminGroup.GET("/session", GetAdminSession)
adminGroup.GET("/overview", GetAdminOverview)
adminGroup.GET("/health", GetAdminHealth)
adminGroup.GET("/logs", ListAdminLogs)
adminGroup.POST("/logout", AdminLogout)
adminGroup.GET("/users", ListUsers)
adminGroup.PATCH("/users/:id/role", UpdateUserRole)
adminGroup.PATCH("/users/:id/enabled", UpdateUserEnabled)

View File

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

View File

@@ -9,14 +9,16 @@ import (
)
const (
writeWait = 10 * time.Second
pongWait = 60 * time.Second
pingPeriod = (pongWait * 9) / 10
maxMessageSize = 512
writeWait = 10 * time.Second
pongWait = 60 * time.Second
pingPeriod = (pongWait * 9) / 10
maxMessageSize = 512
roomQueueBufferSize = 2048
historyLimit = 100
)
type Message struct {
Type string `json:"type"` // "chat", "system", "danmaku"
Type string `json:"type"` // "chat", "system", "danmaku", "stream_end"
Username string `json:"username"`
Content string `json:"content"`
RoomID string `json:"room_id"`
@@ -32,12 +34,23 @@ type Client struct {
}
type Hub struct {
rooms map[string]map[*Client]bool
roomsHistory map[string][]Message
broadcast chan Message
mutex sync.RWMutex
rooms map[string]*roomHub
}
type roomHub struct {
roomID string
manager *Hub
register chan *Client
unregister chan *Client
mutex sync.RWMutex
broadcast chan Message
clearHistory chan struct{}
stop chan struct{}
stopOnce sync.Once
mutex sync.RWMutex
clients map[*Client]struct{}
history []Message
}
type StatsSnapshot struct {
@@ -48,108 +61,196 @@ type StatsSnapshot struct {
func NewHub() *Hub {
return &Hub{
broadcast: make(chan Message),
register: make(chan *Client),
unregister: make(chan *Client),
rooms: make(map[string]map[*Client]bool),
roomsHistory: make(map[string][]Message),
rooms: make(map[string]*roomHub),
}
}
func (h *Hub) Run() {
func (h *Hub) getRoom(roomID string) *roomHub {
h.mutex.RLock()
room := h.rooms[roomID]
h.mutex.RUnlock()
return room
}
func (h *Hub) getOrCreateRoom(roomID string) *roomHub {
if room := h.getRoom(roomID); room != nil {
return room
}
h.mutex.Lock()
defer h.mutex.Unlock()
if room := h.rooms[roomID]; room != nil {
return room
}
room := &roomHub{
roomID: roomID,
manager: h,
register: make(chan *Client, roomQueueBufferSize),
unregister: make(chan *Client, roomQueueBufferSize),
broadcast: make(chan Message, roomQueueBufferSize),
clearHistory: make(chan struct{}, 1),
stop: make(chan struct{}),
clients: make(map[*Client]struct{}),
}
h.rooms[roomID] = room
go room.run()
return room
}
func (h *Hub) deleteRoomIfIdle(room *roomHub) {
room.mutex.RLock()
idle := len(room.clients) == 0 && len(room.history) == 0
room.mutex.RUnlock()
if !idle {
return
}
h.mutex.Lock()
if current := h.rooms[room.roomID]; current == room {
delete(h.rooms, room.roomID)
room.stopOnce.Do(func() {
close(room.stop)
})
}
h.mutex.Unlock()
}
func (r *roomHub) run() {
for {
select {
case client := <-h.register:
h.mutex.Lock()
if h.rooms[client.RoomID] == nil {
h.rooms[client.RoomID] = make(map[*Client]bool)
}
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()
case client := <-h.unregister:
h.mutex.Lock()
if rooms, ok := h.rooms[client.RoomID]; ok {
if _, ok := rooms[client]; ok {
delete(rooms, client)
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 {
delete(h.rooms, client.RoomID)
}
}
}
h.mutex.Unlock()
case message := <-h.broadcast:
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]
if clients != nil {
msgBytes, _ := json.Marshal(message)
for client := range clients {
select {
case client.Send <- msgBytes:
default:
close(client.Send)
delete(clients, client)
}
}
}
h.mutex.Unlock()
case client := <-r.register:
r.handleRegister(client)
case client := <-r.unregister:
r.handleUnregister(client)
case message := <-r.broadcast:
r.handleBroadcast(message)
case <-r.clearHistory:
r.handleClearHistory()
case <-r.stop:
return
}
}
}
// ClearRoomHistory removes history for a room, should be called when stream ends
func (r *roomHub) handleRegister(client *Client) {
r.mutex.RLock()
historyCopy := make([]Message, len(r.history))
copy(historyCopy, r.history)
r.mutex.RUnlock()
for _, msg := range historyCopy {
msg.IsHistory = true
msgBytes, err := json.Marshal(msg)
if err != nil {
continue
}
select {
case client.Send <- msgBytes:
default:
}
}
r.mutex.Lock()
r.clients[client] = struct{}{}
r.mutex.Unlock()
}
func (r *roomHub) handleUnregister(client *Client) {
r.mutex.Lock()
if _, ok := r.clients[client]; ok {
delete(r.clients, client)
close(client.Send)
}
r.mutex.Unlock()
r.manager.deleteRoomIfIdle(r)
}
func (r *roomHub) handleBroadcast(message Message) {
msgBytes, err := json.Marshal(message)
if err != nil {
return
}
r.mutex.Lock()
if message.Type == "chat" || message.Type == "danmaku" {
r.history = append(r.history, message)
if len(r.history) > historyLimit {
r.history = r.history[1:]
}
}
for client := range r.clients {
select {
case client.Send <- msgBytes:
default:
close(client.Send)
delete(r.clients, client)
}
}
shouldDeleteIfIdle := message.Type == "stream_end"
if shouldDeleteIfIdle {
r.history = nil
}
r.mutex.Unlock()
if shouldDeleteIfIdle {
r.manager.deleteRoomIfIdle(r)
}
}
func (r *roomHub) handleClearHistory() {
r.mutex.Lock()
r.history = nil
r.mutex.Unlock()
r.manager.deleteRoomIfIdle(r)
}
func (h *Hub) ClearRoomHistory(roomID string) {
h.mutex.Lock()
defer h.mutex.Unlock()
delete(h.roomsHistory, roomID)
if room := h.getRoom(roomID); room != nil {
select {
case room.clearHistory <- struct{}{}:
default:
}
}
}
func (h *Hub) RegisterClient(c *Client) {
h.register <- c
h.getOrCreateRoom(c.RoomID).register <- c
}
func (h *Hub) UnregisterClient(c *Client) {
if room := h.getRoom(c.RoomID); room != nil {
room.unregister <- c
}
}
// BroadcastToRoom sends a message to the broadcast channel
func (h *Hub) BroadcastToRoom(msg Message) {
h.broadcast <- msg
h.getOrCreateRoom(msg.RoomID).broadcast <- msg
}
func (h *Hub) NotifyStreamEnded(roomID string) {
h.BroadcastToRoom(Message{
Type: "stream_end",
Username: "System",
Content: "The host has ended the live stream.",
RoomID: roomID,
})
}
func (c *Client) ReadPump() {
defer func() {
c.Hub.unregister <- c
c.Hub.UnregisterClient(c)
c.Conn.Close()
}()
c.Conn.SetReadLimit(maxMessageSize)
c.Conn.SetReadDeadline(time.Now().Add(pongWait))
c.Conn.SetPongHandler(func(string) error { c.Conn.SetReadDeadline(time.Now().Add(pongWait)); return nil })
c.Conn.SetPongHandler(func(string) error {
c.Conn.SetReadDeadline(time.Now().Add(pongWait))
return nil
})
for {
_, message, err := c.Conn.ReadMessage()
if err != nil {
@@ -159,7 +260,7 @@ func (c *Client) ReadPump() {
if err := json.Unmarshal(message, &msg); err == nil {
msg.RoomID = c.RoomID
msg.Username = c.Username
c.Hub.broadcast <- msg
c.Hub.BroadcastToRoom(msg)
}
}
}
@@ -182,7 +283,10 @@ func (c *Client) WritePump() {
if err != nil {
return
}
w.Write(message)
if _, err := w.Write(message); err != nil {
_ = w.Close()
return
}
if err := w.Close(); err != nil {
return
}
@@ -199,23 +303,29 @@ var MainHub *Hub
func InitChat() {
MainHub = NewHub()
go MainHub.Run()
}
func (h *Hub) GetStatsSnapshot() StatsSnapshot {
h.mutex.RLock()
defer h.mutex.RUnlock()
rooms := make([]*roomHub, 0, len(h.rooms))
roomClients := make(map[string]int, len(h.rooms))
for roomID, room := range h.rooms {
rooms = append(rooms, room)
roomClients[roomID] = 0
}
h.mutex.RUnlock()
totalClients := 0
for roomID, clients := range h.rooms {
count := len(clients)
roomClients[roomID] = count
for _, room := range rooms {
room.mutex.RLock()
count := len(room.clients)
room.mutex.RUnlock()
roomClients[room.roomID] = count
totalClients += count
}
return StatsSnapshot{
RoomCount: len(h.rooms),
RoomCount: len(rooms),
TotalConnectedClient: totalClients,
RoomClients: roomClients,
}

View File

@@ -0,0 +1,332 @@
package db
import (
"errors"
"strings"
"sync"
"gorm.io/gorm"
"hightube/internal/model"
)
type userCache struct {
mutex sync.RWMutex
byID map[uint]model.User
byUsername map[string]uint
}
type roomCache struct {
mutex sync.RWMutex
byID map[uint]model.Room
byUserID map[uint]uint
byStreamKey map[string]uint
activeRoomIDs map[uint]struct{}
activeRoomsLoaded bool
}
var users = &userCache{
byID: make(map[uint]model.User),
byUsername: make(map[string]uint),
}
var rooms = &roomCache{
byID: make(map[uint]model.Room),
byUserID: make(map[uint]uint),
byStreamKey: make(map[string]uint),
activeRoomIDs: make(map[uint]struct{}),
}
func cacheUser(user model.User) {
usernameKey := strings.ToLower(strings.TrimSpace(user.Username))
users.mutex.Lock()
users.byID[user.ID] = user
if usernameKey != "" {
users.byUsername[usernameKey] = user.ID
}
users.mutex.Unlock()
}
func removeUserFromCache(user model.User) {
usernameKey := strings.ToLower(strings.TrimSpace(user.Username))
users.mutex.Lock()
delete(users.byID, user.ID)
if usernameKey != "" {
delete(users.byUsername, usernameKey)
}
users.mutex.Unlock()
}
func cacheRoom(room model.Room) {
rooms.mutex.Lock()
rooms.byID[room.ID] = room
rooms.byUserID[room.UserID] = room.ID
if room.StreamKey != "" {
rooms.byStreamKey[room.StreamKey] = room.ID
}
if room.IsActive {
rooms.activeRoomIDs[room.ID] = struct{}{}
} else {
delete(rooms.activeRoomIDs, room.ID)
}
rooms.mutex.Unlock()
}
func removeRoomFromCache(room model.Room) {
rooms.mutex.Lock()
delete(rooms.byID, room.ID)
delete(rooms.byUserID, room.UserID)
if room.StreamKey != "" {
delete(rooms.byStreamKey, room.StreamKey)
}
delete(rooms.activeRoomIDs, room.ID)
rooms.mutex.Unlock()
}
func LoadUserByID(id uint) (model.User, error) {
users.mutex.RLock()
if user, ok := users.byID[id]; ok {
users.mutex.RUnlock()
return user, nil
}
users.mutex.RUnlock()
var user model.User
if err := DB.First(&user, id).Error; err != nil {
return model.User{}, err
}
cacheUser(user)
return user, nil
}
func LoadUserByUsername(username string) (model.User, error) {
key := strings.ToLower(strings.TrimSpace(username))
if key == "" {
return model.User{}, gorm.ErrRecordNotFound
}
users.mutex.RLock()
if id, ok := users.byUsername[key]; ok {
if user, found := users.byID[id]; found {
users.mutex.RUnlock()
return user, nil
}
}
users.mutex.RUnlock()
var user model.User
if err := DB.Where("username = ?", strings.TrimSpace(username)).First(&user).Error; err != nil {
return model.User{}, err
}
cacheUser(user)
return user, nil
}
func LoadRoomByUserID(userID uint) (model.Room, error) {
rooms.mutex.RLock()
if roomID, ok := rooms.byUserID[userID]; ok {
if room, found := rooms.byID[roomID]; found {
rooms.mutex.RUnlock()
return room, nil
}
}
rooms.mutex.RUnlock()
var room model.Room
if err := DB.Where("user_id = ?", userID).First(&room).Error; err != nil {
return model.Room{}, err
}
cacheRoom(room)
return room, nil
}
func LoadRoomByStreamKey(streamKey string) (model.Room, error) {
rooms.mutex.RLock()
if roomID, ok := rooms.byStreamKey[streamKey]; ok {
if room, found := rooms.byID[roomID]; found {
rooms.mutex.RUnlock()
return room, nil
}
}
rooms.mutex.RUnlock()
var room model.Room
if err := DB.Where("stream_key = ?", streamKey).First(&room).Error; err != nil {
return model.Room{}, err
}
cacheRoom(room)
return room, nil
}
func ListActiveRooms() ([]model.Room, error) {
rooms.mutex.RLock()
loaded := rooms.activeRoomsLoaded
if loaded {
result := make([]model.Room, 0, len(rooms.activeRoomIDs))
for roomID := range rooms.activeRoomIDs {
if room, ok := rooms.byID[roomID]; ok {
result = append(result, room)
}
}
complete := len(result) == len(rooms.activeRoomIDs)
rooms.mutex.RUnlock()
if complete {
return result, nil
}
} else {
rooms.mutex.RUnlock()
}
var result []model.Room
if err := DB.Where("is_active = ?", true).Find(&result).Error; err != nil {
return nil, err
}
rooms.mutex.Lock()
for _, room := range result {
rooms.byID[room.ID] = room
rooms.byUserID[room.UserID] = room.ID
if room.StreamKey != "" {
rooms.byStreamKey[room.StreamKey] = room.ID
}
rooms.activeRoomIDs[room.ID] = struct{}{}
}
rooms.activeRoomsLoaded = true
rooms.mutex.Unlock()
return result, nil
}
func CreateUserAndRoom(user *model.User, room *model.Room) error {
if user == nil || room == nil {
return errors.New("user and room are required")
}
if err := DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(user).Error; err != nil {
return err
}
room.UserID = user.ID
if err := tx.Create(room).Error; err != nil {
return err
}
return nil
}); err != nil {
return err
}
cacheUser(*user)
cacheRoom(*room)
return nil
}
func UpdateUserRole(userID uint, role string) error {
if err := DB.Model(&model.User{}).Where("id = ?", userID).Update("role", role).Error; err != nil {
return err
}
user, err := LoadUserByID(userID)
if err != nil {
return err
}
user.Role = role
cacheUser(user)
return nil
}
func UpdateUserEnabled(userID uint, enabled bool) error {
if err := DB.Model(&model.User{}).Where("id = ?", userID).Update("enabled", enabled).Error; err != nil {
return err
}
user, err := LoadUserByID(userID)
if err != nil {
return err
}
user.Enabled = enabled
cacheUser(user)
return nil
}
func UpdateUserPassword(userID uint, hash string) error {
if err := DB.Model(&model.User{}).Where("id = ?", userID).Update("password", hash).Error; err != nil {
return err
}
user, err := LoadUserByID(userID)
if err != nil {
return err
}
user.Password = hash
cacheUser(user)
return nil
}
func SetRoomActive(roomID uint, active bool) error {
if err := DB.Model(&model.Room{}).Where("id = ?", roomID).Update("is_active", active).Error; err != nil {
return err
}
rooms.mutex.Lock()
room, ok := rooms.byID[roomID]
if ok {
room.IsActive = active
rooms.byID[roomID] = room
if active {
rooms.activeRoomIDs[roomID] = struct{}{}
} else {
delete(rooms.activeRoomIDs, roomID)
}
rooms.activeRoomsLoaded = true
rooms.mutex.Unlock()
return nil
}
rooms.mutex.Unlock()
if !active {
rooms.mutex.Lock()
delete(rooms.activeRoomIDs, roomID)
rooms.activeRoomsLoaded = true
rooms.mutex.Unlock()
return nil
}
var roomFromDB model.Room
if err := DB.First(&roomFromDB, roomID).Error; err != nil {
return err
}
roomFromDB.IsActive = active
cacheRoom(roomFromDB)
rooms.mutex.Lock()
rooms.activeRoomsLoaded = true
rooms.mutex.Unlock()
return nil
}
func DeleteUserCascade(userID uint) error {
user, userErr := LoadUserByID(userID)
room, roomErr := LoadRoomByUserID(userID)
if err := DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("user_id = ?", userID).Delete(&model.Room{}).Error; err != nil {
return err
}
if err := tx.Delete(&model.User{}, userID).Error; err != nil {
return err
}
return nil
}); err != nil {
return err
}
if roomErr == nil {
removeRoomFromCache(room)
}
if userErr == nil {
removeUserFromCache(user)
}
return nil
}

View File

@@ -3,6 +3,7 @@ package db
import (
"log"
"os"
"runtime"
"time"
"gorm.io/driver/sqlite"
@@ -29,14 +30,40 @@ func InitDB() {
)
var err error
// Use SQLite database stored in a local file named "hightube.db"
DB, err = gorm.Open(sqlite.Open("hightube.db"), &gorm.Config{
// Use SQLite database stored in a local file named "hightube.db" with WAL mode and busy timeout enabled
DB, err = gorm.Open(sqlite.Open("hightube.db?_journal_mode=WAL&_busy_timeout=5000"), &gorm.Config{
Logger: newLogger,
})
if err != nil {
log.Fatalf("Failed to connect database: %v", err)
}
sqlDB, err := DB.DB()
if err != nil {
log.Fatalf("Failed to get database instance: %v", err)
}
maxOpen := runtime.NumCPU()*2 + 1
if maxOpen < 4 {
maxOpen = 4
}
if maxOpen > 32 {
maxOpen = 32
}
sqlDB.SetMaxOpenConns(maxOpen)
sqlDB.SetMaxIdleConns(maxOpen)
sqlDB.SetConnMaxIdleTime(10 * time.Minute)
sqlDB.SetConnMaxLifetime(time.Hour)
for _, pragma := range []string{
"PRAGMA synchronous=NORMAL",
"PRAGMA temp_store=MEMORY",
"PRAGMA foreign_keys=ON",
} {
if execErr := DB.Exec(pragma).Error; execErr != nil {
log.Fatalf("Failed to apply %s: %v", pragma, execErr)
}
}
// Auto-migrate the schema
err = DB.AutoMigrate(&model.User{}, &model.Room{})
if err != nil {
@@ -48,7 +75,7 @@ func InitDB() {
ensureAdminUser()
monitor.Infof("Database initialized successfully")
monitor.Infof("Database initialized successfully with WAL mode and tuned SQLite pragmas")
}
func ensureAdminUser() {
@@ -59,7 +86,7 @@ func ensureAdminUser() {
adminPassword := os.Getenv("HIGHTUBE_ADMIN_PASS")
if adminPassword == "" {
adminPassword = "admin123456"
adminPassword = "admin"
}
var user model.User
@@ -68,14 +95,17 @@ func ensureAdminUser() {
updates := map[string]interface{}{}
if user.Role != "admin" {
updates["role"] = "admin"
user.Role = "admin"
}
if !user.Enabled {
updates["enabled"] = true
user.Enabled = true
}
if len(updates) > 0 {
DB.Model(&user).Updates(updates)
monitor.Warnf("Admin account normalized for username=%s", adminUsername)
}
cacheUser(user)
return
}
@@ -106,5 +136,10 @@ func ensureAdminUser() {
monitor.Warnf("Failed to create default admin room: %v", roomErr)
}
monitor.Warnf("Default admin created: username=%s password=%s", adminUsername, adminPassword)
cacheUser(newAdmin)
if room.ID != 0 {
cacheRoom(room)
}
monitor.Warnf("Default admin created for username=%s; change the password after first login", adminUsername)
}

View File

@@ -10,5 +10,5 @@ type Room struct {
UserID uint `gorm:"uniqueIndex;not null"`
Title string `gorm:"default:'My Live Room'"`
StreamKey string `gorm:"uniqueIndex;not null"` // Secret key for OBS streaming
IsActive bool `gorm:"default:false"` // Whether the stream is currently active
IsActive bool `gorm:"index;default:false"` // Whether the stream is currently active
}

View File

@@ -0,0 +1,28 @@
//go:build linux
package monitor
import (
"os"
"syscall"
)
func getDiskSpaceGB() (float64, float64) {
wd, err := os.Getwd()
if err != nil {
return 0, 0
}
var stat syscall.Statfs_t
if err := syscall.Statfs(wd, &stat); err != nil {
return 0, 0
}
const gb = 1024.0 * 1024.0 * 1024.0
blockSize := uint64(stat.Bsize)
totalBytes := stat.Blocks * blockSize
freeBytes := stat.Bfree * blockSize
return float64(totalBytes) / gb, float64(freeBytes) / gb
}

View File

@@ -1,4 +1,4 @@
//go:build !windows
//go:build !windows && !linux
package monitor

View File

@@ -2,6 +2,7 @@ package monitor
import (
"runtime"
"sync"
"sync/atomic"
"time"
)
@@ -23,21 +24,32 @@ type Snapshot struct {
ErrorsTotal uint64 `json:"errors_total"`
}
func IncrementRequestCount() {
atomic.AddUint64(&totalRequests, 1)
var (
cachedSnapshot Snapshot
snapshotMutex sync.RWMutex
)
func init() {
// Initialize the snapshot once on startup
updateSnapshot()
// Update the snapshot in the background every 2 seconds to avoid STW runtime.ReadMemStats in request threads
go func() {
ticker := time.NewTicker(2 * time.Second)
for range ticker.C {
updateSnapshot()
}
}()
}
func IncrementErrorCount() {
atomic.AddUint64(&totalErrors, 1)
}
func GetSnapshot() Snapshot {
func updateSnapshot() {
var mem runtime.MemStats
runtime.ReadMemStats(&mem)
diskTotal, diskFree := getDiskSpaceGB()
return Snapshot{
snapshotMutex.Lock()
cachedSnapshot = Snapshot{
UptimeSeconds: int64(time.Since(startedAt).Seconds()),
Goroutines: runtime.NumGoroutine(),
MemoryAllocMB: bytesToMB(mem.Alloc),
@@ -48,6 +60,28 @@ func GetSnapshot() Snapshot {
RequestsTotal: atomic.LoadUint64(&totalRequests),
ErrorsTotal: atomic.LoadUint64(&totalErrors),
}
snapshotMutex.Unlock()
}
func IncrementRequestCount() {
atomic.AddUint64(&totalRequests, 1)
}
func IncrementErrorCount() {
atomic.AddUint64(&totalErrors, 1)
}
func GetSnapshot() Snapshot {
snapshotMutex.RLock()
defer snapshotMutex.RUnlock()
// Return the cached snapshot, overlaying volatile/cheap fields in real-time
s := cachedSnapshot
s.UptimeSeconds = int64(time.Since(startedAt).Seconds())
s.Goroutines = runtime.NumGoroutine()
s.RequestsTotal = atomic.LoadUint64(&totalRequests)
s.ErrorsTotal = atomic.LoadUint64(&totalErrors)
return s
}
func bytesToMB(v uint64) float64 {

View File

@@ -1,13 +1,22 @@
package stream
import (
"bufio"
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/nareix/joy4/av"
"github.com/nareix/joy4/av/avutil"
"github.com/nareix/joy4/av/pubsub"
"github.com/nareix/joy4/format"
@@ -16,7 +25,6 @@ import (
"hightube/internal/chat"
"hightube/internal/db"
"hightube/internal/model"
"hightube/internal/monitor"
)
@@ -27,9 +35,43 @@ func init() {
// RTMPServer manages all active live streams
type RTMPServer struct {
server *rtmp.Server
channels map[string]*pubsub.Queue
mutex sync.RWMutex
server *rtmp.Server
channels map[string]*pubsub.Queue
transcoders map[string][]*variantTranscoder
thumbnailJobs map[string]context.CancelFunc
internalPublishKey string
rtmpPort string
thumbnailDir 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"}
const thumbnailCaptureInterval = 12 * time.Second
var supportedQualities = map[string]qualityProfile{
"720p": {
scale: "1280:-2",
videoBitrate: "2500k",
audioBitrate: "128k",
},
"480p": {
scale: "854:-2",
videoBitrate: "1200k",
audioBitrate: "96k",
},
}
type writeFlusher struct {
@@ -42,35 +84,52 @@ func (w writeFlusher) Flush() error {
return nil
}
// NewRTMPServer creates and initializes a new media server
func NewRTMPServer() *RTMPServer {
type bufferedWriteFlusher struct {
bufw *bufio.Writer
httpFlusher http.Flusher
}
func (w *bufferedWriteFlusher) Write(p []byte) (n int, err error) {
return w.bufw.Write(p)
}
func (w *bufferedWriteFlusher) Flush() error {
if err := w.bufw.Flush(); err != nil {
return err
}
w.httpFlusher.Flush()
return nil
}
// NewRTMPServer creates and initializes a new media server.
func NewRTMPServer(rtmpPort string) *RTMPServer {
s := &RTMPServer{
channels: make(map[string]*pubsub.Queue),
server: &rtmp.Server{},
channels: make(map[string]*pubsub.Queue),
transcoders: make(map[string][]*variantTranscoder),
thumbnailJobs: make(map[string]context.CancelFunc),
internalPublishKey: generateInternalPublishKey(),
rtmpPort: rtmpPort,
thumbnailDir: filepath.Join(os.TempDir(), "hightube-thumbnails"),
server: &rtmp.Server{},
}
// Triggered when a broadcaster (e.g., OBS) starts publishing
s.server.HandlePublish = func(conn *rtmp.Conn) {
streamPath := conn.URL.Path // Expected format: /live/{stream_key}
streamPath := conn.URL.Path // Expected format: /live/{stream_key} or /variant/{room_id}/{quality}/{token}
monitor.Infof("OBS publish attempt: %s", streamPath)
// Extract stream key from path
parts := strings.Split(streamPath, "/")
if len(parts) < 3 || parts[1] != "live" {
if len(parts) < 3 {
monitor.Warnf("Invalid publish path format: %s", streamPath)
return
}
streamKey := parts[2]
// Authenticate stream key
var room model.Room
if err := db.DB.Where("stream_key = ?", streamKey).First(&room).Error; err != nil {
monitor.Warnf("Invalid stream key: %s", streamKey)
return // Reject connection
roomID, channelPath, isSource, ok := s.resolvePublishPath(parts)
if !ok {
monitor.Warnf("Invalid publish key/path: %s", streamPath)
return
}
monitor.Infof("Stream authenticated for room_id=%d", room.ID)
// 1. Get audio/video stream metadata
streams, err := conn.Streams()
if err != nil {
@@ -78,31 +137,46 @@ func NewRTMPServer() *RTMPServer {
return
}
// 2. Map the active stream by Room ID so viewers can use /live/{room_id}
roomLivePath := fmt.Sprintf("/live/%d", room.ID)
monitor.Infof("Stream authenticated for room_id=%s path=%s", roomID, channelPath)
s.mutex.Lock()
q := pubsub.NewQueue()
q.WriteHeader(streams)
s.channels[roomLivePath] = q
s.channels[channelPath] = q
s.mutex.Unlock()
// Mark room as active in DB (using map to ensure true/false is correctly updated)
db.DB.Model(&room).Updates(map[string]interface{}{"is_active": true})
if isSource {
roomIDUint := parseRoomID(roomID)
if roomIDUint != 0 {
if err := db.SetRoomActive(roomIDUint, true); err != nil {
monitor.Warnf("Failed to mark room active room_id=%s: %v", roomID, err)
}
}
s.startVariantTranscoders(roomID)
s.startThumbnailCapture(roomID)
}
// 3. Cleanup on end
defer func() {
s.mutex.Lock()
delete(s.channels, roomLivePath)
delete(s.channels, channelPath)
s.mutex.Unlock()
q.Close()
// Explicitly set is_active to false using map
db.DB.Model(&room).Updates(map[string]interface{}{"is_active": false})
// Clear chat history for this room
chat.MainHub.ClearRoomHistory(fmt.Sprintf("%d", room.ID))
monitor.Infof("Publishing ended for room_id=%d", room.ID)
if isSource {
s.stopVariantTranscoders(roomID)
s.stopThumbnailCapture(roomID)
roomIDUint := parseRoomID(roomID)
if roomIDUint != 0 {
if err := db.SetRoomActive(roomIDUint, false); err != nil {
monitor.Warnf("Failed to mark room inactive room_id=%s: %v", roomID, err)
}
}
chat.MainHub.NotifyStreamEnded(roomID)
monitor.Infof("Publishing ended for room_id=%s", roomID)
} else {
monitor.Infof("Variant publishing ended for room_id=%s path=%s", roomID, channelPath)
}
}()
// 4. Continuously copy data packets to our broadcast queue
@@ -158,6 +232,9 @@ func (s *RTMPServer) Start(addr string) error {
// HandleHTTPFLV serves browser-compatible HTTP-FLV playback for web clients.
func (s *RTMPServer) HandleHTTPFLV(c *gin.Context) {
streamPath := fmt.Sprintf("/live/%s", c.Param("room_id"))
if quality := normalizeQuality(c.Query("quality")); quality != "" {
streamPath = fmt.Sprintf("%s/%s", streamPath, quality)
}
s.mutex.RLock()
q, ok := s.channels[streamPath]
@@ -182,13 +259,47 @@ func (s *RTMPServer) HandleHTTPFLV(c *gin.Context) {
c.Status(http.StatusOK)
flusher.Flush()
muxer := flv.NewMuxerWriteFlusher(writeFlusher{
// Coalesce the 3 internal write calls of WriteTag using a 4KB bufio.Writer
bufWriter := bufio.NewWriterSize(c.Writer, 4096)
bwf := &bufferedWriteFlusher{
bufw: bufWriter,
httpFlusher: flusher,
Writer: c.Writer,
})
}
muxer := flv.NewMuxerWriteFlusher(bwf)
cursor := q.Latest()
if err := avutil.CopyFile(muxer, cursor); err != nil && err != io.EOF {
// Write header first
streams, err := cursor.Streams()
if err != nil {
monitor.Errorf("HTTP-FLV failed to get cursor streams: %v", err)
return
}
if err = muxer.WriteHeader(streams); err != nil {
monitor.Errorf("HTTP-FLV failed to write header: %v", err)
return
}
if err = bwf.Flush(); err != nil {
return
}
// Read and write packet loop with per-packet flushing for low latency
for {
var pkt av.Packet
pkt, err = cursor.ReadPacket()
if err != nil {
break
}
if err = muxer.WritePacket(pkt); err != nil {
break
}
// Flush immediately so the frame is sent to the client (grouped write syscall)
if err = bwf.Flush(); err != nil {
break
}
}
if 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)
@@ -198,10 +309,250 @@ func (s *RTMPServer) HandleHTTPFLV(c *gin.Context) {
}
}
func (s *RTMPServer) HandleThumbnail(c *gin.Context) {
thumbnailPath := s.thumbnailPath(c.Param("room_id"))
if _, err := os.Stat(thumbnailPath); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Thumbnail not found"})
return
}
c.Header("Cache-Control", "no-store, no-cache, must-revalidate")
c.File(thumbnailPath)
}
func (s *RTMPServer) resolvePublishPath(parts []string) (roomID string, channelPath string, isSource bool, ok bool) {
if parts[1] == "live" && len(parts) == 3 {
room, err := db.LoadRoomByStreamKey(parts[2])
if 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:%s/live/%s", s.rtmpPort, roomID)
outputURL := fmt.Sprintf("rtmp://127.0.0.1:%s/variant/%s/%s/%s", s.rtmpPort, roomID, quality, s.internalPublishKey)
cmd := exec.CommandContext(
ctx,
"ffmpeg",
"-nostdin",
"-loglevel", "error",
"-fflags", "nobuffer",
"-i", inputURL,
"-vf", "scale="+profile.scale+":force_original_aspect_ratio=decrease",
"-c:v", "libx264",
"-preset", "ultrafast",
"-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 (s *RTMPServer) startThumbnailCapture(roomID string) {
s.stopThumbnailCapture(roomID)
if err := os.MkdirAll(s.thumbnailDir, 0o755); err != nil {
monitor.Errorf("Failed to create thumbnail directory: %v", err)
return
}
ctx, cancel := context.WithCancel(context.Background())
s.mutex.Lock()
s.thumbnailJobs[roomID] = cancel
s.mutex.Unlock()
go func() {
select {
case <-ctx.Done():
return
case <-time.After(3 * time.Second):
}
s.captureThumbnail(roomID)
ticker := time.NewTicker(thumbnailCaptureInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.captureThumbnail(roomID)
}
}
}()
}
func (s *RTMPServer) stopThumbnailCapture(roomID string) {
s.mutex.Lock()
cancel := s.thumbnailJobs[roomID]
delete(s.thumbnailJobs, roomID)
s.mutex.Unlock()
if cancel != nil {
cancel()
}
_ = os.Remove(s.thumbnailPath(roomID))
}
func (s *RTMPServer) captureThumbnail(roomID string) {
outputPath := s.thumbnailPath(roomID)
tempPath := outputPath + ".tmp.jpg"
defer os.Remove(tempPath)
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
cmd := exec.CommandContext(
ctx,
"ffmpeg",
"-y",
"-loglevel", "error",
"-rtmp_live", "live",
"-i", fmt.Sprintf("rtmp://127.0.0.1:%s/live/%s", s.rtmpPort, roomID),
"-frames:v", "1",
"-q:v", "4",
tempPath,
)
if err := cmd.Run(); err != nil {
if ctx.Err() == context.DeadlineExceeded {
monitor.Warnf("Thumbnail capture timed out for room_id=%s", roomID)
return
}
monitor.Warnf("Thumbnail capture failed for room_id=%s: %v", roomID, err)
return
}
_ = os.Remove(outputPath)
if err := os.Rename(tempPath, outputPath); err != nil {
monitor.Warnf("Failed to store thumbnail for room_id=%s: %v", roomID, err)
}
}
func (s *RTMPServer) thumbnailPath(roomID string) string {
return filepath.Join(s.thumbnailDir, fmt.Sprintf("%s.jpg", roomID))
}
func normalizeQuality(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
if _, ok := supportedQualities[value]; ok {
return value
}
return ""
}
func parseRoomID(value string) uint {
var roomID uint
_, _ = fmt.Sscanf(value, "%d", &roomID)
return roomID
}
func generateInternalPublishKey() string {
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
return "internal_publish_fallback_key"
}
return hex.EncodeToString(buf)
}
func (s *RTMPServer) ActiveStreamCount() int {
s.mutex.RLock()
defer s.mutex.RUnlock()
return len(s.channels)
count := 0
for path := range s.channels {
if strings.Count(path, "/") == 2 {
count++
}
}
return count
}
func (s *RTMPServer) AvailablePlaybackQualities(roomID string) []string {
s.mutex.RLock()
defer s.mutex.RUnlock()
basePath := fmt.Sprintf("/live/%s", roomID)
available := make([]string, 0, len(qualityOrder))
for _, quality := range qualityOrder {
streamPath := basePath
if quality != "source" {
streamPath = fmt.Sprintf("%s/%s", basePath, quality)
}
if _, ok := s.channels[streamPath]; ok {
available = append(available, quality)
}
}
return available
}
func (s *RTMPServer) ActiveStreamPaths() []string {
@@ -210,7 +561,9 @@ func (s *RTMPServer) ActiveStreamPaths() []string {
paths := make([]string, 0, len(s.channels))
for path := range s.channels {
paths = append(paths, path)
if strings.Count(path, "/") == 2 {
paths = append(paths, path)
}
}
return paths
}

View File

@@ -11,12 +11,12 @@ android {
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8.toString()
jvmTarget = JavaVersion.VERSION_17.toString()
}
defaultConfig {

View File

@@ -1,5 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<application
android:label="Hightube"
android:name="${applicationName}"

3
frontend/l10n.yaml Normal file
View File

@@ -0,0 +1,3 @@
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart

View File

@@ -0,0 +1,72 @@
{
"@@locale": "en",
"settings": "Settings",
"networkConfiguration": "Network Configuration",
"backendServerUrl": "Backend Server URL",
"saveNetworkSettings": "Save Network Settings",
"serverUrlUpdated": "Server URL Updated",
"themeCustomization": "Theme Customization",
"appearanceMode": "Appearance Mode",
"system": "System",
"light": "Light",
"dark": "Dark",
"accentColor": "Accent Color",
"explore": "Explore",
"livePreviewThumbnails": "Live Preview Thumbnails",
"livePreviewThumbnailsDesc": "Show cached snapshot covers for live rooms when available.",
"security": "Security",
"oldPassword": "Old Password",
"newPassword": "New Password",
"changePassword": "Change Password",
"logout": "Logout",
"confirmLogout": "Confirm Logout",
"confirmLogoutDesc": "Are you sure you want to log out now?",
"cancel": "Cancel",
"language": "Language",
"selectLanguage": "Select Language",
"english": "English",
"simplifiedChinese": "简体中文",
"traditionalChinese": "繁體中文",
"japanese": "日本語",
"console": "Console",
"failedToLoadRooms": "Failed to load rooms",
"goLive": "Go Live",
"noActiveRooms": "No active rooms. Be the first!",
"hostId": "Host ID",
"username": "Username",
"password": "Password",
"fillAllFields": "Please fill in all fields",
"networkError": "Network Error: Could not connect to server",
"loginFailed": "Login Failed",
"login": "LOGIN",
"dontHaveAccount": "Don't have an account? Create one",
"createAccount": "Create Account",
"joinHightube": "Join Hightube",
"desiredUsername": "Desired Username",
"register": "REGISTER",
"alreadyHaveAccount": "Already have an account? Login here",
"accountCreated": "Account created! Please login.",
"playbackResolution": "Playback Resolution",
"availableNow": "Available now",
"waitingForTranscoding": "Waiting for backend transcoding output",
"sendMessage": "Send a message...",
"liveStreamEnded": "The host has ended the live stream.",
"liveStreamEndedShort": "Live stream ended",
"liveChat": "Live Chat",
"refresh": "Refresh",
"volume": "Volume",
"danmakuOn": "Danmaku On",
"danmakuOff": "Danmaku Off",
"fullscreen": "Fullscreen",
"exitFullscreen": "Exit Fullscreen",
"resolution": "Resolution",
"playbackOptionsDesc": "Select an available transcoded stream.",
"sourceOnlyDesc": "Only the source stream is available right now.",
"myStreamConsole": "My Stream Console",
"noRoomInfo": "No room info found.",
"roomTitle": "Room Title",
"rtmpServerUrl": "RTMP Server URL",
"streamKey": "Stream Key (Keep Secret!)",
"copiedToClipboard": "Copied to clipboard",
"failedToFetchRoomInfo": "Failed to fetch room info"
}

View File

@@ -0,0 +1,72 @@
{
"@@locale": "ja",
"settings": "設定",
"networkConfiguration": "ネットワーク設定",
"backendServerUrl": "バックエンドサーバーURL",
"saveNetworkSettings": "ネットワーク設定を保存",
"serverUrlUpdated": "サーバーURLが更新されました",
"themeCustomization": "テーマのカスタマイズ",
"appearanceMode": "外観モード",
"system": "システム",
"light": "ライト",
"dark": "ダーク",
"accentColor": "アクセントカラー",
"explore": "探索",
"livePreviewThumbnails": "ライブプレビューサムネイル",
"livePreviewThumbnailsDesc": "利用可能な場合、ライブルームのキャッシュされたスナップショットカバーを表示します。",
"security": "セキュリティ",
"oldPassword": "現在のパスワード",
"newPassword": "新しいパスワード",
"changePassword": "パスワードを変更",
"logout": "ログアウト",
"confirmLogout": "ログアウトの確認",
"confirmLogoutDesc": "今すぐログアウトしてもよろしいですか?",
"cancel": "キャンセル",
"language": "言語",
"selectLanguage": "言語を選択",
"english": "English",
"simplifiedChinese": "简体中文",
"traditionalChinese": "繁體中文",
"japanese": "日本語",
"console": "コンソール",
"failedToLoadRooms": "ルームの読み込みに失敗しました",
"goLive": "ライブ配信を開始",
"noActiveRooms": "配信中のルームはありません。最初の配信者になりましょう!",
"hostId": "配信者 ID",
"username": "ユーザー名",
"password": "パスワード",
"fillAllFields": "すべての項目を入力してください",
"networkError": "ネットワークエラー:サーバーに接続できませんでした",
"loginFailed": "ログインに失敗しました",
"login": "ログイン",
"dontHaveAccount": "アカウントをお持ちでないですか?新規登録",
"createAccount": "アカウント作成",
"joinHightube": "Hightube に参加",
"desiredUsername": "ユーザー名",
"register": "登録",
"alreadyHaveAccount": "既にアカウントをお持ちですか?ログイン",
"accountCreated": "アカウントが作成されました!ログインしてください。",
"playbackResolution": "再生解像度",
"availableNow": "利用可能",
"waitingForTranscoding": "バックエンドのトランスコード出力を待機中",
"sendMessage": "メッセージを送信...",
"liveStreamEnded": "配信者が退出したため、ライブ配信は終了しました。",
"liveStreamEndedShort": "ライブ配信は終了しました",
"liveChat": "ライブチャット",
"refresh": "更新",
"volume": "音量",
"danmakuOn": "弾幕オン",
"danmakuOff": "弾幕オフ",
"fullscreen": "全画面",
"exitFullscreen": "全画面終了",
"resolution": "解像度",
"playbackOptionsDesc": "利用可能なトランスコード済みストリームを選択します。",
"sourceOnlyDesc": "現在、ソースストリームのみが利用可能です。",
"myStreamConsole": "配信コンソール",
"noRoomInfo": "ルーム情報が見つかりません。",
"roomTitle": "ルームタイトル",
"rtmpServerUrl": "RTMP サーバー URL",
"streamKey": "ストリームキー (秘密にしてください!)",
"copiedToClipboard": "クリップボードにコピーしました",
"failedToFetchRoomInfo": "ルーム情報の取得に失敗しました"
}

View File

@@ -0,0 +1,565 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:intl/intl.dart' as intl;
import 'app_localizations_en.dart';
import 'app_localizations_ja.dart';
import 'app_localizations_zh.dart';
// ignore_for_file: type=lint
/// Callers can lookup localized strings with an instance of AppLocalizations
/// returned by `AppLocalizations.of(context)`.
///
/// Applications need to include `AppLocalizations.delegate()` in their app's
/// `localizationDelegates` list, and the locales they support in the app's
/// `supportedLocales` list. For example:
///
/// ```dart
/// import 'l10n/app_localizations.dart';
///
/// return MaterialApp(
/// localizationsDelegates: AppLocalizations.localizationsDelegates,
/// supportedLocales: AppLocalizations.supportedLocales,
/// home: MyApplicationHome(),
/// );
/// ```
///
/// ## Update pubspec.yaml
///
/// Please make sure to update your pubspec.yaml to include the following
/// packages:
///
/// ```yaml
/// dependencies:
/// # Internationalization support.
/// flutter_localizations:
/// sdk: flutter
/// intl: any # Use the pinned version from flutter_localizations
///
/// # Rest of dependencies
/// ```
///
/// ## iOS Applications
///
/// iOS applications define key application metadata, including supported
/// locales, in an Info.plist file that is built into the application bundle.
/// To configure the locales supported by your app, youll need to edit this
/// file.
///
/// First, open your projects ios/Runner.xcworkspace Xcode workspace file.
/// Then, in the Project Navigator, open the Info.plist file under the Runner
/// projects Runner folder.
///
/// Next, select the Information Property List item, select Add Item from the
/// Editor menu, then select Localizations from the pop-up menu.
///
/// Select and expand the newly-created Localizations item then, for each
/// locale your application supports, add a new item and select the locale
/// you wish to add from the pop-up menu in the Value field. This list should
/// be consistent with the languages listed in the AppLocalizations.supportedLocales
/// property.
abstract class AppLocalizations {
AppLocalizations(String locale)
: localeName = intl.Intl.canonicalizedLocale(locale.toString());
final String localeName;
static AppLocalizations? of(BuildContext context) {
return Localizations.of<AppLocalizations>(context, AppLocalizations);
}
static const LocalizationsDelegate<AppLocalizations> delegate =
_AppLocalizationsDelegate();
/// A list of this localizations delegate along with the default localizations
/// delegates.
///
/// Returns a list of localizations delegates containing this delegate along with
/// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate,
/// and GlobalWidgetsLocalizations.delegate.
///
/// Additional delegates can be added by appending to this list in
/// MaterialApp. This list does not have to be used at all if a custom list
/// of delegates is preferred or required.
static const List<LocalizationsDelegate<dynamic>> localizationsDelegates =
<LocalizationsDelegate<dynamic>>[
delegate,
GlobalMaterialLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
];
/// A list of this localizations delegate's supported locales.
static const List<Locale> supportedLocales = <Locale>[
Locale('en'),
Locale('ja'),
Locale('zh'),
Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant'),
];
/// No description provided for @settings.
///
/// In en, this message translates to:
/// **'Settings'**
String get settings;
/// No description provided for @networkConfiguration.
///
/// In en, this message translates to:
/// **'Network Configuration'**
String get networkConfiguration;
/// No description provided for @backendServerUrl.
///
/// In en, this message translates to:
/// **'Backend Server URL'**
String get backendServerUrl;
/// No description provided for @saveNetworkSettings.
///
/// In en, this message translates to:
/// **'Save Network Settings'**
String get saveNetworkSettings;
/// No description provided for @serverUrlUpdated.
///
/// In en, this message translates to:
/// **'Server URL Updated'**
String get serverUrlUpdated;
/// No description provided for @themeCustomization.
///
/// In en, this message translates to:
/// **'Theme Customization'**
String get themeCustomization;
/// No description provided for @appearanceMode.
///
/// In en, this message translates to:
/// **'Appearance Mode'**
String get appearanceMode;
/// No description provided for @system.
///
/// In en, this message translates to:
/// **'System'**
String get system;
/// No description provided for @light.
///
/// In en, this message translates to:
/// **'Light'**
String get light;
/// No description provided for @dark.
///
/// In en, this message translates to:
/// **'Dark'**
String get dark;
/// No description provided for @accentColor.
///
/// In en, this message translates to:
/// **'Accent Color'**
String get accentColor;
/// No description provided for @explore.
///
/// In en, this message translates to:
/// **'Explore'**
String get explore;
/// No description provided for @livePreviewThumbnails.
///
/// In en, this message translates to:
/// **'Live Preview Thumbnails'**
String get livePreviewThumbnails;
/// No description provided for @livePreviewThumbnailsDesc.
///
/// In en, this message translates to:
/// **'Show cached snapshot covers for live rooms when available.'**
String get livePreviewThumbnailsDesc;
/// No description provided for @security.
///
/// In en, this message translates to:
/// **'Security'**
String get security;
/// No description provided for @oldPassword.
///
/// In en, this message translates to:
/// **'Old Password'**
String get oldPassword;
/// No description provided for @newPassword.
///
/// In en, this message translates to:
/// **'New Password'**
String get newPassword;
/// No description provided for @changePassword.
///
/// In en, this message translates to:
/// **'Change Password'**
String get changePassword;
/// No description provided for @logout.
///
/// In en, this message translates to:
/// **'Logout'**
String get logout;
/// No description provided for @confirmLogout.
///
/// In en, this message translates to:
/// **'Confirm Logout'**
String get confirmLogout;
/// No description provided for @confirmLogoutDesc.
///
/// In en, this message translates to:
/// **'Are you sure you want to log out now?'**
String get confirmLogoutDesc;
/// No description provided for @cancel.
///
/// In en, this message translates to:
/// **'Cancel'**
String get cancel;
/// No description provided for @language.
///
/// In en, this message translates to:
/// **'Language'**
String get language;
/// No description provided for @selectLanguage.
///
/// In en, this message translates to:
/// **'Select Language'**
String get selectLanguage;
/// No description provided for @english.
///
/// In en, this message translates to:
/// **'English'**
String get english;
/// No description provided for @simplifiedChinese.
///
/// In en, this message translates to:
/// **'简体中文'**
String get simplifiedChinese;
/// No description provided for @traditionalChinese.
///
/// In en, this message translates to:
/// **'繁體中文'**
String get traditionalChinese;
/// No description provided for @japanese.
///
/// In en, this message translates to:
/// **'日本語'**
String get japanese;
/// No description provided for @console.
///
/// In en, this message translates to:
/// **'Console'**
String get console;
/// No description provided for @failedToLoadRooms.
///
/// In en, this message translates to:
/// **'Failed to load rooms'**
String get failedToLoadRooms;
/// No description provided for @goLive.
///
/// In en, this message translates to:
/// **'Go Live'**
String get goLive;
/// No description provided for @noActiveRooms.
///
/// In en, this message translates to:
/// **'No active rooms. Be the first!'**
String get noActiveRooms;
/// No description provided for @hostId.
///
/// In en, this message translates to:
/// **'Host ID'**
String get hostId;
/// No description provided for @username.
///
/// In en, this message translates to:
/// **'Username'**
String get username;
/// No description provided for @password.
///
/// In en, this message translates to:
/// **'Password'**
String get password;
/// No description provided for @fillAllFields.
///
/// In en, this message translates to:
/// **'Please fill in all fields'**
String get fillAllFields;
/// No description provided for @networkError.
///
/// In en, this message translates to:
/// **'Network Error: Could not connect to server'**
String get networkError;
/// No description provided for @loginFailed.
///
/// In en, this message translates to:
/// **'Login Failed'**
String get loginFailed;
/// No description provided for @login.
///
/// In en, this message translates to:
/// **'LOGIN'**
String get login;
/// No description provided for @dontHaveAccount.
///
/// In en, this message translates to:
/// **'Don\'t have an account? Create one'**
String get dontHaveAccount;
/// No description provided for @createAccount.
///
/// In en, this message translates to:
/// **'Create Account'**
String get createAccount;
/// No description provided for @joinHightube.
///
/// In en, this message translates to:
/// **'Join Hightube'**
String get joinHightube;
/// No description provided for @desiredUsername.
///
/// In en, this message translates to:
/// **'Desired Username'**
String get desiredUsername;
/// No description provided for @register.
///
/// In en, this message translates to:
/// **'REGISTER'**
String get register;
/// No description provided for @alreadyHaveAccount.
///
/// In en, this message translates to:
/// **'Already have an account? Login here'**
String get alreadyHaveAccount;
/// No description provided for @accountCreated.
///
/// In en, this message translates to:
/// **'Account created! Please login.'**
String get accountCreated;
/// No description provided for @playbackResolution.
///
/// In en, this message translates to:
/// **'Playback Resolution'**
String get playbackResolution;
/// No description provided for @availableNow.
///
/// In en, this message translates to:
/// **'Available now'**
String get availableNow;
/// No description provided for @waitingForTranscoding.
///
/// In en, this message translates to:
/// **'Waiting for backend transcoding output'**
String get waitingForTranscoding;
/// No description provided for @sendMessage.
///
/// In en, this message translates to:
/// **'Send a message...'**
String get sendMessage;
/// No description provided for @liveStreamEnded.
///
/// In en, this message translates to:
/// **'The host has ended the live stream.'**
String get liveStreamEnded;
/// No description provided for @liveStreamEndedShort.
///
/// In en, this message translates to:
/// **'Live stream ended'**
String get liveStreamEndedShort;
/// No description provided for @liveChat.
///
/// In en, this message translates to:
/// **'Live Chat'**
String get liveChat;
/// No description provided for @refresh.
///
/// In en, this message translates to:
/// **'Refresh'**
String get refresh;
/// No description provided for @volume.
///
/// In en, this message translates to:
/// **'Volume'**
String get volume;
/// No description provided for @danmakuOn.
///
/// In en, this message translates to:
/// **'Danmaku On'**
String get danmakuOn;
/// No description provided for @danmakuOff.
///
/// In en, this message translates to:
/// **'Danmaku Off'**
String get danmakuOff;
/// No description provided for @fullscreen.
///
/// In en, this message translates to:
/// **'Fullscreen'**
String get fullscreen;
/// No description provided for @exitFullscreen.
///
/// In en, this message translates to:
/// **'Exit Fullscreen'**
String get exitFullscreen;
/// No description provided for @resolution.
///
/// In en, this message translates to:
/// **'Resolution'**
String get resolution;
/// No description provided for @playbackOptionsDesc.
///
/// In en, this message translates to:
/// **'Select an available transcoded stream.'**
String get playbackOptionsDesc;
/// No description provided for @sourceOnlyDesc.
///
/// In en, this message translates to:
/// **'Only the source stream is available right now.'**
String get sourceOnlyDesc;
/// No description provided for @myStreamConsole.
///
/// In en, this message translates to:
/// **'My Stream Console'**
String get myStreamConsole;
/// No description provided for @noRoomInfo.
///
/// In en, this message translates to:
/// **'No room info found.'**
String get noRoomInfo;
/// No description provided for @roomTitle.
///
/// In en, this message translates to:
/// **'Room Title'**
String get roomTitle;
/// No description provided for @rtmpServerUrl.
///
/// In en, this message translates to:
/// **'RTMP Server URL'**
String get rtmpServerUrl;
/// No description provided for @streamKey.
///
/// In en, this message translates to:
/// **'Stream Key (Keep Secret!)'**
String get streamKey;
/// No description provided for @copiedToClipboard.
///
/// In en, this message translates to:
/// **'Copied to clipboard'**
String get copiedToClipboard;
/// No description provided for @failedToFetchRoomInfo.
///
/// In en, this message translates to:
/// **'Failed to fetch room info'**
String get failedToFetchRoomInfo;
}
class _AppLocalizationsDelegate
extends LocalizationsDelegate<AppLocalizations> {
const _AppLocalizationsDelegate();
@override
Future<AppLocalizations> load(Locale locale) {
return SynchronousFuture<AppLocalizations>(lookupAppLocalizations(locale));
}
@override
bool isSupported(Locale locale) =>
<String>['en', 'ja', 'zh'].contains(locale.languageCode);
@override
bool shouldReload(_AppLocalizationsDelegate old) => false;
}
AppLocalizations lookupAppLocalizations(Locale locale) {
// Lookup logic when language+script codes are specified.
switch (locale.languageCode) {
case 'zh':
{
switch (locale.scriptCode) {
case 'Hant':
return AppLocalizationsZhHant();
}
break;
}
}
// Lookup logic when only language code is specified.
switch (locale.languageCode) {
case 'en':
return AppLocalizationsEn();
case 'ja':
return AppLocalizationsJa();
case 'zh':
return AppLocalizationsZh();
}
throw FlutterError(
'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely '
'an issue with the localizations generation tool. Please file an issue '
'on GitHub with a reproducible sample app and the gen-l10n configuration '
'that was used.',
);
}

View File

@@ -0,0 +1,218 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
// ignore_for_file: type=lint
/// The translations for English (`en`).
class AppLocalizationsEn extends AppLocalizations {
AppLocalizationsEn([String locale = 'en']) : super(locale);
@override
String get settings => 'Settings';
@override
String get networkConfiguration => 'Network Configuration';
@override
String get backendServerUrl => 'Backend Server URL';
@override
String get saveNetworkSettings => 'Save Network Settings';
@override
String get serverUrlUpdated => 'Server URL Updated';
@override
String get themeCustomization => 'Theme Customization';
@override
String get appearanceMode => 'Appearance Mode';
@override
String get system => 'System';
@override
String get light => 'Light';
@override
String get dark => 'Dark';
@override
String get accentColor => 'Accent Color';
@override
String get explore => 'Explore';
@override
String get livePreviewThumbnails => 'Live Preview Thumbnails';
@override
String get livePreviewThumbnailsDesc =>
'Show cached snapshot covers for live rooms when available.';
@override
String get security => 'Security';
@override
String get oldPassword => 'Old Password';
@override
String get newPassword => 'New Password';
@override
String get changePassword => 'Change Password';
@override
String get logout => 'Logout';
@override
String get confirmLogout => 'Confirm Logout';
@override
String get confirmLogoutDesc => 'Are you sure you want to log out now?';
@override
String get cancel => 'Cancel';
@override
String get language => 'Language';
@override
String get selectLanguage => 'Select Language';
@override
String get english => 'English';
@override
String get simplifiedChinese => '简体中文';
@override
String get traditionalChinese => '繁體中文';
@override
String get japanese => '日本語';
@override
String get console => 'Console';
@override
String get failedToLoadRooms => 'Failed to load rooms';
@override
String get goLive => 'Go Live';
@override
String get noActiveRooms => 'No active rooms. Be the first!';
@override
String get hostId => 'Host ID';
@override
String get username => 'Username';
@override
String get password => 'Password';
@override
String get fillAllFields => 'Please fill in all fields';
@override
String get networkError => 'Network Error: Could not connect to server';
@override
String get loginFailed => 'Login Failed';
@override
String get login => 'LOGIN';
@override
String get dontHaveAccount => 'Don\'t have an account? Create one';
@override
String get createAccount => 'Create Account';
@override
String get joinHightube => 'Join Hightube';
@override
String get desiredUsername => 'Desired Username';
@override
String get register => 'REGISTER';
@override
String get alreadyHaveAccount => 'Already have an account? Login here';
@override
String get accountCreated => 'Account created! Please login.';
@override
String get playbackResolution => 'Playback Resolution';
@override
String get availableNow => 'Available now';
@override
String get waitingForTranscoding => 'Waiting for backend transcoding output';
@override
String get sendMessage => 'Send a message...';
@override
String get liveStreamEnded => 'The host has ended the live stream.';
@override
String get liveStreamEndedShort => 'Live stream ended';
@override
String get liveChat => 'Live Chat';
@override
String get refresh => 'Refresh';
@override
String get volume => 'Volume';
@override
String get danmakuOn => 'Danmaku On';
@override
String get danmakuOff => 'Danmaku Off';
@override
String get fullscreen => 'Fullscreen';
@override
String get exitFullscreen => 'Exit Fullscreen';
@override
String get resolution => 'Resolution';
@override
String get playbackOptionsDesc => 'Select an available transcoded stream.';
@override
String get sourceOnlyDesc => 'Only the source stream is available right now.';
@override
String get myStreamConsole => 'My Stream Console';
@override
String get noRoomInfo => 'No room info found.';
@override
String get roomTitle => 'Room Title';
@override
String get rtmpServerUrl => 'RTMP Server URL';
@override
String get streamKey => 'Stream Key (Keep Secret!)';
@override
String get copiedToClipboard => 'Copied to clipboard';
@override
String get failedToFetchRoomInfo => 'Failed to fetch room info';
}

View File

@@ -0,0 +1,218 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
// ignore_for_file: type=lint
/// The translations for Japanese (`ja`).
class AppLocalizationsJa extends AppLocalizations {
AppLocalizationsJa([String locale = 'ja']) : super(locale);
@override
String get settings => '設定';
@override
String get networkConfiguration => 'ネットワーク設定';
@override
String get backendServerUrl => 'バックエンドサーバーURL';
@override
String get saveNetworkSettings => 'ネットワーク設定を保存';
@override
String get serverUrlUpdated => 'サーバーURLが更新されました';
@override
String get themeCustomization => 'テーマのカスタマイズ';
@override
String get appearanceMode => '外観モード';
@override
String get system => 'システム';
@override
String get light => 'ライト';
@override
String get dark => 'ダーク';
@override
String get accentColor => 'アクセントカラー';
@override
String get explore => '探索';
@override
String get livePreviewThumbnails => 'ライブプレビューサムネイル';
@override
String get livePreviewThumbnailsDesc =>
'利用可能な場合、ライブルームのキャッシュされたスナップショットカバーを表示します。';
@override
String get security => 'セキュリティ';
@override
String get oldPassword => '現在のパスワード';
@override
String get newPassword => '新しいパスワード';
@override
String get changePassword => 'パスワードを変更';
@override
String get logout => 'ログアウト';
@override
String get confirmLogout => 'ログアウトの確認';
@override
String get confirmLogoutDesc => '今すぐログアウトしてもよろしいですか?';
@override
String get cancel => 'キャンセル';
@override
String get language => '言語';
@override
String get selectLanguage => '言語を選択';
@override
String get english => 'English';
@override
String get simplifiedChinese => '简体中文';
@override
String get traditionalChinese => '繁體中文';
@override
String get japanese => '日本語';
@override
String get console => 'コンソール';
@override
String get failedToLoadRooms => 'ルームの読み込みに失敗しました';
@override
String get goLive => 'ライブ配信を開始';
@override
String get noActiveRooms => '配信中のルームはありません。最初の配信者になりましょう!';
@override
String get hostId => '配信者 ID';
@override
String get username => 'ユーザー名';
@override
String get password => 'パスワード';
@override
String get fillAllFields => 'すべての項目を入力してください';
@override
String get networkError => 'ネットワークエラー:サーバーに接続できませんでした';
@override
String get loginFailed => 'ログインに失敗しました';
@override
String get login => 'ログイン';
@override
String get dontHaveAccount => 'アカウントをお持ちでないですか?新規登録';
@override
String get createAccount => 'アカウント作成';
@override
String get joinHightube => 'Hightube に参加';
@override
String get desiredUsername => 'ユーザー名';
@override
String get register => '登録';
@override
String get alreadyHaveAccount => '既にアカウントをお持ちですか?ログイン';
@override
String get accountCreated => 'アカウントが作成されました!ログインしてください。';
@override
String get playbackResolution => '再生解像度';
@override
String get availableNow => '利用可能';
@override
String get waitingForTranscoding => 'バックエンドのトランスコード出力を待機中';
@override
String get sendMessage => 'メッセージを送信...';
@override
String get liveStreamEnded => '配信者が退出したため、ライブ配信は終了しました。';
@override
String get liveStreamEndedShort => 'ライブ配信は終了しました';
@override
String get liveChat => 'ライブチャット';
@override
String get refresh => '更新';
@override
String get volume => '音量';
@override
String get danmakuOn => '弾幕オン';
@override
String get danmakuOff => '弾幕オフ';
@override
String get fullscreen => '全画面';
@override
String get exitFullscreen => '全画面終了';
@override
String get resolution => '解像度';
@override
String get playbackOptionsDesc => '利用可能なトランスコード済みストリームを選択します。';
@override
String get sourceOnlyDesc => '現在、ソースストリームのみが利用可能です。';
@override
String get myStreamConsole => '配信コンソール';
@override
String get noRoomInfo => 'ルーム情報が見つかりません。';
@override
String get roomTitle => 'ルームタイトル';
@override
String get rtmpServerUrl => 'RTMP サーバー URL';
@override
String get streamKey => 'ストリームキー (秘密にしてください!)';
@override
String get copiedToClipboard => 'クリップボードにコピーしました';
@override
String get failedToFetchRoomInfo => 'ルーム情報の取得に失敗しました';
}

View File

@@ -0,0 +1,429 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
// ignore_for_file: type=lint
/// The translations for Chinese (`zh`).
class AppLocalizationsZh extends AppLocalizations {
AppLocalizationsZh([String locale = 'zh']) : super(locale);
@override
String get settings => '设置';
@override
String get networkConfiguration => '网络配置';
@override
String get backendServerUrl => '后端服务器地址';
@override
String get saveNetworkSettings => '保存网络设置';
@override
String get serverUrlUpdated => '服务器地址已更新';
@override
String get themeCustomization => '主题自定义';
@override
String get appearanceMode => '外观模式';
@override
String get system => '系统';
@override
String get light => '浅色';
@override
String get dark => '深色';
@override
String get accentColor => '强调色';
@override
String get explore => '探索';
@override
String get livePreviewThumbnails => '直播预览图';
@override
String get livePreviewThumbnailsDesc => '在可用时显示直播房间的缓存快照封面。';
@override
String get security => '安全';
@override
String get oldPassword => '旧密码';
@override
String get newPassword => '新密码';
@override
String get changePassword => '修改密码';
@override
String get logout => '退出登录';
@override
String get confirmLogout => '确认退出';
@override
String get confirmLogoutDesc => '您确定现在要退出登录吗?';
@override
String get cancel => '取消';
@override
String get language => '语言';
@override
String get selectLanguage => '选择语言';
@override
String get english => 'English';
@override
String get simplifiedChinese => '简体中文';
@override
String get traditionalChinese => '繁體中文';
@override
String get japanese => '日本語';
@override
String get console => '控制台';
@override
String get failedToLoadRooms => '加载房间失败';
@override
String get goLive => '开始直播';
@override
String get noActiveRooms => '暂无直播房间。快来开播吧!';
@override
String get hostId => '主播 ID';
@override
String get username => '用户名';
@override
String get password => '密码';
@override
String get fillAllFields => '请填写所有字段';
@override
String get networkError => '网络错误:无法连接到服务器';
@override
String get loginFailed => '登录失败';
@override
String get login => '登录';
@override
String get dontHaveAccount => '没有账号?立即注册';
@override
String get createAccount => '创建账号';
@override
String get joinHightube => '加入 Hightube';
@override
String get desiredUsername => '用户名';
@override
String get register => '注册';
@override
String get alreadyHaveAccount => '已有账号?立即登录';
@override
String get accountCreated => '账号创建成功!请登录。';
@override
String get playbackResolution => '播放分辨率';
@override
String get availableNow => '当前可用';
@override
String get waitingForTranscoding => '正在等待后端转码输出';
@override
String get sendMessage => '发送消息...';
@override
String get liveStreamEnded => '主播已退出,直播已结束。';
@override
String get liveStreamEndedShort => '直播已结束';
@override
String get liveChat => '实时聊天';
@override
String get refresh => '刷新';
@override
String get volume => '音量';
@override
String get danmakuOn => '弹幕开启';
@override
String get danmakuOff => '弹幕关闭';
@override
String get fullscreen => '全屏';
@override
String get exitFullscreen => '退出全屏';
@override
String get resolution => '分辨率';
@override
String get playbackOptionsDesc => '选择可用的转码流。';
@override
String get sourceOnlyDesc => '目前仅源流可用。';
@override
String get myStreamConsole => '我的直播控制台';
@override
String get noRoomInfo => '未找到房间信息。';
@override
String get roomTitle => '房间标题';
@override
String get rtmpServerUrl => 'RTMP 服务器地址';
@override
String get streamKey => '推流码 (请务必保密!)';
@override
String get copiedToClipboard => '已复制到剪贴板';
@override
String get failedToFetchRoomInfo => '获取房间信息失败';
}
/// The translations for Chinese, using the Han script (`zh_Hant`).
class AppLocalizationsZhHant extends AppLocalizationsZh {
AppLocalizationsZhHant() : super('zh_Hant');
@override
String get settings => '設定';
@override
String get networkConfiguration => '網路設定';
@override
String get backendServerUrl => '後端伺服器地址';
@override
String get saveNetworkSettings => '儲存網路設定';
@override
String get serverUrlUpdated => '伺服器地址已更新';
@override
String get themeCustomization => '主題自訂';
@override
String get appearanceMode => '外觀模式';
@override
String get system => '系統';
@override
String get light => '淺色';
@override
String get dark => '深色';
@override
String get accentColor => '強調色';
@override
String get explore => '探索';
@override
String get livePreviewThumbnails => '直播預覽圖';
@override
String get livePreviewThumbnailsDesc => '在可用時顯示直播房間的快取快照封面。';
@override
String get security => '安全';
@override
String get oldPassword => '舊密碼';
@override
String get newPassword => '新密碼';
@override
String get changePassword => '修改密碼';
@override
String get logout => '登出';
@override
String get confirmLogout => '確認登出';
@override
String get confirmLogoutDesc => '您確定現在要登出嗎?';
@override
String get cancel => '取消';
@override
String get language => '語言';
@override
String get selectLanguage => '選擇語言';
@override
String get english => 'English';
@override
String get simplifiedChinese => '简体中文';
@override
String get traditionalChinese => '繁體中文';
@override
String get japanese => '日本語';
@override
String get console => '控制台';
@override
String get failedToLoadRooms => '載入房間失敗';
@override
String get goLive => '開始直播';
@override
String get noActiveRooms => '暫無直播房間。快來開播吧!';
@override
String get hostId => '主播 ID';
@override
String get username => '用戶名';
@override
String get password => '密碼';
@override
String get fillAllFields => '請填寫所有欄位';
@override
String get networkError => '網路錯誤:無法連接到伺服器';
@override
String get loginFailed => '登錄失敗';
@override
String get login => '登錄';
@override
String get dontHaveAccount => '沒有帳號?立即註冊';
@override
String get createAccount => '建立帳號';
@override
String get joinHightube => '加入 Hightube';
@override
String get desiredUsername => '用戶名';
@override
String get register => '註冊';
@override
String get alreadyHaveAccount => '已有帳號?立即登錄';
@override
String get accountCreated => '帳號建立成功!請登錄。';
@override
String get playbackResolution => '播放解析度';
@override
String get availableNow => '目前可用';
@override
String get waitingForTranscoding => '正在等待後端轉碼輸出';
@override
String get sendMessage => '發送訊息...';
@override
String get liveStreamEnded => '主播已退出,直播已結束。';
@override
String get liveStreamEndedShort => '直播已結束';
@override
String get liveChat => '即時聊天';
@override
String get refresh => '重新整理';
@override
String get volume => '音量';
@override
String get danmakuOn => '彈幕開啟';
@override
String get danmakuOff => '彈幕關閉';
@override
String get fullscreen => '全屏';
@override
String get exitFullscreen => '退出全屏';
@override
String get resolution => '解析度';
@override
String get playbackOptionsDesc => '選擇可用的轉碼流。';
@override
String get sourceOnlyDesc => '目前僅源流可用。';
@override
String get myStreamConsole => '我的直播控制台';
@override
String get noRoomInfo => '未找到房間資訊。';
@override
String get roomTitle => '房間標題';
@override
String get rtmpServerUrl => 'RTMP 伺服器地址';
@override
String get streamKey => '推流碼 (請務必保密!)';
@override
String get copiedToClipboard => '已複製到剪貼板';
@override
String get failedToFetchRoomInfo => '獲取房間資訊失敗';
}

View File

@@ -0,0 +1,72 @@
{
"@@locale": "zh",
"settings": "设置",
"networkConfiguration": "网络配置",
"backendServerUrl": "后端服务器地址",
"saveNetworkSettings": "保存网络设置",
"serverUrlUpdated": "服务器地址已更新",
"themeCustomization": "主题自定义",
"appearanceMode": "外观模式",
"system": "系统",
"light": "浅色",
"dark": "深色",
"accentColor": "强调色",
"explore": "探索",
"livePreviewThumbnails": "直播预览图",
"livePreviewThumbnailsDesc": "在可用时显示直播房间的缓存快照封面。",
"security": "安全",
"oldPassword": "旧密码",
"newPassword": "新密码",
"changePassword": "修改密码",
"logout": "退出登录",
"confirmLogout": "确认退出",
"confirmLogoutDesc": "您确定现在要退出登录吗?",
"cancel": "取消",
"language": "语言",
"selectLanguage": "选择语言",
"english": "English",
"simplifiedChinese": "简体中文",
"traditionalChinese": "繁體中文",
"japanese": "日本語",
"console": "控制台",
"failedToLoadRooms": "加载房间失败",
"goLive": "开始直播",
"noActiveRooms": "暂无直播房间。快来开播吧!",
"hostId": "主播 ID",
"username": "用户名",
"password": "密码",
"fillAllFields": "请填写所有字段",
"networkError": "网络错误:无法连接到服务器",
"loginFailed": "登录失败",
"login": "登录",
"dontHaveAccount": "没有账号?立即注册",
"createAccount": "创建账号",
"joinHightube": "加入 Hightube",
"desiredUsername": "用户名",
"register": "注册",
"alreadyHaveAccount": "已有账号?立即登录",
"accountCreated": "账号创建成功!请登录。",
"playbackResolution": "播放分辨率",
"availableNow": "当前可用",
"waitingForTranscoding": "正在等待后端转码输出",
"sendMessage": "发送消息...",
"liveStreamEnded": "主播已退出,直播已结束。",
"liveStreamEndedShort": "直播已结束",
"liveChat": "实时聊天",
"refresh": "刷新",
"volume": "音量",
"danmakuOn": "弹幕开启",
"danmakuOff": "弹幕关闭",
"fullscreen": "全屏",
"exitFullscreen": "退出全屏",
"resolution": "分辨率",
"playbackOptionsDesc": "选择可用的转码流。",
"sourceOnlyDesc": "目前仅源流可用。",
"myStreamConsole": "我的直播控制台",
"noRoomInfo": "未找到房间信息。",
"roomTitle": "房间标题",
"rtmpServerUrl": "RTMP 服务器地址",
"streamKey": "推流码 (请务必保密!)",
"copiedToClipboard": "已复制到剪贴板",
"failedToFetchRoomInfo": "获取房间信息失败"
}

View File

@@ -0,0 +1,72 @@
{
"@@locale": "zh_Hant",
"settings": "設定",
"networkConfiguration": "網路設定",
"backendServerUrl": "後端伺服器地址",
"saveNetworkSettings": "儲存網路設定",
"serverUrlUpdated": "伺服器地址已更新",
"themeCustomization": "主題自訂",
"appearanceMode": "外觀模式",
"system": "系統",
"light": "淺色",
"dark": "深色",
"accentColor": "強調色",
"explore": "探索",
"livePreviewThumbnails": "直播預覽圖",
"livePreviewThumbnailsDesc": "在可用時顯示直播房間的快取快照封面。",
"security": "安全",
"oldPassword": "舊密碼",
"newPassword": "新密碼",
"changePassword": "修改密碼",
"logout": "登出",
"confirmLogout": "確認登出",
"confirmLogoutDesc": "您確定現在要登出嗎?",
"cancel": "取消",
"language": "語言",
"selectLanguage": "選擇語言",
"english": "English",
"simplifiedChinese": "简体中文",
"traditionalChinese": "繁體中文",
"japanese": "日本語",
"console": "控制台",
"failedToLoadRooms": "載入房間失敗",
"goLive": "開始直播",
"noActiveRooms": "暫無直播房間。快來開播吧!",
"hostId": "主播 ID",
"username": "用戶名",
"password": "密碼",
"fillAllFields": "請填寫所有欄位",
"networkError": "網路錯誤:無法連接到伺服器",
"loginFailed": "登錄失敗",
"login": "登錄",
"dontHaveAccount": "沒有帳號?立即註冊",
"createAccount": "建立帳號",
"joinHightube": "加入 Hightube",
"desiredUsername": "用戶名",
"register": "註冊",
"alreadyHaveAccount": "已有帳號?立即登錄",
"accountCreated": "帳號建立成功!請登錄。",
"playbackResolution": "播放解析度",
"availableNow": "目前可用",
"waitingForTranscoding": "正在等待後端轉碼輸出",
"sendMessage": "發送訊息...",
"liveStreamEnded": "主播已退出,直播已結束。",
"liveStreamEndedShort": "直播已結束",
"liveChat": "即時聊天",
"refresh": "重新整理",
"volume": "音量",
"danmakuOn": "彈幕開啟",
"danmakuOff": "彈幕關閉",
"fullscreen": "全屏",
"exitFullscreen": "退出全屏",
"resolution": "解析度",
"playbackOptionsDesc": "選擇可用的轉碼流。",
"sourceOnlyDesc": "目前僅源流可用。",
"myStreamConsole": "我的直播控制台",
"noRoomInfo": "未找到房間資訊。",
"roomTitle": "房間標題",
"rtmpServerUrl": "RTMP 伺服器地址",
"streamKey": "推流碼 (請務必保密!)",
"copiedToClipboard": "已複製到剪貼板",
"failedToFetchRoomInfo": "獲取房間資訊失敗"
}

View File

@@ -1,10 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:provider/provider.dart';
import 'package:fvp/fvp.dart' as fvp;
import 'providers/auth_provider.dart';
import 'providers/settings_provider.dart';
import 'pages/home_page.dart';
import 'pages/login_page.dart';
import 'l10n/app_localizations.dart';
void main() {
fvp.registerWith();
@@ -30,6 +32,14 @@ class HightubeApp extends StatelessWidget {
return MaterialApp(
title: 'Hightube',
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
locale: settings.locale,
theme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../providers/auth_provider.dart';
import '../providers/settings_provider.dart';
import '../services/api_service.dart';
@@ -10,8 +11,10 @@ import 'player_page.dart';
import 'my_stream_page.dart';
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
_HomePageState createState() => _HomePageState();
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
@@ -20,8 +23,9 @@ class _HomePageState extends State<HomePage> {
@override
Widget build(BuildContext context) {
bool isWide = MediaQuery.of(context).size.width > 600;
final l10n = AppLocalizations.of(context)!;
final List<Widget> _pages = [
final List<Widget> pages = [
_ExploreView(onGoLive: () => setState(() => _selectedIndex = 1)),
MyStreamPage(),
SettingsPage(),
@@ -36,22 +40,22 @@ class _HomePageState extends State<HomePage> {
onDestinationSelected: (int index) =>
setState(() => _selectedIndex = index),
labelType: NavigationRailLabelType.all,
destinations: const [
destinations: [
NavigationRailDestination(
icon: Icon(Icons.explore),
label: Text('Explore'),
icon: const Icon(Icons.explore),
label: Text(l10n.explore),
),
NavigationRailDestination(
icon: Icon(Icons.videocam),
label: Text('Console'),
icon: const Icon(Icons.videocam),
label: Text(l10n.console),
),
NavigationRailDestination(
icon: Icon(Icons.settings),
label: Text('Settings'),
icon: const Icon(Icons.settings),
label: Text(l10n.settings),
),
],
),
Expanded(child: _pages[_selectedIndex]),
Expanded(child: pages[_selectedIndex]),
],
),
bottomNavigationBar: !isWide
@@ -59,18 +63,18 @@ class _HomePageState extends State<HomePage> {
selectedIndex: _selectedIndex,
onDestinationSelected: (int index) =>
setState(() => _selectedIndex = index),
destinations: const [
destinations: [
NavigationDestination(
icon: Icon(Icons.explore),
label: 'Explore',
icon: const Icon(Icons.explore),
label: l10n.explore,
),
NavigationDestination(
icon: Icon(Icons.videocam),
label: 'Console',
icon: const Icon(Icons.videocam),
label: l10n.console,
),
NavigationDestination(
icon: Icon(Icons.settings),
label: 'Settings',
icon: const Icon(Icons.settings),
label: l10n.settings,
),
],
)
@@ -91,6 +95,8 @@ class _ExploreViewState extends State<_ExploreView> {
List<dynamic> _activeRooms = [];
bool _isLoading = false;
Timer? _refreshTimer;
String _thumbnailCacheBuster = DateTime.now().millisecondsSinceEpoch
.toString();
@override
void initState() {
@@ -117,62 +123,95 @@ class _ExploreViewState extends State<_ExploreView> {
final response = await api.getActiveRooms();
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
if (mounted) setState(() => _activeRooms = data['active_rooms'] ?? []);
if (mounted) {
setState(() {
_activeRooms = data['active_rooms'] ?? [];
_thumbnailCacheBuster = DateTime.now().millisecondsSinceEpoch
.toString();
});
}
}
} catch (e) {
if (!isAuto && mounted)
if (!isAuto && mounted) {
final l10n = AppLocalizations.of(context);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text("Failed to load rooms")));
).showSnackBar(SnackBar(content: Text(l10n?.failedToLoadRooms ?? "Failed to load rooms")));
}
} finally {
if (!isAuto && mounted) setState(() => _isLoading = false);
}
}
Future<void> _confirmLogout() async {
final l10n = AppLocalizations.of(context)!;
final confirmed = await showDialog<bool>(
context: context,
builder: (context) {
return AlertDialog(
title: Text(l10n.confirmLogout),
content: Text(l10n.confirmLogoutDesc),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(l10n.cancel),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: Text(l10n.logout),
),
],
);
},
);
if (confirmed == true && mounted) {
await context.read<AuthProvider>().logout();
}
}
@override
Widget build(BuildContext context) {
final settings = context.watch<SettingsProvider>();
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(
title: Text("Explore", style: TextStyle(fontWeight: FontWeight.bold)),
title: Text(l10n.explore, style: const TextStyle(fontWeight: FontWeight.bold)),
actions: [
IconButton(
icon: Icon(Icons.refresh),
icon: const Icon(Icons.refresh),
onPressed: () => _refreshRooms(),
),
IconButton(
icon: Icon(Icons.logout),
onPressed: () => context.read<AuthProvider>().logout(),
),
IconButton(icon: const Icon(Icons.logout), onPressed: _confirmLogout),
],
),
floatingActionButton: FloatingActionButton.extended(
onPressed: widget.onGoLive,
label: Text("Go Live"),
icon: Icon(Icons.videocam),
label: Text(l10n.goLive),
icon: const Icon(Icons.videocam),
),
body: RefreshIndicator(
onRefresh: _refreshRooms,
child: _isLoading && _activeRooms.isEmpty
? Center(child: CircularProgressIndicator())
? const Center(child: CircularProgressIndicator())
: _activeRooms.isEmpty
? ListView(
children: [
Padding(
padding: EdgeInsets.only(top: 100),
padding: const EdgeInsets.only(top: 100),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
const Icon(
Icons.live_tv_outlined,
size: 80,
color: Colors.grey,
),
SizedBox(height: 16),
const SizedBox(height: 16),
Text(
"No active rooms. Be the first!",
style: TextStyle(color: Colors.grey, fontSize: 16),
l10n.noActiveRooms,
style: const TextStyle(color: Colors.grey, fontSize: 16),
),
],
),
@@ -180,8 +219,8 @@ class _ExploreViewState extends State<_ExploreView> {
],
)
: GridView.builder(
padding: EdgeInsets.all(12),
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
padding: const EdgeInsets.all(12),
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 400,
childAspectRatio: 1.2,
crossAxisSpacing: 12,
@@ -190,28 +229,29 @@ class _ExploreViewState extends State<_ExploreView> {
itemCount: _activeRooms.length,
itemBuilder: (context, index) {
final room = _activeRooms[index];
return _buildRoomCard(room, settings);
return _buildRoomCard(room, settings, l10n);
},
),
),
);
}
Widget _buildRoomCard(dynamic room, SettingsProvider settings) {
Widget _buildRoomCard(dynamic room, SettingsProvider settings, AppLocalizations l10n) {
final roomId = room['room_id'].toString();
return Card(
elevation: 4,
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: InkWell(
onTap: () {
final playbackUrl = settings.playbackUrl(room['room_id'].toString());
final playbackUrl = settings.playbackUrl(roomId);
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => PlayerPage(
title: room['title'],
playbackUrl: playbackUrl,
roomId: room['room_id'].toString(),
roomId: roomId,
),
),
);
@@ -224,26 +264,47 @@ class _ExploreViewState extends State<_ExploreView> {
child: Stack(
fit: StackFit.expand,
children: [
Container(
color: Theme.of(context).colorScheme.primaryContainer,
child: Center(
child: Icon(
Icons.live_tv,
size: 50,
color: Theme.of(context).colorScheme.primary,
),
),
),
settings.livePreviewThumbnailsEnabled
? Image.network(
settings.thumbnailUrl(
roomId,
cacheBuster: _thumbnailCacheBuster,
),
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) =>
_buildRoomPreviewFallback(),
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) {
return child;
}
return Stack(
fit: StackFit.expand,
children: [
_buildRoomPreviewFallback(),
const Center(
child: SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
strokeWidth: 2,
),
),
),
],
);
},
)
: _buildRoomPreviewFallback(),
Positioned(
top: 8,
left: 8,
child: Container(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(4),
),
child: Row(
child: const Row(
children: [
Icon(Icons.circle, size: 8, color: Colors.white),
SizedBox(width: 4),
@@ -275,7 +336,7 @@ class _ExploreViewState extends State<_ExploreView> {
radius: 16,
child: Text(room['user_id'].toString().substring(0, 1)),
),
SizedBox(width: 12),
const SizedBox(width: 12),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
@@ -285,14 +346,14 @@ class _ExploreViewState extends State<_ExploreView> {
room['title'],
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
Text(
"Host ID: ${room['user_id']}",
style: TextStyle(fontSize: 12, color: Colors.grey),
"${l10n.hostId}: ${room['user_id']}",
style: const TextStyle(fontSize: 12, color: Colors.grey),
),
],
),
@@ -306,4 +367,17 @@ class _ExploreViewState extends State<_ExploreView> {
),
);
}
Widget _buildRoomPreviewFallback() {
return Container(
color: Theme.of(context).colorScheme.primaryContainer,
child: Center(
child: Icon(
Icons.live_tv,
size: 50,
color: Theme.of(context).colorScheme.primary,
),
),
);
}
}

View File

@@ -1,6 +1,7 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../providers/auth_provider.dart';
import '../providers/settings_provider.dart';
import '../services/api_service.dart';
@@ -11,19 +12,29 @@ class LoginPage extends StatefulWidget {
const LoginPage({super.key});
@override
_LoginPageState createState() => _LoginPageState();
State<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> {
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
final _passwordFocusNode = FocusNode();
bool _isLoading = false;
@override
void dispose() {
_usernameController.dispose();
_passwordController.dispose();
_passwordFocusNode.dispose();
super.dispose();
}
void _handleLogin() async {
final l10n = AppLocalizations.of(context)!;
if (_usernameController.text.isEmpty || _passwordController.text.isEmpty) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text("Please fill in all fields")));
).showSnackBar(SnackBar(content: Text(l10n.fillAllFields)));
return;
}
@@ -44,7 +55,7 @@ class _LoginPageState extends State<LoginPage> {
if (!mounted) {
return;
}
final error = jsonDecode(response.body)['error'] ?? "Login Failed";
final error = jsonDecode(response.body)['error'] ?? l10n.loginFailed;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(error)));
@@ -53,9 +64,9 @@ class _LoginPageState extends State<LoginPage> {
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Network Error: Could not connect to server")),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.networkError)));
} finally {
if (mounted) setState(() => _isLoading = false);
}
@@ -63,11 +74,13 @@ class _LoginPageState extends State<LoginPage> {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(
actions: [
IconButton(
icon: Icon(Icons.settings),
icon: const Icon(Icons.settings),
onPressed: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const SettingsPage()),
@@ -79,7 +92,7 @@ class _LoginPageState extends State<LoginPage> {
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 32.0),
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: 400),
constraints: const BoxConstraints(maxWidth: 400),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
@@ -89,7 +102,7 @@ class _LoginPageState extends State<LoginPage> {
size: 80,
color: Theme.of(context).colorScheme.primary,
),
SizedBox(height: 16),
const SizedBox(height: 16),
Text(
"HIGHTUBE",
style: TextStyle(
@@ -99,36 +112,45 @@ class _LoginPageState extends State<LoginPage> {
color: Theme.of(context).colorScheme.primary,
),
),
Text(
const Text(
"Open Source Live Platform",
style: TextStyle(color: Colors.grey),
),
SizedBox(height: 48),
const SizedBox(height: 48),
// Fields
TextField(
controller: _usernameController,
textInputAction: TextInputAction.next,
onSubmitted: (_) => _passwordFocusNode.requestFocus(),
decoration: InputDecoration(
labelText: "Username",
prefixIcon: Icon(Icons.person),
labelText: l10n.username,
prefixIcon: const Icon(Icons.person),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
SizedBox(height: 16),
const SizedBox(height: 16),
TextField(
controller: _passwordController,
focusNode: _passwordFocusNode,
obscureText: true,
textInputAction: TextInputAction.done,
onSubmitted: (_) {
if (!_isLoading) {
_handleLogin();
}
},
decoration: InputDecoration(
labelText: "Password",
prefixIcon: Icon(Icons.lock),
labelText: l10n.password,
prefixIcon: const Icon(Icons.lock),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
SizedBox(height: 32),
const SizedBox(height: 32),
// Login Button
SizedBox(
@@ -142,22 +164,22 @@ class _LoginPageState extends State<LoginPage> {
),
),
child: _isLoading
? CircularProgressIndicator()
? const CircularProgressIndicator()
: Text(
"LOGIN",
style: TextStyle(fontWeight: FontWeight.bold),
l10n.login,
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
),
SizedBox(height: 16),
const SizedBox(height: 16),
// Register Link
TextButton(
onPressed: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => RegisterPage()),
MaterialPageRoute(builder: (_) => const RegisterPage()),
),
child: Text("Don't have an account? Create one"),
child: Text(l10n.dontHaveAccount),
),
],
),

View File

@@ -2,13 +2,17 @@ import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:flutter/services.dart';
import '../l10n/app_localizations.dart';
import '../providers/auth_provider.dart';
import '../providers/settings_provider.dart';
import '../services/api_service.dart';
import '../widgets/android_quick_stream_panel.dart';
class MyStreamPage extends StatefulWidget {
const MyStreamPage({super.key});
@override
_MyStreamPageState createState() => _MyStreamPageState();
State<MyStreamPage> createState() => _MyStreamPageState();
}
class _MyStreamPageState extends State<MyStreamPage> {
@@ -29,78 +33,107 @@ class _MyStreamPageState extends State<MyStreamPage> {
try {
final response = await api.getMyRoom();
if (!mounted) {
return;
}
if (response.statusCode == 200) {
setState(() => _roomInfo = jsonDecode(response.body));
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Failed to fetch room info")));
if (!mounted) {
return;
}
final l10n = AppLocalizations.of(context);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n?.failedToFetchRoomInfo ?? "Failed to fetch room info")));
} finally {
setState(() => _isLoading = false);
if (mounted) {
setState(() => _isLoading = false);
}
}
}
@override
Widget build(BuildContext context) {
final settings = context.watch<SettingsProvider>();
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(title: Text("My Stream Console")),
appBar: AppBar(title: Text(l10n.myStreamConsole)),
body: _isLoading
? Center(child: CircularProgressIndicator())
? const Center(child: CircularProgressIndicator())
: _roomInfo == null
? Center(child: Text("No room info found."))
: SingleChildScrollView(
padding: EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildInfoCard(
title: "Room Title",
value: _roomInfo!['title'],
icon: Icons.edit,
onTap: () {
// TODO: Implement title update API later
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Title editing coming soon!")));
},
),
SizedBox(height: 20),
_buildInfoCard(
title: "RTMP Server URL",
value: settings.rtmpUrl,
icon: Icons.copy,
onTap: () {
Clipboard.setData(ClipboardData(text: settings.rtmpUrl));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Server URL copied to clipboard")));
},
),
SizedBox(height: 20),
_buildInfoCard(
title: "Stream Key (Keep Secret!)",
value: _roomInfo!['stream_key'],
icon: Icons.copy,
isSecret: true,
onTap: () {
Clipboard.setData(ClipboardData(text: _roomInfo!['stream_key']));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Stream Key copied to clipboard")));
},
),
SizedBox(height: 30),
Center(
child: Column(
children: [
Icon(Icons.info_outline, color: Colors.grey),
SizedBox(height: 8),
Text(
"Use OBS or other tools to stream to this address.",
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey, fontSize: 12),
),
],
),
),
],
? Center(child: Text(l10n.noRoomInfo))
: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildInfoCard(
title: l10n.roomTitle,
value: _roomInfo!['title'],
icon: Icons.edit,
onTap: () {
// TODO: Implement title update API later
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("Title editing coming soon!")),
);
},
),
),
const SizedBox(height: 20),
_buildInfoCard(
title: l10n.rtmpServerUrl,
value: settings.rtmpUrl,
icon: Icons.copy,
onTap: () {
Clipboard.setData(ClipboardData(text: settings.rtmpUrl));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.copiedToClipboard),
),
);
},
),
const SizedBox(height: 20),
_buildInfoCard(
title: l10n.streamKey,
value: _roomInfo!['stream_key'],
icon: Icons.copy,
isSecret: true,
onTap: () {
Clipboard.setData(
ClipboardData(text: _roomInfo!['stream_key']),
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.copiedToClipboard),
),
);
},
),
const SizedBox(height: 24),
AndroidQuickStreamPanel(
rtmpBaseUrl: settings.rtmpUrl,
streamKey: _roomInfo!['stream_key'],
),
const SizedBox(height: 30),
const Center(
child: Column(
children: [
Icon(Icons.info_outline, color: Colors.grey),
SizedBox(height: 8),
Text(
"Use OBS or other tools to stream to this address.",
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey, fontSize: 12),
),
],
),
),
],
),
),
);
}
@@ -113,10 +146,10 @@ class _MyStreamPageState extends State<MyStreamPage> {
}) {
return Card(
child: ListTile(
title: Text(title, style: TextStyle(fontSize: 12, color: Colors.grey)),
title: Text(title, style: const TextStyle(fontSize: 12, color: Colors.grey)),
subtitle: Text(
isSecret ? "••••••••••••••••" : value,
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
trailing: IconButton(icon: Icon(icon), onPressed: onTap),
),

View File

@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
@@ -6,8 +7,10 @@ import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:video_player/video_player.dart';
import '../l10n/app_localizations.dart';
import '../providers/auth_provider.dart';
import '../providers/settings_provider.dart';
import '../services/api_service.dart';
import '../services/chat_service.dart';
import '../widgets/web_stream_player.dart';
@@ -24,7 +27,7 @@ class PlayerPage extends StatefulWidget {
});
@override
_PlayerPageState createState() => _PlayerPageState();
State<PlayerPage> createState() => _PlayerPageState();
}
class _PlayerPageState extends State<PlayerPage> {
@@ -40,13 +43,17 @@ class _PlayerPageState extends State<PlayerPage> {
bool _isRefreshing = false;
bool _isFullscreen = false;
bool _controlsVisible = true;
bool _streamEnded = false;
double _volume = kIsWeb ? 0.0 : 1.0;
int _playerVersion = 0;
String _selectedResolution = 'Source';
List<String> _availableResolutions = const ['Source'];
Timer? _controlsHideTimer;
@override
void initState() {
super.initState();
_loadPlaybackOptions();
if (!kIsWeb) {
_initializePlayer();
}
@@ -55,11 +62,11 @@ class _PlayerPageState extends State<PlayerPage> {
}
Future<void> _initializePlayer() async {
_controller = VideoPlayerController.networkUrl(
Uri.parse(widget.playbackUrl),
);
final playbackUrl = _currentPlaybackUrl();
_controller = VideoPlayerController.networkUrl(Uri.parse(playbackUrl));
try {
await _controller!.initialize();
await _controller!.setVolume(_volume);
_controller!.play();
if (mounted) setState(() {});
} catch (e) {
@@ -81,6 +88,55 @@ class _PlayerPageState extends State<PlayerPage> {
}
}
String _currentPlaybackUrl() {
final settings = context.read<SettingsProvider>();
final quality = _selectedResolution == 'Source'
? null
: _selectedResolution.toLowerCase();
return settings.playbackUrl(widget.roomId, quality: quality);
}
Future<void> _loadPlaybackOptions() async {
final settings = context.read<SettingsProvider>();
final auth = context.read<AuthProvider>();
final api = ApiService(settings, auth.token);
try {
final response = await api.getPlaybackOptions(widget.roomId);
if (response.statusCode != 200) {
return;
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
final rawQualities =
(data['qualities'] as List<dynamic>? ?? const ['source'])
.map((item) => item.toString().trim().toLowerCase())
.where((item) => item.isNotEmpty)
.toList();
final normalized = <String>['Source'];
for (final quality in rawQualities) {
if (quality == 'source') {
continue;
}
normalized.add(quality);
}
if (!mounted) {
return;
}
setState(() {
_availableResolutions = normalized.toSet().toList();
if (!_availableResolutions.contains(_selectedResolution)) {
_selectedResolution = 'Source';
}
});
} catch (_) {
// Keep source-only playback when the capability probe fails.
}
}
void _initializeChat() {
final settings = context.read<SettingsProvider>();
final auth = context.read<AuthProvider>();
@@ -91,6 +147,10 @@ class _PlayerPageState extends State<PlayerPage> {
_chatService.messages.listen((msg) {
if (mounted) {
if (msg.type == "stream_end") {
_handleStreamEnded(msg.content);
return;
}
setState(() {
_messages.insert(0, msg);
if (!msg.isHistory && (msg.type == "chat" || msg.type == "danmaku")) {
@@ -103,6 +163,44 @@ class _PlayerPageState extends State<PlayerPage> {
});
}
void _handleStreamEnded(String message) {
if (_streamEnded) {
return;
}
final l10n = AppLocalizations.of(context)!;
final streamEndedMessage = l10n.liveStreamEnded;
setState(() {
_streamEnded = true;
_isRefreshing = false;
_danmakus.clear();
_messages.insert(
0,
ChatMessage(
type: "system",
username: "System",
content: streamEndedMessage,
roomId: widget.roomId,
),
);
if (!kIsWeb) {
_isError = true;
_errorMessage = streamEndedMessage;
}
});
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(streamEndedMessage)));
Future<void>.delayed(const Duration(seconds: 2), () {
if (mounted && Navigator.canPop(context)) {
Navigator.pop(context);
}
});
}
void _addDanmaku(String text) {
final key = UniqueKey();
final lane = DateTime.now().millisecondsSinceEpoch % 8;
@@ -125,7 +223,7 @@ class _PlayerPageState extends State<PlayerPage> {
}
void _sendMsg() {
if (_msgController.text.isNotEmpty) {
if (!_streamEnded && _msgController.text.isNotEmpty) {
final auth = context.read<AuthProvider>();
_chatService.sendMessage(
_msgController.text,
@@ -140,6 +238,11 @@ class _PlayerPageState extends State<PlayerPage> {
if (_isRefreshing) {
return;
}
if (_streamEnded) {
return;
}
await _loadPlaybackOptions();
setState(() {
_isRefreshing = true;
@@ -200,6 +303,77 @@ class _PlayerPageState extends State<PlayerPage> {
_showControls();
}
Future<void> _setVolume(double volume) async {
final nextVolume = volume.clamp(0.0, 1.0);
if (!mounted) {
return;
}
setState(() => _volume = nextVolume);
if (!kIsWeb && _controller != null) {
await _controller!.setVolume(nextVolume);
}
}
Future<void> _openVolumeSheet() async {
_showControls();
final l10n = AppLocalizations.of(context)!;
await showModalBottomSheet<void>(
context: context,
builder: (context) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 28),
child: StatefulBuilder(
builder: (context, setSheetState) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.volume,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
Row(
children: [
Icon(
_volume == 0
? Icons.volume_off
: _volume < 0.5
? Icons.volume_down
: Icons.volume_up,
),
Expanded(
child: Slider(
value: _volume,
min: 0,
max: 1,
divisions: 20,
label: '${(_volume * 100).round()}%',
onChanged: (value) {
setSheetState(() => _volume = value);
_setVolume(value);
},
),
),
SizedBox(
width: 48,
child: Text('${(_volume * 100).round()}%'),
),
],
),
],
);
},
),
),
);
},
);
}
void _showControls() {
_controlsHideTimer?.cancel();
if (mounted) {
@@ -223,22 +397,31 @@ class _PlayerPageState extends State<PlayerPage> {
Future<void> _selectResolution() async {
_showControls();
await _loadPlaybackOptions();
if (!mounted) {
return;
}
final l10n = AppLocalizations.of(context)!;
final nextResolution = await showModalBottomSheet<String>(
context: context,
builder: (context) {
const options = ['Source', '720p', '480p'];
final available = _availableResolutions.toSet();
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const ListTile(
title: Text('Playback Resolution'),
ListTile(
title: Text(l10n.playbackResolution),
subtitle: Text(
'Current backend only provides the source stream. Lower resolutions are reserved for future multi-bitrate output.',
available.length > 1
? l10n.playbackOptionsDesc
: l10n.sourceOnlyDesc,
),
),
...options.map((option) {
final enabled = option == 'Source';
final enabled = available.contains(option);
return ListTile(
enabled: enabled,
leading: Icon(
@@ -248,8 +431,8 @@ class _PlayerPageState extends State<PlayerPage> {
),
title: Text(option),
subtitle: enabled
? const Text('Available now')
: const Text('Requires backend transcoding support'),
? Text(l10n.availableNow)
: Text(l10n.waitingForTranscoding),
onTap: enabled ? () => Navigator.pop(context, option) : null,
);
}),
@@ -376,7 +559,8 @@ class _PlayerPageState extends State<PlayerPage> {
: kIsWeb
? WebStreamPlayer(
key: ValueKey('web-player-$_playerVersion'),
streamUrl: widget.playbackUrl,
streamUrl: _currentPlaybackUrl(),
volume: _volume,
)
: _controller != null && _controller!.value.isInitialized
? AspectRatio(
@@ -455,6 +639,7 @@ class _PlayerPageState extends State<PlayerPage> {
}
Widget _buildPlaybackControls() {
final l10n = AppLocalizations.of(context)!;
return IgnorePointer(
ignoring: !_controlsVisible,
child: AnimatedOpacity(
@@ -485,19 +670,30 @@ class _PlayerPageState extends State<PlayerPage> {
children: [
_buildControlButton(
icon: Icons.refresh,
label: "Refresh",
onPressed: _refreshPlayer,
label: l10n.refresh,
onPressed: _streamEnded ? null : _refreshPlayer,
),
_buildControlButton(
icon: _volume == 0
? Icons.volume_off
: _volume < 0.5
? Icons.volume_down
: Icons.volume_up,
label: l10n.volume,
onPressed: _openVolumeSheet,
),
_buildControlButton(
icon: _showDanmaku ? Icons.subtitles : Icons.subtitles_off,
label: _showDanmaku ? "Danmaku On" : "Danmaku Off",
label: _showDanmaku ? l10n.danmakuOn : l10n.danmakuOff,
onPressed: _toggleDanmaku,
),
_buildControlButton(
icon: _isFullscreen
? Icons.fullscreen_exit
: Icons.fullscreen,
label: _isFullscreen ? "Exit Fullscreen" : "Fullscreen",
label: _isFullscreen
? l10n.exitFullscreen
: l10n.fullscreen,
onPressed: _toggleFullscreen,
),
_buildControlButton(
@@ -517,13 +713,15 @@ class _PlayerPageState extends State<PlayerPage> {
Widget _buildControlButton({
required IconData icon,
required String label,
required FutureOr<void> Function() onPressed,
required FutureOr<void> Function()? onPressed,
}) {
return FilledButton.tonalIcon(
onPressed: () async {
_showControls();
await onPressed();
},
onPressed: onPressed == null
? null
: () async {
_showControls();
await onPressed();
},
icon: Icon(icon, size: 18),
label: Text(label),
style: FilledButton.styleFrom(
@@ -535,23 +733,27 @@ class _PlayerPageState extends State<PlayerPage> {
// 抽离聊天区域组件
Widget _buildChatSection() {
final l10n = AppLocalizations.of(context)!;
return Column(
children: [
Container(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: Row(
children: [
Icon(Icons.chat_bubble_outline, size: 16),
SizedBox(width: 8),
Text("Live Chat", style: TextStyle(fontWeight: FontWeight.bold)),
const Icon(Icons.chat_bubble_outline, size: 16),
const SizedBox(width: 8),
Text(
l10n.liveChat,
style: const TextStyle(fontWeight: FontWeight.bold),
),
],
),
),
Expanded(
child: ListView.builder(
reverse: true,
padding: EdgeInsets.all(8),
padding: const EdgeInsets.all(8),
itemCount: _messages.length,
itemBuilder: (context, index) {
final m = _messages[index];
@@ -559,7 +761,7 @@ class _PlayerPageState extends State<PlayerPage> {
},
),
),
Divider(height: 1),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
@@ -567,12 +769,15 @@ class _PlayerPageState extends State<PlayerPage> {
Expanded(
child: TextField(
controller: _msgController,
enabled: !_streamEnded,
decoration: InputDecoration(
hintText: "Send a message...",
hintText: _streamEnded
? l10n.liveStreamEndedShort
: l10n.sendMessage,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
),
contentPadding: EdgeInsets.symmetric(
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
@@ -585,7 +790,7 @@ class _PlayerPageState extends State<PlayerPage> {
Icons.send,
color: Theme.of(context).colorScheme.primary,
),
onPressed: _sendMsg,
onPressed: _streamEnded ? null : _sendMsg,
),
],
),

View File

@@ -1,12 +1,15 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../providers/settings_provider.dart';
import '../services/api_service.dart';
class RegisterPage extends StatefulWidget {
const RegisterPage({super.key});
@override
_RegisterPageState createState() => _RegisterPageState();
State<RegisterPage> createState() => _RegisterPageState();
}
class _RegisterPageState extends State<RegisterPage> {
@@ -15,8 +18,11 @@ class _RegisterPageState extends State<RegisterPage> {
bool _isLoading = false;
void _handleRegister() async {
final l10n = AppLocalizations.of(context)!;
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(l10n.fillAllFields)));
return;
}
@@ -25,16 +31,32 @@ class _RegisterPageState extends State<RegisterPage> {
final api = ApiService(settings, null);
try {
final response = await api.register(_usernameController.text, _passwordController.text);
final response = await api.register(
_usernameController.text,
_passwordController.text,
);
if (!mounted) {
return;
}
if (response.statusCode == 201) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Account created! Please login.")));
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.accountCreated)));
Navigator.pop(context);
} else {
final error = jsonDecode(response.body)['error'] ?? "Registration Failed";
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(error)));
final error =
jsonDecode(response.body)['error'] ?? "Registration Failed";
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(error)));
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Network Error")));
if (!mounted) {
return;
}
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.networkError)));
} finally {
if (mounted) setState(() => _isLoading = false);
}
@@ -42,57 +64,75 @@ class _RegisterPageState extends State<RegisterPage> {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(title: Text("Create Account")),
appBar: AppBar(title: Text(l10n.createAccount)),
body: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 32.0),
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: 400),
constraints: const BoxConstraints(maxWidth: 400),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.person_add_outlined, size: 64, color: Theme.of(context).colorScheme.primary),
SizedBox(height: 24),
Text(
"Join Hightube",
style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.bold),
Icon(
Icons.person_add_outlined,
size: 64,
color: Theme.of(context).colorScheme.primary,
),
SizedBox(height: 48),
const SizedBox(height: 24),
Text(
l10n.joinHightube,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 48),
TextField(
controller: _usernameController,
decoration: InputDecoration(
labelText: "Desired Username",
prefixIcon: Icon(Icons.person),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
labelText: l10n.desiredUsername,
prefixIcon: const Icon(Icons.person),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
SizedBox(height: 16),
const SizedBox(height: 16),
TextField(
controller: _passwordController,
obscureText: true,
decoration: InputDecoration(
labelText: "Password",
prefixIcon: Icon(Icons.lock),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
labelText: l10n.password,
prefixIcon: const Icon(Icons.lock),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
SizedBox(height: 32),
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
height: 50,
child: ElevatedButton(
onPressed: _isLoading ? null : _handleRegister,
style: ElevatedButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: _isLoading ? CircularProgressIndicator() : Text("REGISTER", style: TextStyle(fontWeight: FontWeight.bold)),
child: _isLoading
? const CircularProgressIndicator()
: Text(
l10n.register,
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
),
SizedBox(height: 16),
const SizedBox(height: 16),
TextButton(
onPressed: () => Navigator.pop(context),
child: Text("Already have an account? Login here"),
child: Text(l10n.alreadyHaveAccount),
),
],
),

View File

@@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../providers/auth_provider.dart';
import '../providers/settings_provider.dart';
@@ -11,7 +12,7 @@ class SettingsPage extends StatefulWidget {
const SettingsPage({super.key});
@override
_SettingsPageState createState() => _SettingsPageState();
State<SettingsPage> createState() => _SettingsPageState();
}
class _SettingsPageState extends State<SettingsPage> {
@@ -88,15 +89,45 @@ class _SettingsPageState extends State<SettingsPage> {
}
}
Future<void> _confirmLogout(AuthProvider auth, AppLocalizations l10n) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) {
return AlertDialog(
title: Text(l10n.confirmLogout),
content: Text(l10n.confirmLogoutDesc),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(l10n.cancel),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: Text(l10n.logout),
),
],
);
},
);
if (confirmed == true && mounted) {
await auth.logout();
}
}
@override
Widget build(BuildContext context) {
final auth = context.watch<AuthProvider>();
final settings = context.watch<SettingsProvider>();
final isAuthenticated = auth.isAuthenticated;
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(
title: Text("Settings", style: TextStyle(fontWeight: FontWeight.bold)),
title: Text(
l10n.settings,
style: TextStyle(fontWeight: FontWeight.bold),
),
centerTitle: true,
),
body: SingleChildScrollView(
@@ -108,12 +139,62 @@ class _SettingsPageState extends State<SettingsPage> {
_buildProfileSection(auth),
const SizedBox(height: 32),
],
_buildSectionTitle("Network Configuration"),
_buildSectionTitle(l10n.language),
const SizedBox(height: 16),
DropdownButtonFormField<Locale?>(
initialValue: settings.locale == null
? null
: AppLocalizations.supportedLocales
.cast<Locale?>()
.firstWhere(
(l) =>
l?.languageCode ==
settings.locale?.languageCode &&
l?.scriptCode == settings.locale?.scriptCode,
orElse: () => null,
),
decoration: InputDecoration(
labelText: l10n.selectLanguage,
prefixIcon: const Icon(Icons.language),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
items: [
DropdownMenuItem(value: null, child: Text(l10n.system)),
DropdownMenuItem(
value: const Locale('en'),
child: Text(l10n.english),
),
DropdownMenuItem(
value: const Locale('zh'),
child: Text(l10n.simplifiedChinese),
),
DropdownMenuItem(
value: const Locale.fromSubtags(
languageCode: 'zh',
scriptCode: 'Hant',
),
child: Text(l10n.traditionalChinese),
),
DropdownMenuItem(
value: const Locale('ja'),
child: Text(l10n.japanese),
),
],
onChanged: (Locale? newLocale) {
settings.setLocale(newLocale);
},
),
const SizedBox(height: 32),
_buildSectionTitle(l10n.networkConfiguration),
const SizedBox(height: 16),
TextField(
controller: _urlController,
decoration: InputDecoration(
labelText: "Backend Server URL",
labelText: l10n.backendServerUrl,
hintText: "http://127.0.0.1:8080",
prefixIcon: Icon(Icons.lan),
border: OutlineInputBorder(
@@ -131,13 +212,13 @@ class _SettingsPageState extends State<SettingsPage> {
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text("Server URL Updated"),
content: Text(l10n.serverUrlUpdated),
behavior: SnackBarBehavior.floating,
),
);
},
icon: Icon(Icons.save),
label: Text("Save Network Settings"),
label: Text(l10n.saveNetworkSettings),
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(
context,
@@ -153,28 +234,28 @@ class _SettingsPageState extends State<SettingsPage> {
),
const SizedBox(height: 32),
_buildSectionTitle("Theme Customization"),
_buildSectionTitle(l10n.themeCustomization),
const SizedBox(height: 16),
Text(
"Appearance Mode",
l10n.appearanceMode,
style: Theme.of(context).textTheme.labelLarge,
),
const SizedBox(height: 12),
SegmentedButton<ThemeMode>(
segments: const [
segments: [
ButtonSegment<ThemeMode>(
value: ThemeMode.system,
label: Text("System"),
label: Text(l10n.system),
icon: Icon(Icons.brightness_auto),
),
ButtonSegment<ThemeMode>(
value: ThemeMode.light,
label: Text("Light"),
label: Text(l10n.light),
icon: Icon(Icons.light_mode),
),
ButtonSegment<ThemeMode>(
value: ThemeMode.dark,
label: Text("Dark"),
label: Text(l10n.dark),
icon: Icon(Icons.dark_mode),
),
],
@@ -184,7 +265,10 @@ class _SettingsPageState extends State<SettingsPage> {
},
),
const SizedBox(height: 20),
Text("Accent Color", style: Theme.of(context).textTheme.labelLarge),
Text(
l10n.accentColor,
style: Theme.of(context).textTheme.labelLarge,
),
const SizedBox(height: 12),
Wrap(
spacing: 12,
@@ -221,15 +305,25 @@ class _SettingsPageState extends State<SettingsPage> {
);
}).toList(),
),
const SizedBox(height: 32),
_buildSectionTitle(l10n.explore),
const SizedBox(height: 8),
SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
title: Text(l10n.livePreviewThumbnails),
subtitle: Text(l10n.livePreviewThumbnailsDesc),
value: settings.livePreviewThumbnailsEnabled,
onChanged: settings.setLivePreviewThumbnailsEnabled,
),
if (isAuthenticated) ...[
const SizedBox(height: 32),
_buildSectionTitle("Security"),
_buildSectionTitle(l10n.security),
const SizedBox(height: 16),
TextField(
controller: _oldPasswordController,
obscureText: true,
decoration: InputDecoration(
labelText: "Old Password",
labelText: l10n.oldPassword,
prefixIcon: const Icon(Icons.lock_outline),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
@@ -241,7 +335,7 @@ class _SettingsPageState extends State<SettingsPage> {
controller: _newPasswordController,
obscureText: true,
decoration: InputDecoration(
labelText: "New Password",
labelText: l10n.newPassword,
prefixIcon: const Icon(Icons.lock_reset),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
@@ -254,7 +348,7 @@ class _SettingsPageState extends State<SettingsPage> {
child: OutlinedButton.icon(
onPressed: _handleChangePassword,
icon: const Icon(Icons.update),
label: const Text("Change Password"),
label: Text(l10n.changePassword),
style: OutlinedButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
@@ -266,9 +360,9 @@ class _SettingsPageState extends State<SettingsPage> {
SizedBox(
width: double.infinity,
child: FilledButton.tonalIcon(
onPressed: auth.logout,
onPressed: () => _confirmLogout(auth, l10n),
icon: const Icon(Icons.logout),
label: const Text("Logout"),
label: Text(l10n.logout),
style: FilledButton.styleFrom(
foregroundColor: Colors.redAccent,
padding: const EdgeInsets.symmetric(vertical: 14),
@@ -306,12 +400,9 @@ class _SettingsPageState extends State<SettingsPage> {
"Hightube",
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
Text("Version: 1.0.1", style: TextStyle(color: Colors.grey)),
Text(
"Version: 1.0.0-beta3.5",
style: TextStyle(color: Colors.grey),
),
Text(
"Author: Highground-Soft & Minimax",
"Author: Highground-Soft",
style: TextStyle(color: Colors.grey),
),
SizedBox(height: 20),

View File

@@ -3,19 +3,28 @@ import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
class SettingsProvider with ChangeNotifier {
// Use 10.0.2.2 for Android emulator to access host's localhost
static String get _defaultUrl =>
(defaultTargetPlatform == TargetPlatform.android && !kIsWeb)
? "http://10.0.2.2:8080"
: "http://localhost:8080";
// On web: use empty string so API calls use same origin (works behind any proxy)
// On Android emulator: 10.0.2.2 maps to host localhost
// On other platforms: localhost
static String get _defaultUrl {
if (kIsWeb) return "";
if (defaultTargetPlatform == TargetPlatform.android) {
return "http://10.0.2.2:8080";
}
return "http://localhost:8080";
}
String _baseUrl = _defaultUrl;
Color _themeColor = Colors.blue;
ThemeMode _themeMode = ThemeMode.system;
bool _livePreviewThumbnailsEnabled = false;
Locale? _locale;
String get baseUrl => _baseUrl;
Color get themeColor => _themeColor;
ThemeMode get themeMode => _themeMode;
bool get livePreviewThumbnailsEnabled => _livePreviewThumbnailsEnabled;
Locale? get locale => _locale;
SettingsProvider() {
_loadSettings();
@@ -32,6 +41,43 @@ class SettingsProvider with ChangeNotifier {
if (savedThemeMode != null) {
_themeMode = _themeModeFromString(savedThemeMode);
}
_livePreviewThumbnailsEnabled =
prefs.getBool('livePreviewThumbnailsEnabled') ?? false;
final languageCode = prefs.getString('languageCode');
final scriptCode = prefs.getString('scriptCode');
final countryCode = prefs.getString('countryCode');
if (languageCode != null) {
_locale = Locale.fromSubtags(
languageCode: languageCode,
scriptCode: scriptCode,
countryCode: countryCode,
);
}
notifyListeners();
}
void setLocale(Locale? newLocale) async {
_locale = newLocale;
final prefs = await SharedPreferences.getInstance();
if (newLocale == null) {
await prefs.remove('languageCode');
await prefs.remove('scriptCode');
await prefs.remove('countryCode');
} else {
await prefs.setString('languageCode', newLocale.languageCode);
if (newLocale.scriptCode != null) {
await prefs.setString('scriptCode', newLocale.scriptCode!);
} else {
await prefs.remove('scriptCode');
}
if (newLocale.countryCode != null) {
await prefs.setString('countryCode', newLocale.countryCode!);
} else {
await prefs.remove('countryCode');
}
}
notifyListeners();
}
@@ -56,18 +102,59 @@ class SettingsProvider with ChangeNotifier {
notifyListeners();
}
// Also provide the RTMP URL based on the same hostname
String get rtmpUrl {
final uri = Uri.parse(_baseUrl);
return "rtmp://${uri.host}:1935/live";
void setLivePreviewThumbnailsEnabled(bool enabled) async {
_livePreviewThumbnailsEnabled = enabled;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('livePreviewThumbnailsEnabled', enabled);
notifyListeners();
}
String playbackUrl(String roomId) {
final uri = Uri.parse(_baseUrl);
// Also provide the RTMP URL based on the same hostname
String get rtmpUrl {
final host = _baseUrl.isEmpty ? _effectiveHost : Uri.parse(_baseUrl).host;
return "rtmp://$host:1935/live";
}
// Fallback hostname when baseUrl is empty (web same-origin mode)
String get _effectiveHost {
if (kIsWeb) {
return uri.replace(path: '/live/$roomId').toString();
final host = Uri.base.host;
if (host.isNotEmpty) return host;
return 'localhost';
}
return "$rtmpUrl/$roomId";
return 'localhost';
}
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";
}
String thumbnailUrl(String roomId, {String? cacheBuster}) {
final uri = Uri.parse(_baseUrl);
return uri
.replace(
path: '/api/rooms/$roomId/thumbnail',
queryParameters: cacheBuster == null ? null : {'t': cacheBuster},
)
.toString();
}
ThemeMode _themeModeFromString(String value) {

View File

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

View File

@@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
class ChatMessage {
@@ -38,31 +39,64 @@ class ChatMessage {
class ChatService {
WebSocketChannel? _channel;
final StreamController<ChatMessage> _messageController = StreamController<ChatMessage>.broadcast();
final StreamController<ChatMessage> _messageController =
StreamController<ChatMessage>.broadcast();
Stream<ChatMessage> get messages => _messageController.stream;
void connect(String baseUrl, String roomId, String username) {
final wsUri = Uri.parse(baseUrl).replace(scheme: 'ws', path: '/api/ws/room/$roomId', queryParameters: {'username': username});
final wsUri = _webSocketUri(baseUrl).replace(
path: '/api/ws/room/$roomId',
queryParameters: {'username': username},
);
_channel = WebSocketChannel.connect(wsUri);
_channel!.stream.listen((data) {
final json = jsonDecode(data);
_messageController.add(ChatMessage.fromJson(json));
}, onError: (err) {
print("[WS ERROR] $err");
}, onDone: () {
print("[WS DONE] Connection closed");
});
_channel!.stream.listen(
(data) {
final json = jsonDecode(data);
_messageController.add(ChatMessage.fromJson(json));
},
onError: (err) {
debugPrint("[WS ERROR] $err");
},
onDone: () {
debugPrint("[WS DONE] Connection closed");
},
);
}
void sendMessage(String content, String username, String roomId, {String type = 'chat'}) {
void sendMessage(
String content,
String username,
String roomId, {
String type = 'chat',
}) {
if (_channel != null) {
final msg = ChatMessage(type: type, username: username, content: content, roomId: roomId);
final msg = ChatMessage(
type: type,
username: username,
content: content,
roomId: roomId,
);
_channel!.sink.add(jsonEncode(msg.toJson()));
}
}
Uri _webSocketUri(String baseUrl) {
if (baseUrl.isEmpty) {
if (kIsWeb) {
return Uri.base.replace(
scheme: Uri.base.scheme == 'https' ? 'wss' : 'ws',
);
}
return Uri.parse('http://localhost:8080');
}
final uri = Uri.parse(baseUrl);
final scheme = uri.scheme == 'https' ? 'wss' : 'ws';
return uri.replace(scheme: scheme);
}
void dispose() {
_channel?.sink.close();
_messageController.close();

View File

@@ -0,0 +1,477 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:rtmp_streaming/camera.dart';
class AndroidQuickStreamPanel extends StatefulWidget {
final String rtmpBaseUrl;
final String streamKey;
const AndroidQuickStreamPanel({
super.key,
required this.rtmpBaseUrl,
required this.streamKey,
});
@override
State<AndroidQuickStreamPanel> createState() =>
_AndroidQuickStreamPanelState();
}
class _AndroidQuickStreamPanelState extends State<AndroidQuickStreamPanel> {
final CameraController _controller = CameraController(
ResolutionPreset.medium,
enableAudio: true,
androidUseOpenGL: true,
);
List<CameraDescription> _cameras = const [];
CameraDescription? _currentCamera;
StreamStatistics? _stats;
Timer? _statsTimer;
bool _permissionsGranted = false;
bool _isPreparing = true;
bool _isBusy = false;
bool _audioEnabled = true;
String? _statusMessage;
String get _streamUrl => '${widget.rtmpBaseUrl}/${widget.streamKey}';
bool get _isInitialized => _controller.value.isInitialized ?? false;
bool get _isStreaming => _controller.value.isStreamingVideoRtmp ?? false;
@override
void initState() {
super.initState();
_controller.addListener(_onControllerChanged);
_initialize();
}
@override
void dispose() {
_statsTimer?.cancel();
_controller.removeListener(_onControllerChanged);
_controller.dispose();
super.dispose();
}
Future<void> _initialize() async {
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
setState(() {
_isPreparing = false;
_statusMessage = 'Quick stream is only supported on Android.';
});
return;
}
if (!mounted) {
return;
}
setState(() {
_isPreparing = true;
_statusMessage = null;
});
final cameraStatus = await Permission.camera.request();
final micStatus = await Permission.microphone.request();
final granted = cameraStatus.isGranted && micStatus.isGranted;
if (!mounted) {
return;
}
if (!granted) {
setState(() {
_permissionsGranted = false;
_isPreparing = false;
_statusMessage =
'Camera and microphone permissions are required for quick streaming.';
});
return;
}
try {
final cameras = await availableCameras();
if (!mounted) {
return;
}
if (cameras.isEmpty) {
setState(() {
_permissionsGranted = true;
_isPreparing = false;
_statusMessage = 'No available cameras were found on this device.';
});
return;
}
_cameras = cameras;
_currentCamera = cameras.first;
await _controller.initialize(_currentCamera!);
if (!mounted) {
return;
}
setState(() {
_permissionsGranted = true;
_isPreparing = false;
_statusMessage = 'Ready to go live.';
});
} on CameraException catch (e) {
if (!mounted) {
return;
}
setState(() {
_permissionsGranted = true;
_isPreparing = false;
_statusMessage = e.description ?? e.code;
});
} catch (e) {
if (!mounted) {
return;
}
setState(() {
_permissionsGranted = true;
_isPreparing = false;
_statusMessage = 'Failed to initialize camera: $e';
});
}
}
void _onControllerChanged() {
if (!mounted) {
return;
}
final event = _controller.value.event;
final eventType = event is Map ? event['eventType']?.toString() : null;
if (eventType == 'rtmp_stopped' || eventType == 'camera_closing') {
_statsTimer?.cancel();
setState(() {
_statusMessage =
_controller.value.errorDescription ?? 'Streaming stopped.';
});
return;
}
if (eventType == 'error' || eventType == 'rtmp_retry') {
setState(() {
_statusMessage =
_controller.value.errorDescription ?? 'Streaming error occurred.';
});
return;
}
setState(() {});
}
Future<void> _startStreaming() async {
if (!_isInitialized || _isBusy) {
return;
}
setState(() {
_isBusy = true;
_statusMessage = 'Connecting to stream server...';
});
try {
await _controller.startVideoStreaming(_streamUrl, bitrate: 1500 * 1024);
if (_isStreaming && !_audioEnabled) {
await _controller.switchAudio(false);
}
_startStatsPolling();
if (!mounted) {
return;
}
setState(() => _statusMessage = 'Quick stream is live.');
} on CameraException catch (e) {
if (!mounted) {
return;
}
setState(() => _statusMessage = e.description ?? e.code);
} finally {
if (mounted) {
setState(() => _isBusy = false);
}
}
}
Future<void> _stopStreaming() async {
if (!_isStreaming || _isBusy) {
return;
}
setState(() => _isBusy = true);
try {
await _controller.stopVideoStreaming();
_statsTimer?.cancel();
if (!mounted) {
return;
}
setState(() {
_stats = null;
_statusMessage = 'Quick stream stopped.';
});
} on CameraException catch (e) {
if (!mounted) {
return;
}
setState(() => _statusMessage = e.description ?? e.code);
} finally {
if (mounted) {
setState(() => _isBusy = false);
}
}
}
Future<void> _switchCamera() async {
if (_cameras.length < 2 || !_isInitialized || _isBusy) {
return;
}
final currentIndex = _cameras.indexOf(_currentCamera!);
final nextCamera = _cameras[(currentIndex + 1) % _cameras.length];
final nextCameraName = nextCamera.name;
if (nextCameraName == null || nextCameraName.isEmpty) {
setState(
() => _statusMessage = 'Unable to switch camera on this device.',
);
return;
}
setState(() => _isBusy = true);
try {
await _controller.switchCamera(nextCameraName);
if (!mounted) {
return;
}
setState(() {
_currentCamera = nextCamera;
_statusMessage = 'Switched camera.';
});
} on CameraException catch (e) {
if (!mounted) {
return;
}
setState(() => _statusMessage = e.description ?? e.code);
} finally {
if (mounted) {
setState(() => _isBusy = false);
}
}
}
Future<void> _toggleAudio() async {
final nextAudioEnabled = !_audioEnabled;
if (!_isStreaming) {
setState(() {
_audioEnabled = nextAudioEnabled;
_statusMessage = nextAudioEnabled
? 'Microphone will be enabled when streaming starts.'
: 'Microphone will be muted after streaming starts.';
});
return;
}
setState(() => _isBusy = true);
try {
await _controller.switchAudio(nextAudioEnabled);
if (!mounted) {
return;
}
setState(() {
_audioEnabled = nextAudioEnabled;
_statusMessage = nextAudioEnabled
? 'Microphone enabled.'
: 'Microphone muted.';
});
} on CameraException catch (e) {
if (!mounted) {
return;
}
setState(() => _statusMessage = e.description ?? e.code);
} finally {
if (mounted) {
setState(() => _isBusy = false);
}
}
}
void _startStatsPolling() {
_statsTimer?.cancel();
_statsTimer = Timer.periodic(const Duration(seconds: 2), (_) async {
if (!_isStreaming) {
return;
}
try {
final stats = await _controller.getStreamStatistics();
if (!mounted) {
return;
}
setState(() => _stats = stats);
} catch (_) {
// Ignore transient stats failures while streaming starts up.
}
});
}
@override
Widget build(BuildContext context) {
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
return const SizedBox.shrink();
}
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.rocket_launch,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 10),
Text(
'Quick Stream (Experimental)',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 8),
Text(
'Start streaming directly from your phone camera. For advanced scenes and overlays, continue using OBS.',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 16),
_buildPreview(context),
const SizedBox(height: 12),
_buildStatus(context),
const SizedBox(height: 12),
Wrap(
spacing: 10,
runSpacing: 10,
children: [
FilledButton.icon(
onPressed: !_permissionsGranted || _isPreparing || _isBusy
? null
: _isStreaming
? _stopStreaming
: _startStreaming,
icon: Icon(_isStreaming ? Icons.stop_circle : Icons.wifi),
label: Text(_isStreaming ? 'Stop Stream' : 'Start Stream'),
),
OutlinedButton.icon(
onPressed: _cameras.length > 1 && !_isBusy && _isInitialized
? _switchCamera
: null,
icon: const Icon(Icons.cameraswitch),
label: const Text('Switch Camera'),
),
OutlinedButton.icon(
onPressed: _isBusy || !_permissionsGranted
? null
: _toggleAudio,
icon: Icon(_audioEnabled ? Icons.mic : Icons.mic_off),
label: Text(_audioEnabled ? 'Mic On' : 'Mic Off'),
),
TextButton.icon(
onPressed: _isBusy ? null : _initialize,
icon: const Icon(Icons.refresh),
label: const Text('Reinitialize'),
),
],
),
const SizedBox(height: 12),
Text(
'Target: $_streamUrl',
style: Theme.of(context).textTheme.bodySmall,
),
if (_stats != null) ...[
const SizedBox(height: 8),
Text(
'Stats: ${_stats!.width ?? '-'}x${_stats!.height ?? '-'} | ${_stats!.fps ?? '-'} fps | ${_formatKbps(_stats!.bitrate)} | dropped ${_stats!.droppedVideoFrames ?? 0} video',
style: Theme.of(context).textTheme.bodySmall,
),
],
],
),
),
);
}
Widget _buildPreview(BuildContext context) {
return Container(
height: 220,
width: double.infinity,
decoration: BoxDecoration(
color: Colors.black,
borderRadius: BorderRadius.circular(16),
),
clipBehavior: Clip.antiAlias,
child: _isPreparing
? const Center(child: CircularProgressIndicator())
: !_permissionsGranted
? _buildPreviewMessage('Grant camera and microphone permissions.')
: !_isInitialized
? _buildPreviewMessage('Camera is not ready yet.')
: AspectRatio(
aspectRatio: _controller.value.aspectRatio,
child: CameraPreview(_controller),
),
);
}
Widget _buildPreviewMessage(String text) {
return Center(
child: Padding(
padding: const EdgeInsets.all(20),
child: Text(
text,
style: const TextStyle(color: Colors.white70),
textAlign: TextAlign.center,
),
),
);
}
Widget _buildStatus(BuildContext context) {
final statusText =
_statusMessage ??
(_isStreaming ? 'Quick stream is live.' : 'Waiting for camera.');
return Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(
_isStreaming ? Icons.circle : Icons.info_outline,
size: _isStreaming ? 12 : 18,
color: _isStreaming ? Colors.red : null,
),
const SizedBox(width: 10),
Expanded(child: Text(statusText)),
],
),
);
}
String _formatKbps(int? bitrate) {
if (bitrate == null || bitrate <= 0) {
return '- kbps';
}
return '${(bitrate / 1000).toStringAsFixed(0)} kbps';
}
}

View File

@@ -2,11 +2,13 @@ import 'package:flutter/material.dart';
class WebStreamPlayer extends StatelessWidget {
final String streamUrl;
final double volume;
final int? refreshToken;
const WebStreamPlayer({
super.key,
required this.streamUrl,
required this.volume,
this.refreshToken,
});

View File

@@ -1,3 +1,5 @@
// ignore_for_file: avoid_web_libraries_in_flutter, deprecated_member_use
import 'dart:html' as html;
import 'dart:ui_web' as ui_web;
@@ -5,11 +7,13 @@ import 'package:flutter/material.dart';
class WebStreamPlayer extends StatefulWidget {
final String streamUrl;
final double volume;
final int? refreshToken;
const WebStreamPlayer({
super.key,
required this.streamUrl,
required this.volume,
this.refreshToken,
});
@@ -19,6 +23,7 @@ class WebStreamPlayer extends StatefulWidget {
class _WebStreamPlayerState extends State<WebStreamPlayer> {
late final String _viewType;
html.IFrameElement? _iframe;
@override
void initState() {
@@ -29,15 +34,30 @@ class _WebStreamPlayerState extends State<WebStreamPlayer> {
ui_web.platformViewRegistry.registerViewFactory(_viewType, (int viewId) {
final iframe = html.IFrameElement()
..src =
'flv_player.html?v=$cacheBuster&src=${Uri.encodeComponent(widget.streamUrl)}'
'flv_player.html?v=$cacheBuster'
'&src=${Uri.encodeComponent(widget.streamUrl)}'
'&volume=${widget.volume}'
..style.border = '0'
..style.width = '100%'
..style.height = '100%'
..style.pointerEvents = 'none'
..allow = 'autoplay; fullscreen';
_iframe = iframe;
return iframe;
});
}
@override
void didUpdateWidget(covariant WebStreamPlayer oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.volume != widget.volume) {
_iframe?.contentWindow?.postMessage({
'type': 'setVolume',
'value': widget.volume,
}, '*');
}
}
@override
Widget build(BuildContext context) {
return HtmlElementView(viewType: _viewType);

View File

@@ -150,6 +150,11 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_localizations:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_test:
dependency: "direct dev"
description: flutter
@@ -216,6 +221,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.8.0"
intl:
dependency: "direct main"
description:
name: intl
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
url: "https://pub.dev"
source: hosted
version: "0.20.2"
json_annotation:
dependency: transitive
description:
@@ -368,6 +381,54 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.0"
permission_handler:
dependency: "direct main"
description:
name: permission_handler
sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1
url: "https://pub.dev"
source: hosted
version: "12.0.1"
permission_handler_android:
dependency: transitive
description:
name: permission_handler_android
sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6"
url: "https://pub.dev"
source: hosted
version: "13.0.1"
permission_handler_apple:
dependency: transitive
description:
name: permission_handler_apple
sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023
url: "https://pub.dev"
source: hosted
version: "9.4.7"
permission_handler_html:
dependency: transitive
description:
name: permission_handler_html
sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24"
url: "https://pub.dev"
source: hosted
version: "0.1.3+5"
permission_handler_platform_interface:
dependency: transitive
description:
name: permission_handler_platform_interface
sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878
url: "https://pub.dev"
source: hosted
version: "4.3.0"
permission_handler_windows:
dependency: transitive
description:
name: permission_handler_windows
sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e"
url: "https://pub.dev"
source: hosted
version: "0.2.1"
petitparser:
dependency: transitive
description:
@@ -416,6 +477,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
rtmp_streaming:
dependency: "direct main"
description:
name: rtmp_streaming
sha256: f54c0c0443df65086d2936b0f3432fbb351fc35bffba69aa2b004ee7ecf45d40
url: "https://pub.dev"
source: hosted
version: "1.0.5"
shared_preferences:
dependency: "direct main"
description:

View File

@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# 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.
version: 1.0.0-beta4.1
version: 1.0.1
environment:
sdk: ^3.11.1
@@ -40,6 +40,11 @@ dependencies:
video_player: ^2.11.1
fvp: ^0.35.2
web_socket_channel: ^3.0.3
permission_handler: ^12.0.1
rtmp_streaming: ^1.0.5
intl: ^0.20.2
flutter_localizations:
sdk: flutter
dev_dependencies:
flutter_test:
@@ -73,6 +78,7 @@ flutter_launcher_icons:
# The following section is specific to Flutter packages.
flutter:
generate: true
# The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in

View File

@@ -45,14 +45,21 @@
<script src="flv.min.js"></script>
</head>
<body>
<video id="player" controls autoplay muted playsinline></video>
<video id="player" 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 initialVolume = Number.parseFloat(params.get('volume') || '1');
const video = document.getElementById('player');
const message = document.getElementById('message');
function applyVolume(value) {
const normalized = Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : 1;
video.volume = normalized;
video.muted = normalized === 0;
}
function showMessage(text) {
video.style.display = 'none';
message.style.display = 'flex';
@@ -66,12 +73,15 @@
} else if (!flvjs.isSupported()) {
showMessage('This browser does not support FLV playback.');
} else {
applyVolume(initialVolume);
const player = flvjs.createPlayer({
type: 'flv',
url: streamUrl,
isLive: true,
}, {
enableWorker: false,
enableStashBuffer: false,
stashInitialSize: 128,
});
@@ -79,6 +89,24 @@
player.load();
player.play().catch(() => {});
// Live latency auto-catchup logic
video.addEventListener('timeupdate', function() {
if (video.buffered.length > 0) {
const end = video.buffered.end(video.buffered.length - 1);
const diff = end - video.currentTime;
if (diff > 5) {
// If way behind, jump directly close to the live edge
video.currentTime = end - 1.0;
} else if (diff > 1.5) {
// Speed up slightly to catch up
video.playbackRate = 1.15;
} else {
// Reset to normal speed
video.playbackRate = 1.0;
}
}
});
player.on(flvjs.Events.ERROR, function(errorType, detail, info) {
const parts = ['Live stream failed to load.'];
if (errorType) parts.push('type=' + errorType);
@@ -90,6 +118,13 @@
video.style.display = 'block';
message.style.display = 'none';
window.addEventListener('message', function(event) {
const data = event.data || {};
if (data.type === 'setVolume') {
applyVolume(Number(data.value));
}
});
window.addEventListener('beforeunload', function() {
player.destroy();
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

395
website/hightube/index.html Normal file

File diff suppressed because one or more lines are too long

804
website/hightube/styles.css Normal file
View File

@@ -0,0 +1,804 @@
:root {
color-scheme: light dark;
--primary: #0b57d0;
--on-primary: #ffffff;
--primary-container: #d7e3ff;
--on-primary-container: #001b3f;
--secondary: #565f71;
--tertiary: #705575;
--surface: #fbfcff;
--surface-rgb: 251 252 255;
--surface-container: #eef3fb;
--surface-container-high: #e5ebf5;
--outline: #727782;
--outline-rgb: 114 119 130;
--text: #191c20;
--muted: #42474f;
--success: #146c2e;
--warning: #7a5900;
--shadow: 0 24px 60px rgba(11, 87, 208, 0.16);
--topbar-bg: rgba(251, 252, 255, 0.68);
--topbar-border: rgba(114, 119, 130, 0.18);
--topbar-shadow: 0 12px 36px rgba(11, 87, 208, 0.08);
--grid-divider: rgba(114, 119, 130, 0.24);
--card-border: rgba(114, 119, 130, 0.24);
--device-border: rgba(114, 119, 130, 0.32);
--status-available-bg: rgba(20, 108, 46, 0.12);
--status-planned-bg: rgba(122, 89, 0, 0.12);
}
/* ---- Dark theme: forced ---- */
[data-theme="dark"] {
--primary: #a8c7ff;
--on-primary: #001b3f;
--primary-container: #003a7a;
--on-primary-container: #d7e3ff;
--secondary: #bcc7db;
--tertiary: #d7bde0;
--surface: #111318;
--surface-rgb: 17 19 24;
--surface-container: #1a1d25;
--surface-container-high: #21242d;
--outline: #8b909c;
--outline-rgb: 139 144 156;
--text: #e3e3e8;
--muted: #b0b3bd;
--success: #81c784;
--warning: #ffe08a;
--shadow: 0 24px 60px rgba(0, 0, 0, 0.4);
--topbar-bg: rgba(17, 19, 24, 0.72);
--topbar-border: rgba(139, 144, 156, 0.18);
--topbar-shadow: 0 12px 36px rgba(0, 0, 0, 0.28);
--grid-divider: rgba(139, 144, 156, 0.2);
--card-border: rgba(139, 144, 156, 0.2);
--device-border: rgba(139, 144, 156, 0.28);
--status-available-bg: rgba(129, 199, 132, 0.15);
--status-planned-bg: rgba(255, 224, 138, 0.15);
}
/* ---- Dark theme: auto (system preference, no manual override) ---- */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--primary: #a8c7ff;
--on-primary: #001b3f;
--primary-container: #003a7a;
--on-primary-container: #d7e3ff;
--secondary: #bcc7db;
--tertiary: #d7bde0;
--surface: #111318;
--surface-rgb: 17 19 24;
--surface-container: #1a1d25;
--surface-container-high: #21242d;
--outline: #8b909c;
--outline-rgb: 139 144 156;
--text: #e3e3e8;
--muted: #b0b3bd;
--success: #81c784;
--warning: #ffe08a;
--shadow: 0 24px 60px rgba(0, 0, 0, 0.4);
--topbar-bg: rgba(17, 19, 24, 0.72);
--topbar-border: rgba(139, 144, 156, 0.18);
--topbar-shadow: 0 12px 36px rgba(0, 0, 0, 0.28);
--grid-divider: rgba(139, 144, 156, 0.2);
--card-border: rgba(139, 144, 156, 0.2);
--device-border: rgba(139, 144, 156, 0.28);
--status-available-bg: rgba(129, 199, 132, 0.15);
--status-planned-bg: rgba(255, 224, 138, 0.15);
}
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
min-width: 320px;
background: var(--surface);
color: var(--text);
font-family:
Inter, Roboto, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
line-height: 1.6;
transition: background-color 300ms ease, color 300ms ease;
}
a {
color: inherit;
}
.topbar {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 14px clamp(20px, 5vw, 72px);
background: var(--topbar-bg);
border-bottom: 1px solid var(--topbar-border);
box-shadow: var(--topbar-shadow);
-webkit-backdrop-filter: blur(22px) saturate(160%);
backdrop-filter: blur(22px) saturate(160%);
}
.brand {
display: inline-flex;
align-items: center;
gap: 10px;
color: var(--text);
font-weight: 800;
text-decoration: none;
}
.brand img {
width: 36px;
height: 36px;
border-radius: 10px;
}
.nav {
display: flex;
align-items: center;
gap: 6px;
}
.nav a {
min-height: 40px;
padding: 8px 14px;
border-radius: 20px;
color: var(--muted);
font-size: 0.94rem;
font-weight: 650;
text-decoration: none;
}
.nav a:hover {
background: var(--surface-container);
color: var(--primary);
}
/* 立即尝试 CTA button in nav */
.nav-cta {
background: var(--primary) !important;
color: var(--on-primary) !important;
margin-left: 8px;
font-weight: 800 !important;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.2);
transition: transform 160ms ease, box-shadow 160ms ease, background 160ms ease;
}
.nav-cta:hover {
filter: brightness(0.88);
transform: translateY(-1px);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.26);
}
/* Hamburger menu button — hidden on desktop */
.hamburger {
display: none;
flex-direction: column;
justify-content: center;
gap: 5px;
width: 40px;
height: 40px;
padding: 8px;
border: none;
border-radius: 10px;
background: transparent;
cursor: pointer;
z-index: 20;
}
.hamburger span {
display: block;
width: 100%;
height: 2.5px;
border-radius: 2px;
background: var(--text);
transition: transform 200ms ease, opacity 200ms ease;
}
.hamburger[aria-expanded="true"] span:nth-child(1) {
transform: translateY(7.5px) rotate(45deg);
}
.hamburger[aria-expanded="true"] span:nth-child(2) {
opacity: 0;
}
.hamburger[aria-expanded="true"] span:nth-child(3) {
transform: translateY(-7.5px) rotate(-45deg);
}
/* Right-side controls group (theme + hamburger) */
.topbar-actions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
/* Theme toggle button */
.theme-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
padding: 0;
border: none;
border-radius: 50%;
background: transparent;
color: var(--text);
font-size: 1.25rem;
cursor: pointer;
transition: background 200ms ease, transform 200ms ease;
flex-shrink: 0;
}
.theme-toggle:hover {
background: var(--surface-container);
transform: scale(1.08);
}
.theme-toggle:active {
transform: scale(0.94);
}
.theme-toggle svg {
width: 22px;
height: 22px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
/* hide all icons by default, show based on data-state */
.theme-toggle .icon-sun,
.theme-toggle .icon-moon,
.theme-toggle .icon-auto {
display: none;
}
.theme-toggle[data-state="light"] .icon-sun {
display: block;
}
.theme-toggle[data-state="dark"] .icon-moon {
display: block;
}
.theme-toggle[data-state="auto"] .icon-auto {
display: block;
}
.hero {
display: grid;
grid-template-columns: minmax(0, 1.05fr) minmax(320px, 0.95fr);
gap: clamp(28px, 6vw, 84px);
align-items: center;
min-height: calc(100vh - 68px);
padding: clamp(48px, 7vw, 96px) clamp(20px, 5vw, 72px);
}
.hero-copy {
max-width: 680px;
}
.eyebrow {
margin: 0 0 12px;
color: var(--primary);
font-size: 0.85rem;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
h1,
h2,
h3,
p {
overflow-wrap: anywhere;
}
h1 {
margin: 0;
color: var(--text);
font-size: clamp(3.5rem, 11vw, 8rem);
line-height: 0.9;
letter-spacing: 0;
}
h2 {
margin: 0;
font-size: clamp(2rem, 4vw, 3.5rem);
line-height: 1.05;
letter-spacing: 0;
}
h3 {
margin: 0;
font-size: 1.2rem;
line-height: 1.2;
letter-spacing: 0;
}
.lead {
max-width: 620px;
margin: 28px 0 0;
color: var(--muted);
font-size: clamp(1.1rem, 2vw, 1.35rem);
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 34px;
}
.button,
.download-link {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 48px;
padding: 12px 22px;
border-radius: 24px;
font-weight: 800;
text-decoration: none;
transition:
transform 160ms ease,
box-shadow 160ms ease,
background 160ms ease;
}
.button:hover,
.download-link:hover {
transform: translateY(-1px);
}
.primary {
background: var(--primary);
color: var(--on-primary);
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.18);
}
.secondary {
background: var(--primary-container);
color: var(--on-primary-container);
}
.hero-panel {
display: flex;
justify-content: center;
}
.device-window {
width: min(100%, 560px);
overflow: hidden;
border: 1px solid var(--device-border);
border-radius: 28px;
background: var(--surface-container);
box-shadow: var(--shadow);
}
.window-bar {
display: flex;
gap: 8px;
padding: 16px 18px;
border-bottom: 1px solid var(--card-border);
}
.window-bar span {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--outline);
}
.window-bar span:first-child {
background: #ba1a1a;
}
.window-bar span:nth-child(2) {
background: #a46700;
}
.window-bar span:nth-child(3) {
background: #146c2e;
}
.stream-preview {
position: relative;
display: grid;
min-height: 280px;
place-items: center;
background:
linear-gradient(135deg, rgba(11, 87, 208, 0.94), rgba(112, 85, 117, 0.9)),
radial-gradient(circle at 30% 30%, #d7e3ff, transparent 32%);
}
.live-badge {
position: absolute;
top: 18px;
left: 18px;
padding: 6px 12px;
border-radius: 16px;
background: #ba1a1a;
color: #ffffff;
font-size: 0.8rem;
font-weight: 900;
}
.play-symbol {
width: 86px;
height: 86px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.88);
clip-path: polygon(28% 18%, 28% 82%, 82% 50%);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1px;
background: var(--grid-divider);
}
.stats-grid div {
min-width: 0;
padding: 18px 14px;
background: var(--surface);
}
.stats-grid strong,
.stats-grid span {
display: block;
}
.stats-grid strong {
color: var(--primary);
font-size: 1.02rem;
}
.stats-grid span {
color: var(--muted);
font-size: 0.88rem;
}
.section {
padding: clamp(64px, 8vw, 112px) clamp(20px, 5vw, 72px);
}
.section-heading {
max-width: 820px;
margin-bottom: 32px;
}
.section-heading p:not(.eyebrow) {
max-width: 760px;
color: var(--muted);
font-size: 1.06rem;
}
.architecture-grid,
.download-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 18px;
}
.architecture-card,
.download-card {
min-width: 0;
padding: 24px;
border: 1px solid var(--card-border);
border-radius: 24px;
background: var(--surface-container);
}
.architecture-card .icon {
display: grid;
width: 48px;
height: 48px;
margin-bottom: 20px;
place-items: center;
border-radius: 16px;
background: var(--primary);
color: var(--on-primary);
}
.architecture-card .icon svg {
width: 26px;
height: 26px;
}
.architecture-card p,
.download-card p,
.feature-item p,
.source-section p {
color: var(--muted);
}
.feature-band {
background: var(--surface-container);
}
.feature-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1px;
overflow: hidden;
border: 1px solid var(--card-border);
border-radius: 28px;
background: var(--grid-divider);
}
.feature-item {
min-width: 0;
padding: 28px;
background: var(--surface);
}
.feature-icon {
width: 36px;
height: 36px;
margin-bottom: 14px;
color: var(--primary);
}
.feature-icon svg {
width: 100%;
height: 100%;
display: block;
}
.download-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.download-card {
display: flex;
min-height: 240px;
flex-direction: column;
align-items: flex-start;
}
.download-icon {
width: 32px;
height: 32px;
margin-bottom: 14px;
color: var(--muted);
}
.download-icon svg {
width: 100%;
height: 100%;
display: block;
}
.status {
margin-bottom: 18px;
padding: 5px 10px;
border-radius: 14px;
background: var(--status-available-bg);
color: var(--success);
font-size: 0.78rem;
font-weight: 850;
}
.status.muted {
background: var(--status-planned-bg);
color: var(--warning);
}
.download-link {
margin-top: auto;
background: var(--primary);
color: var(--on-primary);
}
.planned {
background: var(--surface-container-high);
}
.source-section {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
background: #001b3f;
color: #ffffff;
}
.source-section .eyebrow,
.source-section p {
color: #d7e3ff;
}
.source-section div {
max-width: 820px;
}
.footer {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 16px;
padding: 28px clamp(20px, 5vw, 72px);
background: #001533;
color: #d7e3ff;
font-size: 0.92rem;
}
.footer span:nth-child(2) {
text-align: center;
}
.footer span:last-child {
text-align: right;
}
@media (max-width: 900px) {
.topbar {
flex-direction: row;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
}
.hamburger {
display: flex;
}
.nav {
display: none;
width: 100%;
flex-direction: column;
gap: 2px;
padding: 8px 0 4px;
border-top: 1px solid var(--topbar-border);
margin-top: 10px;
}
.nav.open {
display: flex;
}
.nav a {
width: 100%;
padding: 12px 16px;
border-radius: 14px;
font-size: 1rem;
}
.nav-cta {
margin-left: 0 !important;
margin-top: 6px;
text-align: center;
}
.hero {
grid-template-columns: 1fr;
min-height: auto;
}
.architecture-grid,
.download-grid {
grid-template-columns: 1fr 1fr;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.source-section {
align-items: flex-start;
flex-direction: column;
}
}
@media (max-width: 640px) {
.topbar {
padding: 10px 16px;
}
.hero,
.section {
padding-right: 16px;
padding-left: 16px;
}
.hero {
padding-top: 32px;
padding-bottom: 32px;
gap: 24px;
}
h1 {
font-size: clamp(2.8rem, 10vw, 4rem);
}
h2 {
font-size: clamp(1.6rem, 5vw, 2.4rem);
}
.lead {
font-size: 1rem;
margin-top: 16px;
}
.hero-panel {
width: 100%;
}
.device-window {
width: 100%;
border-radius: 20px;
}
.stream-preview {
min-height: 180px;
}
.play-symbol {
width: 60px;
height: 60px;
}
.architecture-grid,
.download-grid,
.feature-list {
grid-template-columns: 1fr;
}
.architecture-card,
.download-card {
padding: 20px;
border-radius: 18px;
}
.feature-item {
padding: 20px;
}
.actions {
flex-direction: column;
}
.button,
.download-link {
width: 100%;
justify-content: center;
}
.nav a {
min-height: 48px;
padding: 14px 16px;
font-size: 1.05rem;
}
.nav-cta {
min-height: 50px;
font-size: 1.05rem;
}
.footer {
grid-template-columns: 1fr;
gap: 8px;
padding: 24px 16px;
}
.footer-hide-mobile {
display: none;
}
}

344
website/index.html Normal file
View File

@@ -0,0 +1,344 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta
name="description"
content="Hightube is an open source, cross-platform live streaming platform built with Flutter and Go."
/>
<title>Hightube | Open Source Live Streaming Platform</title>
<link rel="icon" href="assets/hightube-icon.png" />
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<header class="topbar">
<a class="brand" href="#home" aria-label="Hightube home">
<img src="assets/hightube-icon.png" alt="" />
<span>Hightube</span>
</a>
<nav class="nav" aria-label="Primary navigation">
<a href="#architecture">Architecture</a>
<a href="#features">Features</a>
<a href="#downloads">Downloads</a>
<a href="#source">Source</a>
<a class="nav-cta" href="https://stream.nudt.space" target="_blank" rel="noopener">Try Now</a>
</nav>
<div class="topbar-actions">
<button class="theme-toggle" data-state="auto" aria-label="Theme: Auto" title="Theme: Auto — click to force Light">
<svg class="icon-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>
<svg class="icon-moon" viewBox="0 0 24 24"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
<svg class="icon-auto" viewBox="0 0 24 24"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>
</button>
<button class="hamburger" aria-label="Toggle navigation" aria-expanded="false">
<span></span>
<span></span>
<span></span>
</button>
</div>
</header>
<main id="home">
<section class="hero">
<div class="hero-copy">
<p class="eyebrow">Flutter + Go live streaming stack</p>
<h1>Hightube</h1>
<p class="lead">
An open source live streaming platform designed for creators,
private communities, classrooms, labs, and self-hosted media
services.
</p>
<div class="actions">
<a class="button primary" href="#downloads">Download builds</a>
<a
class="button secondary"
href="https://git.nudt.space/Highground-Soft/Hightube.git"
rel="noopener"
>
View source
</a>
</div>
</div>
<div class="hero-panel" aria-label="Hightube platform overview">
<div class="device-window">
<div class="window-bar">
<span></span>
<span></span>
<span></span>
</div>
<div class="stream-preview">
<div class="live-badge">LIVE</div>
<div class="play-symbol"></div>
</div>
<div class="stats-grid">
<div>
<strong>Flutter</strong>
<span>Client UI</span>
</div>
<div>
<strong>Go</strong>
<span>Backend API</span>
</div>
<div>
<strong>RTMP</strong>
<span>Streaming</span>
</div>
<div>
<strong>Open</strong>
<span>Source</span>
</div>
</div>
</div>
</div>
</section>
<section id="architecture" class="section">
<div class="section-heading">
<p class="eyebrow">Architecture</p>
<h2>Built with Flutter clients and a Go streaming backend</h2>
<p>
Hightube separates a portable client experience from a compact,
self-hostable backend, keeping deployment simple while leaving room
for native desktop, mobile, and web clients.
</p>
</div>
<div class="architecture-grid">
<article class="architecture-card">
<div class="icon">F</div>
<h3>Flutter frontends</h3>
<p>
One UI technology stack targets Linux, Android, Web, and future
desktop builds with consistent interaction patterns.
</p>
</article>
<article class="architecture-card">
<div class="icon">G</div>
<h3>Go backend</h3>
<p>
The server handles API requests, authentication, live room state,
RTMP publishing, HTTP-FLV playback, chat, and monitoring.
</p>
</article>
<article class="architecture-card">
<div class="icon">S</div>
<h3>Self-hosted streaming</h3>
<p>
Deploy the server on your own Linux host and connect compatible
broadcasters such as OBS through standard RTMP workflows.
</p>
</article>
</div>
</section>
<section id="features" class="section feature-band">
<div class="section-heading">
<p class="eyebrow">Project highlights</p>
<h2>Open, free, and cross-platform by design</h2>
</div>
<div class="feature-list">
<div class="feature-item">
<h3>Open source freedom</h3>
<p>
Read, modify, build, and deploy the platform from source without
vendor lock-in.
</p>
</div>
<div class="feature-item">
<h3>Cross-platform clients</h3>
<p>
Current builds include Linux, Web, and Android client packages,
with Windows builds planned later.
</p>
</div>
<div class="feature-item">
<h3>Practical live features</h3>
<p>
Live rooms, RTMP publishing, HTTP-FLV playback, multi-quality
transcoding, chat, danmaku-style messages, and admin monitoring.
</p>
</div>
<div class="feature-item">
<h3>Small deployment surface</h3>
<p>
The Go server uses a lightweight runtime model and can be deployed
as a single Linux server component.
</p>
</div>
</div>
</section>
<section id="downloads" class="section">
<div class="section-heading">
<p class="eyebrow">Downloads</p>
<h2>Get the current executable builds</h2>
<p>
All builds are distributed through the project release page. Apple
device builds are not provided at this time.
</p>
</div>
<div class="download-grid">
<article class="download-card available">
<span class="status">Available</span>
<h3>Linux server</h3>
<p>Backend service for self-hosted streaming deployments.</p>
<a
class="download-link"
href="https://git.nudt.space/Highground-Soft/Hightube/releases/download/v1.0.0/hightube-server_amd64_v1.0.0"
rel="noopener"
>
Download Linux server
</a>
</article>
<article class="download-card available">
<span class="status">Available</span>
<h3>Linux client</h3>
<p>Desktop Flutter client packaged as an AppImage.</p>
<a
class="download-link"
href="https://git.nudt.space/Highground-Soft/Hightube/releases/download/v1.0.0/hightube-linux-amd64-v1.0.0.AppImage"
rel="noopener"
>
Download Linux AppImage
</a>
</article>
<article class="download-card available">
<span class="status">Available</span>
<h3>Android client</h3>
<p>ARM64 Android APK build for mobile viewing and interaction.</p>
<a
class="download-link"
href="https://git.nudt.space/Highground-Soft/Hightube/releases/download/v1.0.0/hightube-android-arm64-v8a-v1.0.0.apk"
rel="noopener"
>
Download Android APK
</a>
</article>
<article class="download-card available">
<span class="status">Available</span>
<h3>Web client</h3>
<p>Static web build archive for hosting the Flutter web frontend.</p>
<a
class="download-link"
href="https://git.nudt.space/Highground-Soft/Hightube/releases/download/v1.0.0/hightube-web-v1.0.0.tar.gz"
rel="noopener"
>
Download Web archive
</a>
</article>
<article class="download-card available">
<span class="status">Available</span>
<h3>Windows server</h3>
<p>Server executable for Windows environments.</p>
<a
class="download-link"
href="https://git.nudt.space/Highground-Soft/Hightube/releases/download/v1.0.0/hightube-server_amd64_v1.0.0.exe"
rel="noopener"
>
Download Windows server
</a>
</article>
<article class="download-card available">
<span class="status">Available</span>
<h3>Windows client</h3>
<p>Desktop client build for Windows users.</p>
<a
class="download-link"
href="https://git.nudt.space/Highground-Soft/Hightube/releases/download/v1.0.0/hightube-win_amd64_v1.0.0.zip"
rel="noopener"
>
Download Windows client
</a>
</article>
</div>
</section>
<section id="source" class="section source-section">
<div>
<p class="eyebrow">Source code</p>
<h2>Build it, audit it, host it your way</h2>
<p>
Hightube is developed as an open source project. Clone the source,
inspect the Flutter and Go code, build your own binaries, and adapt
it for your own live platform.
</p>
</div>
<a
class="button primary"
href="https://git.nudt.space/Highground-Soft/Hightube.git"
rel="noopener"
>
Open repository
</a>
</section>
</main>
<script>
(function () {
/* ---- hamburger menu ---- */
var btn = document.querySelector('.hamburger');
var nav = document.querySelector('.nav');
if (btn && nav) {
btn.addEventListener('click', function () {
var open = nav.classList.toggle('open');
btn.setAttribute('aria-expanded', open);
});
}
/* ---- theme toggle ---- */
var toggle = document.querySelector('.theme-toggle');
if (!toggle) return;
var STATES = ['auto', 'light', 'dark'];
var LABELS = {
auto: 'Theme: Auto — click to force Light',
light: 'Theme: Light — click to force Dark',
dark: 'Theme: Dark — click to return to Auto'
};
function getTheme() {
return localStorage.getItem('theme') || 'auto';
}
function applyTheme(state) {
if (state === 'light') {
document.documentElement.setAttribute('data-theme', 'light');
} else if (state === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark');
} else {
document.documentElement.removeAttribute('data-theme');
}
toggle.setAttribute('data-state', state);
toggle.setAttribute('aria-label', 'Theme: ' + state.charAt(0).toUpperCase() + state.slice(1));
toggle.setAttribute('title', LABELS[state]);
localStorage.setItem('theme', state);
}
function nextTheme(current) {
var idx = STATES.indexOf(current);
return STATES[(idx + 1) % STATES.length];
}
// init
var current = getTheme();
applyTheme(current);
toggle.addEventListener('click', function () {
applyTheme(nextTheme(getTheme()));
});
// listen for system changes (only matters in auto mode)
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function () {
if (getTheme() === 'auto') {
// force a repaint by re-applying auto
applyTheme('auto');
}
});
})();
</script>
<footer class="footer">
<span class="footer-hide-mobile">Hightube</span>
<span>Built with ❤️ by HighGround-soft 2026</span>
<span class="footer-hide-mobile">Open Source Live Platform</span>
</footer>
</body>
</html>

774
website/styles.css Normal file
View File

@@ -0,0 +1,774 @@
:root {
color-scheme: light dark;
--primary: #0b57d0;
--on-primary: #ffffff;
--primary-container: #d7e3ff;
--on-primary-container: #001b3f;
--secondary: #565f71;
--tertiary: #705575;
--surface: #fbfcff;
--surface-rgb: 251 252 255;
--surface-container: #eef3fb;
--surface-container-high: #e5ebf5;
--outline: #727782;
--outline-rgb: 114 119 130;
--text: #191c20;
--muted: #42474f;
--success: #146c2e;
--warning: #7a5900;
--shadow: 0 24px 60px rgba(11, 87, 208, 0.16);
--topbar-bg: rgba(251, 252, 255, 0.68);
--topbar-border: rgba(114, 119, 130, 0.18);
--topbar-shadow: 0 12px 36px rgba(11, 87, 208, 0.08);
--grid-divider: rgba(114, 119, 130, 0.24);
--card-border: rgba(114, 119, 130, 0.24);
--device-border: rgba(114, 119, 130, 0.32);
--status-available-bg: rgba(20, 108, 46, 0.12);
--status-planned-bg: rgba(122, 89, 0, 0.12);
}
/* ---- Dark theme: forced ---- */
[data-theme="dark"] {
--primary: #a8c7ff;
--on-primary: #001b3f;
--primary-container: #003a7a;
--on-primary-container: #d7e3ff;
--secondary: #bcc7db;
--tertiary: #d7bde0;
--surface: #111318;
--surface-rgb: 17 19 24;
--surface-container: #1a1d25;
--surface-container-high: #21242d;
--outline: #8b909c;
--outline-rgb: 139 144 156;
--text: #e3e3e8;
--muted: #b0b3bd;
--success: #81c784;
--warning: #ffe08a;
--shadow: 0 24px 60px rgba(0, 0, 0, 0.4);
--topbar-bg: rgba(17, 19, 24, 0.72);
--topbar-border: rgba(139, 144, 156, 0.18);
--topbar-shadow: 0 12px 36px rgba(0, 0, 0, 0.28);
--grid-divider: rgba(139, 144, 156, 0.2);
--card-border: rgba(139, 144, 156, 0.2);
--device-border: rgba(139, 144, 156, 0.28);
--status-available-bg: rgba(129, 199, 132, 0.15);
--status-planned-bg: rgba(255, 224, 138, 0.15);
}
/* ---- Dark theme: auto (system preference, no manual override) ---- */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--primary: #a8c7ff;
--on-primary: #001b3f;
--primary-container: #003a7a;
--on-primary-container: #d7e3ff;
--secondary: #bcc7db;
--tertiary: #d7bde0;
--surface: #111318;
--surface-rgb: 17 19 24;
--surface-container: #1a1d25;
--surface-container-high: #21242d;
--outline: #8b909c;
--outline-rgb: 139 144 156;
--text: #e3e3e8;
--muted: #b0b3bd;
--success: #81c784;
--warning: #ffe08a;
--shadow: 0 24px 60px rgba(0, 0, 0, 0.4);
--topbar-bg: rgba(17, 19, 24, 0.72);
--topbar-border: rgba(139, 144, 156, 0.18);
--topbar-shadow: 0 12px 36px rgba(0, 0, 0, 0.28);
--grid-divider: rgba(139, 144, 156, 0.2);
--card-border: rgba(139, 144, 156, 0.2);
--device-border: rgba(139, 144, 156, 0.28);
--status-available-bg: rgba(129, 199, 132, 0.15);
--status-planned-bg: rgba(255, 224, 138, 0.15);
}
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
min-width: 320px;
background: var(--surface);
color: var(--text);
font-family:
Inter, Roboto, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
line-height: 1.6;
transition: background-color 300ms ease, color 300ms ease;
}
a {
color: inherit;
}
.topbar {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 14px clamp(20px, 5vw, 72px);
background: var(--topbar-bg);
border-bottom: 1px solid var(--topbar-border);
box-shadow: var(--topbar-shadow);
-webkit-backdrop-filter: blur(22px) saturate(160%);
backdrop-filter: blur(22px) saturate(160%);
}
.brand {
display: inline-flex;
align-items: center;
gap: 10px;
color: var(--text);
font-weight: 800;
text-decoration: none;
}
.brand img {
width: 36px;
height: 36px;
border-radius: 10px;
}
.nav {
display: flex;
align-items: center;
gap: 6px;
}
.nav a {
min-height: 40px;
padding: 8px 14px;
border-radius: 20px;
color: var(--muted);
font-size: 0.94rem;
font-weight: 650;
text-decoration: none;
}
.nav a:hover {
background: var(--surface-container);
color: var(--primary);
}
/* 立即尝试 CTA button in nav */
.nav-cta {
background: var(--primary) !important;
color: var(--on-primary) !important;
margin-left: 8px;
font-weight: 800 !important;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.2);
transition: transform 160ms ease, box-shadow 160ms ease, background 160ms ease;
}
.nav-cta:hover {
filter: brightness(0.88);
transform: translateY(-1px);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.26);
}
/* Hamburger menu button — hidden on desktop */
.hamburger {
display: none;
flex-direction: column;
justify-content: center;
gap: 5px;
width: 40px;
height: 40px;
padding: 8px;
border: none;
border-radius: 10px;
background: transparent;
cursor: pointer;
z-index: 20;
}
.hamburger span {
display: block;
width: 100%;
height: 2.5px;
border-radius: 2px;
background: var(--text);
transition: transform 200ms ease, opacity 200ms ease;
}
.hamburger[aria-expanded="true"] span:nth-child(1) {
transform: translateY(7.5px) rotate(45deg);
}
.hamburger[aria-expanded="true"] span:nth-child(2) {
opacity: 0;
}
.hamburger[aria-expanded="true"] span:nth-child(3) {
transform: translateY(-7.5px) rotate(-45deg);
}
/* Right-side controls group (theme + hamburger) */
.topbar-actions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
/* Theme toggle button */
.theme-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
padding: 0;
border: none;
border-radius: 50%;
background: transparent;
color: var(--text);
font-size: 1.25rem;
cursor: pointer;
transition: background 200ms ease, transform 200ms ease;
flex-shrink: 0;
}
.theme-toggle:hover {
background: var(--surface-container);
transform: scale(1.08);
}
.theme-toggle:active {
transform: scale(0.94);
}
.theme-toggle svg {
width: 22px;
height: 22px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
/* hide all icons by default, show based on data-state */
.theme-toggle .icon-sun,
.theme-toggle .icon-moon,
.theme-toggle .icon-auto {
display: none;
}
.theme-toggle[data-state="light"] .icon-sun {
display: block;
}
.theme-toggle[data-state="dark"] .icon-moon {
display: block;
}
.theme-toggle[data-state="auto"] .icon-auto {
display: block;
}
.hero {
display: grid;
grid-template-columns: minmax(0, 1.05fr) minmax(320px, 0.95fr);
gap: clamp(28px, 6vw, 84px);
align-items: center;
min-height: calc(100vh - 68px);
padding: clamp(48px, 7vw, 96px) clamp(20px, 5vw, 72px);
}
.hero-copy {
max-width: 680px;
}
.eyebrow {
margin: 0 0 12px;
color: var(--primary);
font-size: 0.85rem;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
h1,
h2,
h3,
p {
overflow-wrap: anywhere;
}
h1 {
margin: 0;
color: var(--text);
font-size: clamp(3.5rem, 11vw, 8rem);
line-height: 0.9;
letter-spacing: 0;
}
h2 {
margin: 0;
font-size: clamp(2rem, 4vw, 3.5rem);
line-height: 1.05;
letter-spacing: 0;
}
h3 {
margin: 0;
font-size: 1.2rem;
line-height: 1.2;
letter-spacing: 0;
}
.lead {
max-width: 620px;
margin: 28px 0 0;
color: var(--muted);
font-size: clamp(1.1rem, 2vw, 1.35rem);
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 34px;
}
.button,
.download-link {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 48px;
padding: 12px 22px;
border-radius: 24px;
font-weight: 800;
text-decoration: none;
transition:
transform 160ms ease,
box-shadow 160ms ease,
background 160ms ease;
}
.button:hover,
.download-link:hover {
transform: translateY(-1px);
}
.primary {
background: var(--primary);
color: var(--on-primary);
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.18);
}
.secondary {
background: var(--primary-container);
color: var(--on-primary-container);
}
.hero-panel {
display: flex;
justify-content: center;
}
.device-window {
width: min(100%, 560px);
overflow: hidden;
border: 1px solid var(--device-border);
border-radius: 28px;
background: var(--surface-container);
box-shadow: var(--shadow);
}
.window-bar {
display: flex;
gap: 8px;
padding: 16px 18px;
border-bottom: 1px solid var(--card-border);
}
.window-bar span {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--outline);
}
.window-bar span:first-child {
background: #ba1a1a;
}
.window-bar span:nth-child(2) {
background: #a46700;
}
.window-bar span:nth-child(3) {
background: #146c2e;
}
.stream-preview {
position: relative;
display: grid;
min-height: 280px;
place-items: center;
background:
linear-gradient(135deg, rgba(11, 87, 208, 0.94), rgba(112, 85, 117, 0.9)),
radial-gradient(circle at 30% 30%, #d7e3ff, transparent 32%);
}
.live-badge {
position: absolute;
top: 18px;
left: 18px;
padding: 6px 12px;
border-radius: 16px;
background: #ba1a1a;
color: #ffffff;
font-size: 0.8rem;
font-weight: 900;
}
.play-symbol {
width: 86px;
height: 86px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.88);
clip-path: polygon(28% 18%, 28% 82%, 82% 50%);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1px;
background: var(--grid-divider);
}
.stats-grid div {
min-width: 0;
padding: 18px 14px;
background: var(--surface);
}
.stats-grid strong,
.stats-grid span {
display: block;
}
.stats-grid strong {
color: var(--primary);
font-size: 1.02rem;
}
.stats-grid span {
color: var(--muted);
font-size: 0.88rem;
}
.section {
padding: clamp(64px, 8vw, 112px) clamp(20px, 5vw, 72px);
}
.section-heading {
max-width: 820px;
margin-bottom: 32px;
}
.section-heading p:not(.eyebrow) {
max-width: 760px;
color: var(--muted);
font-size: 1.06rem;
}
.architecture-grid,
.download-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 18px;
}
.architecture-card,
.download-card {
min-width: 0;
padding: 24px;
border: 1px solid var(--card-border);
border-radius: 24px;
background: var(--surface-container);
}
.architecture-card .icon {
display: grid;
width: 48px;
height: 48px;
margin-bottom: 20px;
place-items: center;
border-radius: 16px;
background: var(--primary);
color: var(--on-primary);
font-weight: 900;
}
.architecture-card p,
.download-card p,
.feature-item p,
.source-section p {
color: var(--muted);
}
.feature-band {
background: var(--surface-container);
}
.feature-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1px;
overflow: hidden;
border: 1px solid var(--card-border);
border-radius: 28px;
background: var(--grid-divider);
}
.feature-item {
min-width: 0;
padding: 28px;
background: var(--surface);
}
.download-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.download-card {
display: flex;
min-height: 240px;
flex-direction: column;
align-items: flex-start;
}
.status {
margin-bottom: 18px;
padding: 5px 10px;
border-radius: 14px;
background: var(--status-available-bg);
color: var(--success);
font-size: 0.78rem;
font-weight: 850;
}
.status.muted {
background: var(--status-planned-bg);
color: var(--warning);
}
.download-link {
margin-top: auto;
background: var(--primary);
color: var(--on-primary);
}
.planned {
background: var(--surface-container-high);
}
.source-section {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
background: #001b3f;
color: #ffffff;
}
.source-section .eyebrow,
.source-section p {
color: #d7e3ff;
}
.source-section div {
max-width: 820px;
}
.footer {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 16px;
padding: 28px clamp(20px, 5vw, 72px);
background: #001533;
color: #d7e3ff;
font-size: 0.92rem;
}
.footer span:nth-child(2) {
text-align: center;
}
.footer span:last-child {
text-align: right;
}
@media (max-width: 900px) {
.topbar {
flex-direction: row;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
}
.hamburger {
display: flex;
}
.nav {
display: none;
width: 100%;
flex-direction: column;
gap: 2px;
padding: 8px 0 4px;
border-top: 1px solid var(--topbar-border);
margin-top: 10px;
}
.nav.open {
display: flex;
}
.nav a {
width: 100%;
padding: 12px 16px;
border-radius: 14px;
font-size: 1rem;
}
.nav-cta {
margin-left: 0 !important;
margin-top: 6px;
text-align: center;
}
.hero {
grid-template-columns: 1fr;
min-height: auto;
}
.architecture-grid,
.download-grid {
grid-template-columns: 1fr 1fr;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.source-section {
align-items: flex-start;
flex-direction: column;
}
}
@media (max-width: 640px) {
.topbar {
padding: 10px 16px;
}
.hero,
.section {
padding-right: 16px;
padding-left: 16px;
}
.hero {
padding-top: 32px;
padding-bottom: 32px;
gap: 24px;
}
h1 {
font-size: clamp(2.8rem, 10vw, 4rem);
}
h2 {
font-size: clamp(1.6rem, 5vw, 2.4rem);
}
.lead {
font-size: 1rem;
margin-top: 16px;
}
.hero-panel {
width: 100%;
}
.device-window {
width: 100%;
border-radius: 20px;
}
.stream-preview {
min-height: 180px;
}
.play-symbol {
width: 60px;
height: 60px;
}
.architecture-grid,
.download-grid,
.feature-list {
grid-template-columns: 1fr;
}
.architecture-card,
.download-card {
padding: 20px;
border-radius: 18px;
}
.feature-item {
padding: 20px;
}
.actions {
flex-direction: column;
}
.button,
.download-link {
width: 100%;
justify-content: center;
}
.nav a {
min-height: 48px;
padding: 14px 16px;
font-size: 1.05rem;
}
.nav-cta {
min-height: 50px;
font-size: 1.05rem;
}
.footer {
grid-template-columns: 1fr;
gap: 8px;
padding: 24px 16px;
}
.footer-hide-mobile {
display: none;
}
}