Compare commits
8 Commits
1cce5634b1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 261b1ab169 | |||
| 1539e495e6 | |||
| c5b7451fc6 | |||
| b07f243c88 | |||
| 425ea363f8 | |||
| 6eb0baf16e | |||
| 146f05388e | |||
| 98666ab1ea |
@@ -10,7 +10,7 @@ import (
|
|||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
monitor.Init(2000)
|
monitor.Init(2000)
|
||||||
monitor.Infof("Starting Hightube Server v1.0.0-Beta3.7")
|
monitor.Infof("Starting Hightube Server v1.0.0-Beta4.7")
|
||||||
|
|
||||||
// Initialize Database and run auto-migrations
|
// Initialize Database and run auto-migrations
|
||||||
db.InitDB()
|
db.InitDB()
|
||||||
@@ -24,6 +24,7 @@ func main() {
|
|||||||
go func() {
|
go func() {
|
||||||
r := api.SetupRouter(srv)
|
r := api.SetupRouter(srv)
|
||||||
monitor.Infof("API server listening on :8080")
|
monitor.Infof("API server listening on :8080")
|
||||||
|
monitor.Infof("Web console listening on :8080/admin")
|
||||||
if err := r.Run(":8080"); err != nil {
|
if err := r.Run(":8080"); err != nil {
|
||||||
monitor.Errorf("Failed to start API server: %v", err)
|
monitor.Errorf("Failed to start API server: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,64 @@ func BindAdminDependencies(rtmpSrv *stream.RTMPServer) {
|
|||||||
adminRTMP = rtmpSrv
|
adminRTMP = rtmpSrv
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func AdminLogin(c *gin.Context) {
|
||||||
|
var req LoginRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var user model.User
|
||||||
|
if err := db.DB.Where("username = ?", strings.TrimSpace(req.Username)).First(&user).Error; err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid username or password"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !utils.CheckPasswordHash(req.Password, user.Password) {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid username or password"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !user.Enabled {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Account is disabled"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.Role != "admin" {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := utils.GenerateToken(user.ID, user.Username, user.Role)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create admin session"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.SetCookie(adminSessionCookieName, token, 86400, "/", "", false, true)
|
||||||
|
monitor.Auditf("admin=%s signed in", user.Username)
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"username": user.Username,
|
||||||
|
"role": user.Role,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func AdminLogout(c *gin.Context) {
|
||||||
|
operator, _ := c.Get("username")
|
||||||
|
c.SetCookie(adminSessionCookieName, "", -1, "/", "", false, true)
|
||||||
|
monitor.Auditf("admin=%v signed out", operator)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "signed out"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetAdminSession(c *gin.Context) {
|
||||||
|
username, _ := c.Get("username")
|
||||||
|
role, _ := c.Get("role")
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"username": username,
|
||||||
|
"role": role,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func GetAdminOverview(c *gin.Context) {
|
func GetAdminOverview(c *gin.Context) {
|
||||||
stats := monitor.GetSnapshot()
|
stats := monitor.GetSnapshot()
|
||||||
chatStats := chat.StatsSnapshot{}
|
chatStats := chat.StatsSnapshot{}
|
||||||
@@ -83,10 +141,6 @@ func ListAdminLogs(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func StreamAdminLogs(c *gin.Context) {
|
func StreamAdminLogs(c *gin.Context) {
|
||||||
if !authorizeAdminTokenFromQuery(c) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Header("Content-Type", "text/event-stream")
|
c.Header("Content-Type", "text/event-stream")
|
||||||
c.Header("Cache-Control", "no-cache")
|
c.Header("Cache-Control", "no-cache")
|
||||||
c.Header("Connection", "keep-alive")
|
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 {
|
type updateRoleRequest struct {
|
||||||
Role string `json:"role" binding:"required"`
|
Role string `json:"role" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
@@ -32,6 +34,12 @@ func Register(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
req.Username = strings.TrimSpace(req.Username)
|
||||||
|
if strings.EqualFold(req.Username, bootstrapAdminUsername()) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "This username is reserved"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Check if user exists
|
// Check if user exists
|
||||||
var existingUser model.User
|
var existingUser model.User
|
||||||
if err := db.DB.Where("username = ?", req.Username).First(&existingUser).Error; err == nil {
|
if err := db.DB.Where("username = ?", req.Username).First(&existingUser).Error; err == nil {
|
||||||
@@ -146,3 +154,11 @@ func ChangePassword(c *gin.Context) {
|
|||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Password updated successfully"})
|
c.JSON(http.StatusOK, gin.H{"message": "Password updated successfully"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func bootstrapAdminUsername() string {
|
||||||
|
adminUsername := strings.TrimSpace(os.Getenv("HIGHTUBE_ADMIN_USER"))
|
||||||
|
if adminUsername == "" {
|
||||||
|
return "admin"
|
||||||
|
}
|
||||||
|
return adminUsername
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -13,47 +14,30 @@ import (
|
|||||||
"hightube/internal/utils"
|
"hightube/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const adminSessionCookieName = "hightube_admin_session"
|
||||||
|
|
||||||
// AuthMiddleware intercepts requests, validates JWT, and injects user_id into context
|
// AuthMiddleware intercepts requests, validates JWT, and injects user_id into context
|
||||||
func AuthMiddleware() gin.HandlerFunc {
|
func AuthMiddleware() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
authHeader := c.GetHeader("Authorization")
|
user, err := authenticateRequest(c)
|
||||||
if authHeader == "" {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header is required"})
|
|
||||||
c.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
parts := strings.Split(authHeader, " ")
|
|
||||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header format must be Bearer {token}"})
|
|
||||||
c.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
tokenStr := parts[1]
|
|
||||||
claims, err := utils.ParseToken(tokenStr)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token"})
|
switch {
|
||||||
|
case errors.Is(err, errMissingToken):
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization is required"})
|
||||||
|
case errors.Is(err, errInvalidToken):
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token"})
|
||||||
|
case errors.Is(err, errUserNotFound):
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not found"})
|
||||||
|
case errors.Is(err, errDisabledAccount):
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Account is disabled"})
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication failed"})
|
||||||
|
}
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
userID, _ := strconv.ParseUint(claims.Subject, 10, 32)
|
c.Set("user_id", user.ID)
|
||||||
|
|
||||||
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("username", user.Username)
|
c.Set("username", user.Username)
|
||||||
c.Set("role", user.Role)
|
c.Set("role", user.Role)
|
||||||
c.Next()
|
c.Next()
|
||||||
@@ -82,6 +66,58 @@ func RequestMetricsMiddleware() gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
errMissingToken = errors.New("missing token")
|
||||||
|
errInvalidToken = errors.New("invalid token")
|
||||||
|
errUserNotFound = errors.New("user not found")
|
||||||
|
errDisabledAccount = errors.New("disabled account")
|
||||||
|
)
|
||||||
|
|
||||||
|
func authenticateRequest(c *gin.Context) (*model.User, error) {
|
||||||
|
tokenStr := extractToken(c)
|
||||||
|
if tokenStr == "" {
|
||||||
|
return nil, errMissingToken
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := utils.ParseToken(tokenStr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errInvalidToken
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, err := strconv.ParseUint(claims.Subject, 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errInvalidToken
|
||||||
|
}
|
||||||
|
|
||||||
|
var user model.User
|
||||||
|
if err := db.DB.First(&user, uint(userID)).Error; err != nil {
|
||||||
|
return nil, errUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if !user.Enabled {
|
||||||
|
return nil, errDisabledAccount
|
||||||
|
}
|
||||||
|
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractToken(c *gin.Context) string {
|
||||||
|
authHeader := strings.TrimSpace(c.GetHeader("Authorization"))
|
||||||
|
if authHeader != "" {
|
||||||
|
parts := strings.Split(authHeader, " ")
|
||||||
|
if len(parts) == 2 && parts[0] == "Bearer" {
|
||||||
|
return strings.TrimSpace(parts[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cookieToken, err := c.Cookie(adminSessionCookieName)
|
||||||
|
if err == nil {
|
||||||
|
return strings.TrimSpace(cookieToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// CORSMiddleware handles cross-origin requests from web clients
|
// CORSMiddleware handles cross-origin requests from web clients
|
||||||
func CORSMiddleware() gin.HandlerFunc {
|
func CORSMiddleware() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
|
|||||||
@@ -48,3 +48,18 @@ func GetActiveRooms(c *gin.Context) {
|
|||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"active_rooms": result})
|
c.JSON(http.StatusOK, gin.H{"active_rooms": result})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetRoomPlaybackOptions(c *gin.Context) {
|
||||||
|
roomID := c.Param("room_id")
|
||||||
|
qualities := []string{"source"}
|
||||||
|
if adminRTMP != nil {
|
||||||
|
if available := adminRTMP.AvailablePlaybackQualities(roomID); len(available) > 0 {
|
||||||
|
qualities = available
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"room_id": roomID,
|
||||||
|
"qualities": qualities,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,13 +23,16 @@ func SetupRouter(streamServer *stream.RTMPServer) *gin.Engine {
|
|||||||
// Public routes
|
// Public routes
|
||||||
r.POST("/api/register", Register)
|
r.POST("/api/register", Register)
|
||||||
r.POST("/api/login", Login)
|
r.POST("/api/login", Login)
|
||||||
|
r.POST("/api/admin/login", AdminLogin)
|
||||||
r.GET("/api/rooms/active", GetActiveRooms)
|
r.GET("/api/rooms/active", GetActiveRooms)
|
||||||
|
r.GET("/api/rooms/:room_id/playback-options", GetRoomPlaybackOptions)
|
||||||
|
r.GET("/api/rooms/:room_id/thumbnail", streamServer.HandleThumbnail)
|
||||||
r.GET("/live/:room_id", streamServer.HandleHTTPFLV)
|
r.GET("/live/:room_id", streamServer.HandleHTTPFLV)
|
||||||
|
|
||||||
// WebSocket endpoint for live chat
|
// WebSocket endpoint for live chat
|
||||||
r.GET("/api/ws/room/:room_id", WSHandler)
|
r.GET("/api/ws/room/:room_id", WSHandler)
|
||||||
r.GET("/admin", AdminPage)
|
r.GET("/admin", AdminPage)
|
||||||
r.GET("/api/admin/logs/stream", StreamAdminLogs)
|
r.GET("/api/admin/logs/stream", AuthMiddleware(), AdminMiddleware(), StreamAdminLogs)
|
||||||
|
|
||||||
// Protected routes (require JWT)
|
// Protected routes (require JWT)
|
||||||
authGroup := r.Group("/api")
|
authGroup := r.Group("/api")
|
||||||
@@ -41,9 +44,11 @@ func SetupRouter(streamServer *stream.RTMPServer) *gin.Engine {
|
|||||||
adminGroup := authGroup.Group("/admin")
|
adminGroup := authGroup.Group("/admin")
|
||||||
adminGroup.Use(AdminMiddleware())
|
adminGroup.Use(AdminMiddleware())
|
||||||
{
|
{
|
||||||
|
adminGroup.GET("/session", GetAdminSession)
|
||||||
adminGroup.GET("/overview", GetAdminOverview)
|
adminGroup.GET("/overview", GetAdminOverview)
|
||||||
adminGroup.GET("/health", GetAdminHealth)
|
adminGroup.GET("/health", GetAdminHealth)
|
||||||
adminGroup.GET("/logs", ListAdminLogs)
|
adminGroup.GET("/logs", ListAdminLogs)
|
||||||
|
adminGroup.POST("/logout", AdminLogout)
|
||||||
adminGroup.GET("/users", ListUsers)
|
adminGroup.GET("/users", ListUsers)
|
||||||
adminGroup.PATCH("/users/:id/role", UpdateUserRole)
|
adminGroup.PATCH("/users/:id/role", UpdateUserRole)
|
||||||
adminGroup.PATCH("/users/:id/enabled", UpdateUserEnabled)
|
adminGroup.PATCH("/users/:id/enabled", UpdateUserEnabled)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="zh-CN">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
<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">
|
<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>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--bg: #0f1c2e;
|
--bg: #eef4fb;
|
||||||
--bg2: #102944;
|
--bg-accent: #dbe9f7;
|
||||||
--card: #f7f6f3;
|
--surface: #ffffff;
|
||||||
--ink: #12263d;
|
--surface-soft: #f6f9fc;
|
||||||
--accent: #ff6b35;
|
--ink: #19324d;
|
||||||
--accent2: #0ea5a3;
|
--muted: #5a718a;
|
||||||
|
--line: #d7e1eb;
|
||||||
|
--primary: #2f6fed;
|
||||||
|
--primary-strong: #1d4ed8;
|
||||||
|
--primary-soft: #dce8ff;
|
||||||
--danger: #dc2626;
|
--danger: #dc2626;
|
||||||
--ok: #15803d;
|
--danger-soft: #fee2e2;
|
||||||
--line: #d9d5cc;
|
--success: #15803d;
|
||||||
|
--success-soft: #dcfce7;
|
||||||
|
--warning: #b45309;
|
||||||
|
--shadow: 0 18px 44px rgba(29, 78, 216, 0.10);
|
||||||
}
|
}
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
font-family: "Space Grotesk", "Segoe UI", sans-serif;
|
font-family: "Space Grotesk", "Segoe UI", sans-serif;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
background:
|
background:
|
||||||
radial-gradient(1200px 600px at -10% -10%, #2a4b70 0%, transparent 65%),
|
radial-gradient(900px 460px at -10% -20%, rgba(47,111,237,0.16) 0%, transparent 60%),
|
||||||
radial-gradient(1000px 500px at 110% 0%, #1f4f6d 0%, transparent 65%),
|
radial-gradient(800px 420px at 110% 0%, rgba(61,187,167,0.12) 0%, transparent 55%),
|
||||||
linear-gradient(140deg, var(--bg) 0%, var(--bg2) 100%);
|
linear-gradient(180deg, var(--bg) 0%, #f8fbff 100%);
|
||||||
min-height: 100vh;
|
padding: 24px;
|
||||||
padding: 20px;
|
|
||||||
}
|
}
|
||||||
.layout {
|
|
||||||
|
.shell {
|
||||||
max-width: 1280px;
|
max-width: 1280px;
|
||||||
margin: 0 auto;
|
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;
|
display: grid;
|
||||||
gap: 16px;
|
|
||||||
grid-template-columns: repeat(12, 1fr);
|
grid-template-columns: repeat(12, 1fr);
|
||||||
animation: appear 500ms ease-out;
|
gap: 16px;
|
||||||
}
|
|
||||||
@keyframes appear {
|
|
||||||
from { opacity: 0; transform: translateY(8px); }
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
background: var(--card);
|
background: rgba(255,255,255,0.88);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 16px;
|
border-radius: 20px;
|
||||||
box-shadow: 0 12px 30px rgba(0,0,0,0.22);
|
padding: 18px;
|
||||||
padding: 14px;
|
box-shadow: var(--shadow);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
grid-column: 4 / span 6;
|
||||||
|
padding: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
.full { grid-column: 1 / -1; }
|
.full { grid-column: 1 / -1; }
|
||||||
.col4 { grid-column: span 4; }
|
.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; }
|
.col8 { grid-column: span 8; }
|
||||||
h1 { margin: 0; color: #f8fafc; letter-spacing: .02em; }
|
|
||||||
h2 { margin: 0 0 10px; font-size: 18px; }
|
h2 {
|
||||||
.topbar {
|
margin: 0 0 12px;
|
||||||
grid-column: 1 / -1;
|
font-size: 19px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row.between {
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
.tokenbox {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
width: min(800px, 100%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
input, select, button {
|
input, select, button {
|
||||||
border-radius: 10px;
|
border-radius: 14px;
|
||||||
border: 1px solid #c8c2b7;
|
border: 1px solid var(--line);
|
||||||
padding: 8px 10px;
|
padding: 10px 12px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
input { width: 100%; }
|
|
||||||
|
input, select {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: #fff;
|
|
||||||
background: linear-gradient(120deg, var(--accent), #f97316);
|
|
||||||
border: none;
|
border: none;
|
||||||
|
color: white;
|
||||||
|
background: linear-gradient(120deg, var(--primary), #5994ff);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
transition: transform 140ms ease, opacity 140ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
button.secondary {
|
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 {
|
button.danger {
|
||||||
background: var(--danger);
|
background: linear-gradient(120deg, #ef4444, #dc2626);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats {
|
.stats {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric {
|
.metric {
|
||||||
border: 1px dashed #c9c2b4;
|
background: var(--surface-soft);
|
||||||
padding: 8px;
|
border: 1px solid var(--line);
|
||||||
border-radius: 12px;
|
border-radius: 16px;
|
||||||
|
padding: 12px;
|
||||||
}
|
}
|
||||||
.metric .k { font-size: 12px; opacity: .75; }
|
|
||||||
.metric .v { font-size: 22px; font-weight: 700; }
|
.metric .k {
|
||||||
.mono { font-family: "IBM Plex Mono", monospace; font-size: 12px; }
|
font-size: 12px;
|
||||||
#logs {
|
color: var(--muted);
|
||||||
height: 280px;
|
}
|
||||||
overflow: auto;
|
|
||||||
background: #0e1624;
|
.metric .v {
|
||||||
color: #d9f6ea;
|
margin-top: 8px;
|
||||||
border-radius: 10px;
|
font-size: 22px;
|
||||||
padding: 10px;
|
font-weight: 700;
|
||||||
white-space: pre-wrap;
|
}
|
||||||
|
|
||||||
|
.health-strip {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-chip {
|
||||||
|
min-width: 120px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: var(--surface-soft);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-chip .k {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
font-family: "IBM Plex Mono", monospace;
|
font-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 {
|
table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
th, td {
|
th, td {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
border-bottom: 1px solid var(--line);
|
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 {
|
.pill {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 2px 8px;
|
padding: 4px 10px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
.pill.ok { background: #dcfce7; color: var(--ok); }
|
|
||||||
.pill.off { background: #fee2e2; color: var(--danger); }
|
.pill.ok {
|
||||||
.pill.admin { background: #dbeafe; color: #1d4ed8; }
|
background: var(--success-soft);
|
||||||
.pill.user { background: #e2e8f0; color: #334155; }
|
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) {
|
@media (max-width: 960px) {
|
||||||
.col4, .col6, .col8 { grid-column: 1 / -1; }
|
.login-card, .col4, .col5, .col7, .col8 {
|
||||||
.stats { grid-template-columns: repeat(2, 1fr); }
|
grid-column: 1 / -1;
|
||||||
.topbar { flex-direction: column; align-items: stretch; }
|
}
|
||||||
|
|
||||||
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="layout">
|
<div class="shell">
|
||||||
<div class="topbar">
|
<div class="hero">
|
||||||
<h1>Hightube Admin Console</h1>
|
<div class="brand">
|
||||||
<div class="tokenbox">
|
<div class="badge">Admin Panel</div>
|
||||||
<input id="token" placeholder="粘贴 admin JWT token(来自 /api/login)" />
|
<h1>Hightube Control Console</h1>
|
||||||
<button onclick="connectAll()">连接</button>
|
<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>
|
</div>
|
||||||
|
|
||||||
<div class="card col8">
|
<div class="panel-grid hidden" id="appView">
|
||||||
<h2>系统状态</h2>
|
<div class="card full">
|
||||||
<div class="stats" id="stats"></div>
|
<div class="row between">
|
||||||
<div class="mono" id="health"></div>
|
<div>
|
||||||
</div>
|
<h2 style="margin-bottom:6px;">Session</h2>
|
||||||
|
<div class="muted">Authenticated through a server-managed admin session cookie.</div>
|
||||||
<div class="card col4">
|
</div>
|
||||||
<h2>在线状态</h2>
|
<div class="row">
|
||||||
<div id="online"></div>
|
<button class="subtle" onclick="refreshAll()">Refresh Now</button>
|
||||||
</div>
|
<button class="danger" onclick="logout()">Sign Out</button>
|
||||||
|
</div>
|
||||||
<div class="card col8">
|
</div>
|
||||||
<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>
|
</div>
|
||||||
<div id="logs"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card col4">
|
<div class="card col7">
|
||||||
<h2>操作说明</h2>
|
<h2>System Overview</h2>
|
||||||
<ul>
|
<div class="stats" id="stats"></div>
|
||||||
<li>先在上方输入 admin token 并点击连接。</li>
|
</div>
|
||||||
<li>用户管理支持搜索、角色切换、启用禁用、重置密码、删除。</li>
|
|
||||||
<li>日志区会持续接收后端实时事件。</li>
|
<div class="card col5">
|
||||||
</ul>
|
<h2>Admin Password</h2>
|
||||||
</div>
|
<div class="stack">
|
||||||
|
<input id="oldPassword" type="password" placeholder="Current password" />
|
||||||
<div class="card full">
|
<input id="newPassword" type="password" placeholder="New password" />
|
||||||
<h2>用户管理</h2>
|
<div class="row">
|
||||||
<div style="display:flex; gap:8px; margin-bottom:8px;">
|
<button onclick="changePassword()">Update Password</button>
|
||||||
<input id="userKeyword" placeholder="按用户名搜索" />
|
</div>
|
||||||
<button class="secondary" onclick="loadUsers()">查询</button>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card col4">
|
||||||
|
<h2>Live Status</h2>
|
||||||
|
<div id="online"></div>
|
||||||
|
<div class="health-strip" id="health" style="margin-top:14px;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card col8">
|
||||||
|
<div class="row between" style="margin-bottom:10px;">
|
||||||
|
<h2 style="margin:0;">Audit and Runtime Logs</h2>
|
||||||
|
<div class="row">
|
||||||
|
<select id="logLevel">
|
||||||
|
<option value="">All levels</option>
|
||||||
|
<option value="info">info</option>
|
||||||
|
<option value="warn">warn</option>
|
||||||
|
<option value="error">error</option>
|
||||||
|
<option value="audit">audit</option>
|
||||||
|
</select>
|
||||||
|
<input id="logKeyword" placeholder="Filter keyword" style="width:220px;" />
|
||||||
|
<button class="secondary" onclick="loadHistory()">Load History</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="logs"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card full">
|
||||||
|
<div class="row between" style="margin-bottom:10px;">
|
||||||
|
<h2 style="margin:0;">User Management</h2>
|
||||||
|
<div class="row">
|
||||||
|
<input id="userKeyword" placeholder="Search by username" style="width:220px;" />
|
||||||
|
<button class="secondary" onclick="loadUsers()">Search</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Username</th>
|
||||||
|
<th>Role</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="usersBody"></tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
let evt = null;
|
let evt = null;
|
||||||
|
let overviewTimer = null;
|
||||||
|
let healthTimer = null;
|
||||||
|
let currentAdmin = null;
|
||||||
|
|
||||||
function authHeaders() {
|
function setSessionText(text) {
|
||||||
const token = document.getElementById('token').value.trim();
|
document.getElementById('sessionInfo').textContent = text;
|
||||||
return {
|
|
||||||
'Authorization': 'Bearer ' + token,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function addLogLine(text) {
|
function addLogLine(text) {
|
||||||
@@ -228,62 +442,144 @@
|
|||||||
box.scrollTop = box.scrollHeight;
|
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()]);
|
await Promise.all([loadOverview(), loadHealth(), loadUsers(), loadHistory()]);
|
||||||
connectLiveLogs();
|
|
||||||
setInterval(loadOverview, 1000);
|
|
||||||
setInterval(loadHealth, 1000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadOverview() {
|
async function loadOverview() {
|
||||||
const resp = await fetch('/api/admin/overview', { headers: authHeaders() });
|
const data = await api('/api/admin/overview');
|
||||||
if (!resp.ok) {
|
|
||||||
addLogLine('[error] 概览拉取失败,请检查 token 是否为 admin。');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const data = await resp.json();
|
|
||||||
const sys = data.system || {};
|
const sys = data.system || {};
|
||||||
const stream = data.stream || {};
|
const stream = data.stream || {};
|
||||||
const chat = data.chat || {};
|
const chat = data.chat || {};
|
||||||
|
|
||||||
document.getElementById('stats').innerHTML = `
|
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">Uptime (s)</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">Requests</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">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">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">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">System Mem (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">CPU Cores</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">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 = `
|
document.getElementById('online').innerHTML = `
|
||||||
<p>活跃流数量:<b>${stream.active_stream_count ?? 0}</b></p>
|
<p>Active streams: <b>${stream.active_stream_count ?? 0}</b></p>
|
||||||
<p>活跃聊天室:<b>${chat.room_count ?? 0}</b></p>
|
<p>Active chat rooms: <b>${chat.room_count ?? 0}</b></p>
|
||||||
<p>在线聊天连接:<b>${chat.total_connected_client ?? 0}</b></p>
|
<p>Connected chat clients: <b>${chat.total_connected_client ?? 0}</b></p>
|
||||||
<div class="mono">流路径: ${(stream.active_stream_paths || []).join(', ') || '无'}</div>
|
<div class="mono">Stream paths: ${(stream.active_stream_paths || []).join(', ') || 'none'}</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadHealth() {
|
async function loadHealth() {
|
||||||
const resp = await fetch('/api/admin/health', { headers: authHeaders() });
|
const h = await api('/api/admin/health');
|
||||||
if (!resp.ok) return;
|
|
||||||
const h = await resp.json();
|
|
||||||
const dbOk = h.db && h.db.ok;
|
const dbOk = h.db && h.db.ok;
|
||||||
document.getElementById('health').innerHTML =
|
document.getElementById('health').innerHTML =
|
||||||
`API: <span class="pill ${h.api ? 'ok' : 'off'}">${h.api ? '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>` +
|
||||||
`RTMP: <span class="pill ${h.rtmp ? 'ok' : 'off'}">${h.rtmp ? 'UP' : 'DOWN'}</span> ` +
|
`<div class="health-chip"><div class="k">RTMP</div><span class="pill ${h.rtmp ? 'ok' : 'off'}">${h.rtmp ? 'UP' : 'DOWN'}</span></div>` +
|
||||||
`DB: <span class="pill ${dbOk ? 'ok' : 'off'}">${dbOk ? 'UP' : 'DOWN'}</span>`;
|
`<div class="health-chip"><div class="k">Database</div><span class="pill ${dbOk ? 'ok' : 'off'}">${dbOk ? 'UP' : 'DOWN'}</span></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadHistory() {
|
async function loadHistory() {
|
||||||
const level = encodeURIComponent(document.getElementById('logLevel').value || '');
|
const level = encodeURIComponent(document.getElementById('logLevel').value || '');
|
||||||
const keyword = encodeURIComponent(document.getElementById('logKeyword').value || '');
|
const keyword = encodeURIComponent(document.getElementById('logKeyword').value || '');
|
||||||
const resp = await fetch(`/api/admin/logs?page=1&page_size=100&level=${level}&keyword=${keyword}`, {
|
const data = await api(`/api/admin/logs?page=1&page_size=100&level=${level}&keyword=${keyword}`);
|
||||||
headers: authHeaders()
|
|
||||||
});
|
|
||||||
if (!resp.ok) return;
|
|
||||||
const data = await resp.json();
|
|
||||||
const items = data.items || [];
|
const items = data.items || [];
|
||||||
const box = document.getElementById('logs');
|
const box = document.getElementById('logs');
|
||||||
box.textContent = '';
|
box.textContent = '';
|
||||||
@@ -291,32 +587,24 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function connectLiveLogs() {
|
function connectLiveLogs() {
|
||||||
if (evt) evt.close();
|
disconnectLogs();
|
||||||
const token = document.getElementById('token').value.trim();
|
evt = new EventSource('/api/admin/logs/stream', { withCredentials: true });
|
||||||
evt = new EventSource('/api/admin/logs/stream?token=' + encodeURIComponent(token));
|
|
||||||
evt.onmessage = () => {};
|
evt.onmessage = () => {};
|
||||||
evt.addEventListener('log', (e) => {
|
evt.addEventListener('log', (e) => {
|
||||||
const item = JSON.parse(e.data);
|
const item = JSON.parse(e.data);
|
||||||
addLogLine(`[${item.time}] [${item.level}] ${item.message}`);
|
addLogLine(`[${item.time}] [${item.level}] ${item.message}`);
|
||||||
});
|
});
|
||||||
evt.onerror = () => {
|
evt.onerror = () => {
|
||||||
addLogLine('[warn] 实时日志连接断开,稍后可重连。');
|
addLogLine('[warn] Live log stream disconnected.');
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadUsers() {
|
async function loadUsers() {
|
||||||
const keyword = encodeURIComponent(document.getElementById('userKeyword').value || '');
|
const keyword = encodeURIComponent(document.getElementById('userKeyword').value || '');
|
||||||
const resp = await fetch(`/api/admin/users?page=1&page_size=50&keyword=${keyword}`, {
|
const data = await api(`/api/admin/users?page=1&page_size=50&keyword=${keyword}`);
|
||||||
headers: authHeaders()
|
|
||||||
});
|
|
||||||
if (!resp.ok) {
|
|
||||||
addLogLine('[error] 用户列表拉取失败。');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const data = await resp.json();
|
|
||||||
const body = document.getElementById('usersBody');
|
const body = document.getElementById('usersBody');
|
||||||
body.innerHTML = '';
|
body.innerHTML = '';
|
||||||
(data.items || []).forEach(u => {
|
(data.items || []).forEach((u) => {
|
||||||
const tr = document.createElement('tr');
|
const tr = document.createElement('tr');
|
||||||
tr.innerHTML = `
|
tr.innerHTML = `
|
||||||
<td>${u.id}</td>
|
<td>${u.id}</td>
|
||||||
@@ -325,10 +613,10 @@
|
|||||||
<td><span class="pill ${u.enabled ? 'ok' : 'off'}">${u.enabled ? 'enabled' : 'disabled'}</span></td>
|
<td><span class="pill ${u.enabled ? 'ok' : 'off'}">${u.enabled ? 'enabled' : 'disabled'}</span></td>
|
||||||
<td>${new Date(u.created_at).toLocaleString()}</td>
|
<td>${new Date(u.created_at).toLocaleString()}</td>
|
||||||
<td class="actions">
|
<td class="actions">
|
||||||
<button class="secondary" onclick="toggleRole(${u.id}, '${u.role}')">切换角色</button>
|
<button class="subtle" onclick="toggleRole(${u.id}, '${u.role}')">Toggle Role</button>
|
||||||
<button class="secondary" onclick="toggleEnabled(${u.id}, ${u.enabled})">启用/禁用</button>
|
<button class="subtle" onclick="toggleEnabled(${u.id}, ${u.enabled})">Enable / Disable</button>
|
||||||
<button class="secondary" onclick="resetPwd(${u.id})">重置密码</button>
|
<button class="secondary" onclick="resetPwd(${u.id})">Reset Password</button>
|
||||||
<button class="danger" onclick="deleteUser(${u.id})">删除</button>
|
<button class="danger" onclick="deleteUser(${u.id})">Delete</button>
|
||||||
</td>
|
</td>
|
||||||
`;
|
`;
|
||||||
body.appendChild(tr);
|
body.appendChild(tr);
|
||||||
@@ -337,42 +625,53 @@
|
|||||||
|
|
||||||
async function toggleRole(id, role) {
|
async function toggleRole(id, role) {
|
||||||
const next = role === 'admin' ? 'user' : 'admin';
|
const next = role === 'admin' ? 'user' : 'admin';
|
||||||
await fetch(`/api/admin/users/${id}/role`, {
|
await api(`/api/admin/users/${id}/role`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: authHeaders(),
|
body: JSON.stringify({ role: next }),
|
||||||
body: JSON.stringify({ role: next })
|
|
||||||
});
|
});
|
||||||
loadUsers();
|
await loadUsers();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleEnabled(id, enabled) {
|
async function toggleEnabled(id, enabled) {
|
||||||
await fetch(`/api/admin/users/${id}/enabled`, {
|
await api(`/api/admin/users/${id}/enabled`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: authHeaders(),
|
body: JSON.stringify({ enabled: !enabled }),
|
||||||
body: JSON.stringify({ enabled: !enabled })
|
|
||||||
});
|
});
|
||||||
loadUsers();
|
await loadUsers();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resetPwd(id) {
|
async function resetPwd(id) {
|
||||||
const newPwd = prompt('输入新密码(至少 6 位)');
|
const newPwd = prompt('Enter a new password for this user');
|
||||||
if (!newPwd) return;
|
if (!newPwd) return;
|
||||||
await fetch(`/api/admin/users/${id}/reset-password`, {
|
await api(`/api/admin/users/${id}/reset-password`, {
|
||||||
method: 'POST',
|
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) {
|
async function deleteUser(id) {
|
||||||
if (!confirm('确认删除该用户?')) return;
|
if (!confirm('Delete this user account?')) return;
|
||||||
await fetch(`/api/admin/users/${id}`, {
|
await api(`/api/admin/users/${id}`, { method: 'DELETE' });
|
||||||
method: 'DELETE',
|
await loadUsers();
|
||||||
headers: authHeaders()
|
|
||||||
});
|
|
||||||
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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ func ensureAdminUser() {
|
|||||||
|
|
||||||
adminPassword := os.Getenv("HIGHTUBE_ADMIN_PASS")
|
adminPassword := os.Getenv("HIGHTUBE_ADMIN_PASS")
|
||||||
if adminPassword == "" {
|
if adminPassword == "" {
|
||||||
adminPassword = "admin123456"
|
adminPassword = "admin"
|
||||||
}
|
}
|
||||||
|
|
||||||
var user model.User
|
var user model.User
|
||||||
@@ -106,5 +106,5 @@ func ensureAdminUser() {
|
|||||||
monitor.Warnf("Failed to create default admin room: %v", roomErr)
|
monitor.Warnf("Failed to create default admin room: %v", roomErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
monitor.Warnf("Default admin created: username=%s password=%s", adminUsername, adminPassword)
|
monitor.Warnf("Default admin created for username=%s; change the password after first login", adminUsername)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
package stream
|
package stream
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/nareix/joy4/av/avutil"
|
"github.com/nareix/joy4/av/avutil"
|
||||||
@@ -27,9 +34,42 @@ func init() {
|
|||||||
|
|
||||||
// RTMPServer manages all active live streams
|
// RTMPServer manages all active live streams
|
||||||
type RTMPServer struct {
|
type RTMPServer struct {
|
||||||
server *rtmp.Server
|
server *rtmp.Server
|
||||||
channels map[string]*pubsub.Queue
|
channels map[string]*pubsub.Queue
|
||||||
mutex sync.RWMutex
|
transcoders map[string][]*variantTranscoder
|
||||||
|
thumbnailJobs map[string]context.CancelFunc
|
||||||
|
internalPublishKey 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 {
|
type writeFlusher struct {
|
||||||
@@ -45,32 +85,31 @@ func (w writeFlusher) Flush() error {
|
|||||||
// NewRTMPServer creates and initializes a new media server
|
// NewRTMPServer creates and initializes a new media server
|
||||||
func NewRTMPServer() *RTMPServer {
|
func NewRTMPServer() *RTMPServer {
|
||||||
s := &RTMPServer{
|
s := &RTMPServer{
|
||||||
channels: make(map[string]*pubsub.Queue),
|
channels: make(map[string]*pubsub.Queue),
|
||||||
server: &rtmp.Server{},
|
transcoders: make(map[string][]*variantTranscoder),
|
||||||
|
thumbnailJobs: make(map[string]context.CancelFunc),
|
||||||
|
internalPublishKey: generateInternalPublishKey(),
|
||||||
|
thumbnailDir: filepath.Join(os.TempDir(), "hightube-thumbnails"),
|
||||||
|
server: &rtmp.Server{},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Triggered when a broadcaster (e.g., OBS) starts publishing
|
// Triggered when a broadcaster (e.g., OBS) starts publishing
|
||||||
s.server.HandlePublish = func(conn *rtmp.Conn) {
|
s.server.HandlePublish = func(conn *rtmp.Conn) {
|
||||||
streamPath := conn.URL.Path // Expected format: /live/{stream_key}
|
streamPath := conn.URL.Path // Expected format: /live/{stream_key} or /variant/{room_id}/{quality}/{token}
|
||||||
monitor.Infof("OBS publish attempt: %s", streamPath)
|
monitor.Infof("OBS publish attempt: %s", streamPath)
|
||||||
|
|
||||||
// Extract stream key from path
|
|
||||||
parts := strings.Split(streamPath, "/")
|
parts := strings.Split(streamPath, "/")
|
||||||
if len(parts) < 3 || parts[1] != "live" {
|
if len(parts) < 3 {
|
||||||
monitor.Warnf("Invalid publish path format: %s", streamPath)
|
monitor.Warnf("Invalid publish path format: %s", streamPath)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
streamKey := parts[2]
|
|
||||||
|
|
||||||
// Authenticate stream key
|
roomID, channelPath, isSource, ok := s.resolvePublishPath(parts)
|
||||||
var room model.Room
|
if !ok {
|
||||||
if err := db.DB.Where("stream_key = ?", streamKey).First(&room).Error; err != nil {
|
monitor.Warnf("Invalid publish key/path: %s", streamPath)
|
||||||
monitor.Warnf("Invalid stream key: %s", streamKey)
|
return
|
||||||
return // Reject connection
|
|
||||||
}
|
}
|
||||||
|
|
||||||
monitor.Infof("Stream authenticated for room_id=%d", room.ID)
|
|
||||||
|
|
||||||
// 1. Get audio/video stream metadata
|
// 1. Get audio/video stream metadata
|
||||||
streams, err := conn.Streams()
|
streams, err := conn.Streams()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -78,31 +117,42 @@ func NewRTMPServer() *RTMPServer {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Map the active stream by Room ID so viewers can use /live/{room_id}
|
monitor.Infof("Stream authenticated for room_id=%s path=%s", roomID, channelPath)
|
||||||
roomLivePath := fmt.Sprintf("/live/%d", room.ID)
|
|
||||||
|
|
||||||
s.mutex.Lock()
|
s.mutex.Lock()
|
||||||
q := pubsub.NewQueue()
|
q := pubsub.NewQueue()
|
||||||
q.WriteHeader(streams)
|
q.WriteHeader(streams)
|
||||||
s.channels[roomLivePath] = q
|
s.channels[channelPath] = q
|
||||||
s.mutex.Unlock()
|
s.mutex.Unlock()
|
||||||
|
|
||||||
// Mark room as active in DB (using map to ensure true/false is correctly updated)
|
if isSource {
|
||||||
db.DB.Model(&room).Updates(map[string]interface{}{"is_active": true})
|
roomIDUint := parseRoomID(roomID)
|
||||||
|
if roomIDUint != 0 {
|
||||||
|
db.DB.Model(&model.Room{}).Where("id = ?", roomIDUint).Updates(map[string]interface{}{"is_active": true})
|
||||||
|
}
|
||||||
|
s.startVariantTranscoders(roomID)
|
||||||
|
s.startThumbnailCapture(roomID)
|
||||||
|
}
|
||||||
|
|
||||||
// 3. Cleanup on end
|
// 3. Cleanup on end
|
||||||
defer func() {
|
defer func() {
|
||||||
s.mutex.Lock()
|
s.mutex.Lock()
|
||||||
delete(s.channels, roomLivePath)
|
delete(s.channels, channelPath)
|
||||||
s.mutex.Unlock()
|
s.mutex.Unlock()
|
||||||
q.Close()
|
q.Close()
|
||||||
// Explicitly set is_active to false using map
|
|
||||||
db.DB.Model(&room).Updates(map[string]interface{}{"is_active": false})
|
if isSource {
|
||||||
|
s.stopVariantTranscoders(roomID)
|
||||||
// Clear chat history for this room
|
s.stopThumbnailCapture(roomID)
|
||||||
chat.MainHub.ClearRoomHistory(fmt.Sprintf("%d", room.ID))
|
roomIDUint := parseRoomID(roomID)
|
||||||
|
if roomIDUint != 0 {
|
||||||
monitor.Infof("Publishing ended for room_id=%d", room.ID)
|
db.DB.Model(&model.Room{}).Where("id = ?", roomIDUint).Updates(map[string]interface{}{"is_active": false})
|
||||||
|
}
|
||||||
|
chat.MainHub.ClearRoomHistory(roomID)
|
||||||
|
monitor.Infof("Publishing ended for room_id=%s", roomID)
|
||||||
|
} else {
|
||||||
|
monitor.Infof("Variant publishing ended for room_id=%s path=%s", roomID, channelPath)
|
||||||
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// 4. Continuously copy data packets to our broadcast queue
|
// 4. Continuously copy data packets to our broadcast queue
|
||||||
@@ -158,6 +208,9 @@ func (s *RTMPServer) Start(addr string) error {
|
|||||||
// HandleHTTPFLV serves browser-compatible HTTP-FLV playback for web clients.
|
// HandleHTTPFLV serves browser-compatible HTTP-FLV playback for web clients.
|
||||||
func (s *RTMPServer) HandleHTTPFLV(c *gin.Context) {
|
func (s *RTMPServer) HandleHTTPFLV(c *gin.Context) {
|
||||||
streamPath := fmt.Sprintf("/live/%s", c.Param("room_id"))
|
streamPath := fmt.Sprintf("/live/%s", c.Param("room_id"))
|
||||||
|
if quality := normalizeQuality(c.Query("quality")); quality != "" {
|
||||||
|
streamPath = fmt.Sprintf("%s/%s", streamPath, quality)
|
||||||
|
}
|
||||||
|
|
||||||
s.mutex.RLock()
|
s.mutex.RLock()
|
||||||
q, ok := s.channels[streamPath]
|
q, ok := s.channels[streamPath]
|
||||||
@@ -198,10 +251,249 @@ 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 {
|
||||||
|
var room model.Room
|
||||||
|
if err := db.DB.Where("stream_key = ?", parts[2]).First(&room).Error; err != nil {
|
||||||
|
return "", "", false, false
|
||||||
|
}
|
||||||
|
roomID = fmt.Sprintf("%d", room.ID)
|
||||||
|
channelPath = fmt.Sprintf("/live/%s", roomID)
|
||||||
|
return roomID, channelPath, true, true
|
||||||
|
}
|
||||||
|
|
||||||
|
if parts[1] == "variant" && len(parts) == 5 {
|
||||||
|
roomID = parts[2]
|
||||||
|
quality := normalizeQuality(parts[3])
|
||||||
|
token := parts[4]
|
||||||
|
if quality == "" || token != s.internalPublishKey {
|
||||||
|
return "", "", false, false
|
||||||
|
}
|
||||||
|
return roomID, fmt.Sprintf("/live/%s/%s", roomID, quality), false, true
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", "", false, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *RTMPServer) startVariantTranscoders(roomID string) {
|
||||||
|
s.stopVariantTranscoders(roomID)
|
||||||
|
|
||||||
|
launch := make([]*variantTranscoder, 0, len(supportedQualities))
|
||||||
|
for quality, profile := range supportedQualities {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
inputURL := fmt.Sprintf("rtmp://127.0.0.1:1935/live/%s", roomID)
|
||||||
|
outputURL := fmt.Sprintf("rtmp://127.0.0.1:1935/variant/%s/%s/%s", roomID, quality, s.internalPublishKey)
|
||||||
|
cmd := exec.CommandContext(
|
||||||
|
ctx,
|
||||||
|
"ffmpeg",
|
||||||
|
"-nostdin",
|
||||||
|
"-loglevel", "error",
|
||||||
|
"-i", inputURL,
|
||||||
|
"-vf", "scale="+profile.scale+":force_original_aspect_ratio=decrease",
|
||||||
|
"-c:v", "libx264",
|
||||||
|
"-preset", "veryfast",
|
||||||
|
"-tune", "zerolatency",
|
||||||
|
"-g", "48",
|
||||||
|
"-keyint_min", "48",
|
||||||
|
"-sc_threshold", "0",
|
||||||
|
"-b:v", profile.videoBitrate,
|
||||||
|
"-maxrate", profile.videoBitrate,
|
||||||
|
"-bufsize", profile.videoBitrate,
|
||||||
|
"-c:a", "aac",
|
||||||
|
"-b:a", profile.audioBitrate,
|
||||||
|
"-ar", "44100",
|
||||||
|
"-ac", "2",
|
||||||
|
"-f", "flv",
|
||||||
|
outputURL,
|
||||||
|
)
|
||||||
|
|
||||||
|
transcoder := &variantTranscoder{
|
||||||
|
quality: quality,
|
||||||
|
cancel: cancel,
|
||||||
|
cmd: cmd,
|
||||||
|
}
|
||||||
|
launch = append(launch, transcoder)
|
||||||
|
|
||||||
|
go func(roomID string, tr *variantTranscoder) {
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
monitor.Infof("Starting transcoder room_id=%s quality=%s", roomID, tr.quality)
|
||||||
|
if err := tr.cmd.Start(); err != nil {
|
||||||
|
monitor.Errorf("Failed to start transcoder room_id=%s quality=%s: %v", roomID, tr.quality, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := tr.cmd.Wait(); err != nil && ctx.Err() == nil {
|
||||||
|
monitor.Warnf("Transcoder exited room_id=%s quality=%s: %v", roomID, tr.quality, err)
|
||||||
|
}
|
||||||
|
}(roomID, transcoder)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mutex.Lock()
|
||||||
|
s.transcoders[roomID] = launch
|
||||||
|
s.mutex.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *RTMPServer) stopVariantTranscoders(roomID string) {
|
||||||
|
s.mutex.Lock()
|
||||||
|
transcoders := s.transcoders[roomID]
|
||||||
|
delete(s.transcoders, roomID)
|
||||||
|
s.mutex.Unlock()
|
||||||
|
|
||||||
|
for _, transcoder := range transcoders {
|
||||||
|
transcoder.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (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:1935/live/%s", 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 {
|
func (s *RTMPServer) ActiveStreamCount() int {
|
||||||
s.mutex.RLock()
|
s.mutex.RLock()
|
||||||
defer s.mutex.RUnlock()
|
defer s.mutex.RUnlock()
|
||||||
return len(s.channels)
|
|
||||||
|
count := 0
|
||||||
|
for path := range s.channels {
|
||||||
|
if strings.Count(path, "/") == 2 {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *RTMPServer) AvailablePlaybackQualities(roomID string) []string {
|
||||||
|
s.mutex.RLock()
|
||||||
|
defer s.mutex.RUnlock()
|
||||||
|
|
||||||
|
basePath := fmt.Sprintf("/live/%s", roomID)
|
||||||
|
available := make([]string, 0, len(qualityOrder))
|
||||||
|
for _, quality := range qualityOrder {
|
||||||
|
streamPath := basePath
|
||||||
|
if quality != "source" {
|
||||||
|
streamPath = fmt.Sprintf("%s/%s", basePath, quality)
|
||||||
|
}
|
||||||
|
if _, ok := s.channels[streamPath]; ok {
|
||||||
|
available = append(available, quality)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return available
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *RTMPServer) ActiveStreamPaths() []string {
|
func (s *RTMPServer) ActiveStreamPaths() []string {
|
||||||
@@ -210,7 +502,9 @@ func (s *RTMPServer) ActiveStreamPaths() []string {
|
|||||||
|
|
||||||
paths := make([]string, 0, len(s.channels))
|
paths := make([]string, 0, len(s.channels))
|
||||||
for path := range s.channels {
|
for path := range s.channels {
|
||||||
paths = append(paths, path)
|
if strings.Count(path, "/") == 2 {
|
||||||
|
paths = append(paths, path)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return paths
|
return paths
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,12 +11,12 @@ android {
|
|||||||
ndkVersion = flutter.ndkVersion
|
ndkVersion = flutter.ndkVersion
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_1_8
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
jvmTarget = JavaVersion.VERSION_1_8.toString()
|
jvmTarget = JavaVersion.VERSION_17.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
<application
|
<application
|
||||||
android:label="Hightube"
|
android:label="Hightube"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
|
|||||||
3
frontend/l10n.yaml
Normal file
3
frontend/l10n.yaml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
arb-dir: lib/l10n
|
||||||
|
template-arb-file: app_en.arb
|
||||||
|
output-localization-file: app_localizations.dart
|
||||||
70
frontend/lib/l10n/app_en.arb
Normal file
70
frontend/lib/l10n/app_en.arb
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
{
|
||||||
|
"@@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...",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
70
frontend/lib/l10n/app_ja.arb
Normal file
70
frontend/lib/l10n/app_ja.arb
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
{
|
||||||
|
"@@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": "メッセージを送信...",
|
||||||
|
"liveChat": "ライブチャット",
|
||||||
|
"refresh": "更新",
|
||||||
|
"volume": "音量",
|
||||||
|
"danmakuOn": "弾幕オン",
|
||||||
|
"danmakuOff": "弾幕オフ",
|
||||||
|
"fullscreen": "全画面",
|
||||||
|
"exitFullscreen": "全画面終了",
|
||||||
|
"resolution": "解像度",
|
||||||
|
"playbackOptionsDesc": "利用可能なトランスコード済みストリームを選択します。",
|
||||||
|
"sourceOnlyDesc": "現在、ソースストリームのみが利用可能です。",
|
||||||
|
"myStreamConsole": "配信コンソール",
|
||||||
|
"noRoomInfo": "ルーム情報が見つかりません。",
|
||||||
|
"roomTitle": "ルームタイトル",
|
||||||
|
"rtmpServerUrl": "RTMP サーバー URL",
|
||||||
|
"streamKey": "ストリームキー (秘密にしてください!)",
|
||||||
|
"copiedToClipboard": "クリップボードにコピーしました",
|
||||||
|
"failedToFetchRoomInfo": "ルーム情報の取得に失敗しました"
|
||||||
|
}
|
||||||
553
frontend/lib/l10n/app_localizations.dart
Normal file
553
frontend/lib/l10n/app_localizations.dart
Normal file
@@ -0,0 +1,553 @@
|
|||||||
|
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, you’ll need to edit this
|
||||||
|
/// file.
|
||||||
|
///
|
||||||
|
/// First, open your project’s ios/Runner.xcworkspace Xcode workspace file.
|
||||||
|
/// Then, in the Project Navigator, open the Info.plist file under the Runner
|
||||||
|
/// project’s 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 @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.',
|
||||||
|
);
|
||||||
|
}
|
||||||
212
frontend/lib/l10n/app_localizations_en.dart
Normal file
212
frontend/lib/l10n/app_localizations_en.dart
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
// 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 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';
|
||||||
|
}
|
||||||
212
frontend/lib/l10n/app_localizations_ja.dart
Normal file
212
frontend/lib/l10n/app_localizations_ja.dart
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
// 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 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 => 'ルーム情報の取得に失敗しました';
|
||||||
|
}
|
||||||
417
frontend/lib/l10n/app_localizations_zh.dart
Normal file
417
frontend/lib/l10n/app_localizations_zh.dart
Normal file
@@ -0,0 +1,417 @@
|
|||||||
|
// 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 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 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 => '獲取房間資訊失敗';
|
||||||
|
}
|
||||||
70
frontend/lib/l10n/app_zh.arb
Normal file
70
frontend/lib/l10n/app_zh.arb
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
{
|
||||||
|
"@@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": "发送消息...",
|
||||||
|
"liveChat": "实时聊天",
|
||||||
|
"refresh": "刷新",
|
||||||
|
"volume": "音量",
|
||||||
|
"danmakuOn": "弹幕开启",
|
||||||
|
"danmakuOff": "弹幕关闭",
|
||||||
|
"fullscreen": "全屏",
|
||||||
|
"exitFullscreen": "退出全屏",
|
||||||
|
"resolution": "分辨率",
|
||||||
|
"playbackOptionsDesc": "选择可用的转码流。",
|
||||||
|
"sourceOnlyDesc": "目前仅源流可用。",
|
||||||
|
"myStreamConsole": "我的直播控制台",
|
||||||
|
"noRoomInfo": "未找到房间信息。",
|
||||||
|
"roomTitle": "房间标题",
|
||||||
|
"rtmpServerUrl": "RTMP 服务器地址",
|
||||||
|
"streamKey": "推流码 (请务必保密!)",
|
||||||
|
"copiedToClipboard": "已复制到剪贴板",
|
||||||
|
"failedToFetchRoomInfo": "获取房间信息失败"
|
||||||
|
}
|
||||||
70
frontend/lib/l10n/app_zh_Hant.arb
Normal file
70
frontend/lib/l10n/app_zh_Hant.arb
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
{
|
||||||
|
"@@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": "發送訊息...",
|
||||||
|
"liveChat": "即時聊天",
|
||||||
|
"refresh": "重新整理",
|
||||||
|
"volume": "音量",
|
||||||
|
"danmakuOn": "彈幕開啟",
|
||||||
|
"danmakuOff": "彈幕關閉",
|
||||||
|
"fullscreen": "全屏",
|
||||||
|
"exitFullscreen": "退出全屏",
|
||||||
|
"resolution": "解析度",
|
||||||
|
"playbackOptionsDesc": "選擇可用的轉碼流。",
|
||||||
|
"sourceOnlyDesc": "目前僅源流可用。",
|
||||||
|
"myStreamConsole": "我的直播控制台",
|
||||||
|
"noRoomInfo": "未找到房間資訊。",
|
||||||
|
"roomTitle": "房間標題",
|
||||||
|
"rtmpServerUrl": "RTMP 伺服器地址",
|
||||||
|
"streamKey": "推流碼 (請務必保密!)",
|
||||||
|
"copiedToClipboard": "已複製到剪貼板",
|
||||||
|
"failedToFetchRoomInfo": "獲取房間資訊失敗"
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:fvp/fvp.dart' as fvp;
|
import 'package:fvp/fvp.dart' as fvp;
|
||||||
import 'providers/auth_provider.dart';
|
import 'providers/auth_provider.dart';
|
||||||
import 'providers/settings_provider.dart';
|
import 'providers/settings_provider.dart';
|
||||||
import 'pages/home_page.dart';
|
import 'pages/home_page.dart';
|
||||||
import 'pages/login_page.dart';
|
import 'pages/login_page.dart';
|
||||||
|
import 'l10n/app_localizations.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
fvp.registerWith();
|
fvp.registerWith();
|
||||||
@@ -30,6 +32,14 @@ class HightubeApp extends StatelessWidget {
|
|||||||
|
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
title: 'Hightube',
|
title: 'Hightube',
|
||||||
|
localizationsDelegates: const [
|
||||||
|
AppLocalizations.delegate,
|
||||||
|
GlobalMaterialLocalizations.delegate,
|
||||||
|
GlobalWidgetsLocalizations.delegate,
|
||||||
|
GlobalCupertinoLocalizations.delegate,
|
||||||
|
],
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
locale: settings.locale,
|
||||||
theme: ThemeData(
|
theme: ThemeData(
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
colorScheme: ColorScheme.fromSeed(
|
colorScheme: ColorScheme.fromSeed(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import '../l10n/app_localizations.dart';
|
||||||
import '../providers/auth_provider.dart';
|
import '../providers/auth_provider.dart';
|
||||||
import '../providers/settings_provider.dart';
|
import '../providers/settings_provider.dart';
|
||||||
import '../services/api_service.dart';
|
import '../services/api_service.dart';
|
||||||
@@ -10,8 +11,10 @@ import 'player_page.dart';
|
|||||||
import 'my_stream_page.dart';
|
import 'my_stream_page.dart';
|
||||||
|
|
||||||
class HomePage extends StatefulWidget {
|
class HomePage extends StatefulWidget {
|
||||||
|
const HomePage({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_HomePageState createState() => _HomePageState();
|
State<HomePage> createState() => _HomePageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _HomePageState extends State<HomePage> {
|
class _HomePageState extends State<HomePage> {
|
||||||
@@ -20,8 +23,9 @@ class _HomePageState extends State<HomePage> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
bool isWide = MediaQuery.of(context).size.width > 600;
|
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)),
|
_ExploreView(onGoLive: () => setState(() => _selectedIndex = 1)),
|
||||||
MyStreamPage(),
|
MyStreamPage(),
|
||||||
SettingsPage(),
|
SettingsPage(),
|
||||||
@@ -36,22 +40,22 @@ class _HomePageState extends State<HomePage> {
|
|||||||
onDestinationSelected: (int index) =>
|
onDestinationSelected: (int index) =>
|
||||||
setState(() => _selectedIndex = index),
|
setState(() => _selectedIndex = index),
|
||||||
labelType: NavigationRailLabelType.all,
|
labelType: NavigationRailLabelType.all,
|
||||||
destinations: const [
|
destinations: [
|
||||||
NavigationRailDestination(
|
NavigationRailDestination(
|
||||||
icon: Icon(Icons.explore),
|
icon: const Icon(Icons.explore),
|
||||||
label: Text('Explore'),
|
label: Text(l10n.explore),
|
||||||
),
|
),
|
||||||
NavigationRailDestination(
|
NavigationRailDestination(
|
||||||
icon: Icon(Icons.videocam),
|
icon: const Icon(Icons.videocam),
|
||||||
label: Text('Console'),
|
label: Text(l10n.console),
|
||||||
),
|
),
|
||||||
NavigationRailDestination(
|
NavigationRailDestination(
|
||||||
icon: Icon(Icons.settings),
|
icon: const Icon(Icons.settings),
|
||||||
label: Text('Settings'),
|
label: Text(l10n.settings),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Expanded(child: _pages[_selectedIndex]),
|
Expanded(child: pages[_selectedIndex]),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
bottomNavigationBar: !isWide
|
bottomNavigationBar: !isWide
|
||||||
@@ -59,18 +63,18 @@ class _HomePageState extends State<HomePage> {
|
|||||||
selectedIndex: _selectedIndex,
|
selectedIndex: _selectedIndex,
|
||||||
onDestinationSelected: (int index) =>
|
onDestinationSelected: (int index) =>
|
||||||
setState(() => _selectedIndex = index),
|
setState(() => _selectedIndex = index),
|
||||||
destinations: const [
|
destinations: [
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
icon: Icon(Icons.explore),
|
icon: const Icon(Icons.explore),
|
||||||
label: 'Explore',
|
label: l10n.explore,
|
||||||
),
|
),
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
icon: Icon(Icons.videocam),
|
icon: const Icon(Icons.videocam),
|
||||||
label: 'Console',
|
label: l10n.console,
|
||||||
),
|
),
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
icon: Icon(Icons.settings),
|
icon: const Icon(Icons.settings),
|
||||||
label: 'Settings',
|
label: l10n.settings,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -91,6 +95,8 @@ class _ExploreViewState extends State<_ExploreView> {
|
|||||||
List<dynamic> _activeRooms = [];
|
List<dynamic> _activeRooms = [];
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
Timer? _refreshTimer;
|
Timer? _refreshTimer;
|
||||||
|
String _thumbnailCacheBuster = DateTime.now().millisecondsSinceEpoch
|
||||||
|
.toString();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -117,62 +123,95 @@ class _ExploreViewState extends State<_ExploreView> {
|
|||||||
final response = await api.getActiveRooms();
|
final response = await api.getActiveRooms();
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
final data = jsonDecode(response.body);
|
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) {
|
} catch (e) {
|
||||||
if (!isAuto && mounted)
|
if (!isAuto && mounted) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(
|
||||||
context,
|
context,
|
||||||
).showSnackBar(SnackBar(content: Text("Failed to load rooms")));
|
).showSnackBar(SnackBar(content: Text(l10n?.failedToLoadRooms ?? "Failed to load rooms")));
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (!isAuto && mounted) setState(() => _isLoading = false);
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final settings = context.watch<SettingsProvider>();
|
final settings = context.watch<SettingsProvider>();
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text("Explore", style: TextStyle(fontWeight: FontWeight.bold)),
|
title: Text(l10n.explore, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(Icons.refresh),
|
icon: const Icon(Icons.refresh),
|
||||||
onPressed: () => _refreshRooms(),
|
onPressed: () => _refreshRooms(),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(icon: const Icon(Icons.logout), onPressed: _confirmLogout),
|
||||||
icon: Icon(Icons.logout),
|
|
||||||
onPressed: () => context.read<AuthProvider>().logout(),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
floatingActionButton: FloatingActionButton.extended(
|
floatingActionButton: FloatingActionButton.extended(
|
||||||
onPressed: widget.onGoLive,
|
onPressed: widget.onGoLive,
|
||||||
label: Text("Go Live"),
|
label: Text(l10n.goLive),
|
||||||
icon: Icon(Icons.videocam),
|
icon: const Icon(Icons.videocam),
|
||||||
),
|
),
|
||||||
body: RefreshIndicator(
|
body: RefreshIndicator(
|
||||||
onRefresh: _refreshRooms,
|
onRefresh: _refreshRooms,
|
||||||
child: _isLoading && _activeRooms.isEmpty
|
child: _isLoading && _activeRooms.isEmpty
|
||||||
? Center(child: CircularProgressIndicator())
|
? const Center(child: CircularProgressIndicator())
|
||||||
: _activeRooms.isEmpty
|
: _activeRooms.isEmpty
|
||||||
? ListView(
|
? ListView(
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: EdgeInsets.only(top: 100),
|
padding: const EdgeInsets.only(top: 100),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
const Icon(
|
||||||
Icons.live_tv_outlined,
|
Icons.live_tv_outlined,
|
||||||
size: 80,
|
size: 80,
|
||||||
color: Colors.grey,
|
color: Colors.grey,
|
||||||
),
|
),
|
||||||
SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
"No active rooms. Be the first!",
|
l10n.noActiveRooms,
|
||||||
style: TextStyle(color: Colors.grey, fontSize: 16),
|
style: const TextStyle(color: Colors.grey, fontSize: 16),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -180,8 +219,8 @@ class _ExploreViewState extends State<_ExploreView> {
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
: GridView.builder(
|
: GridView.builder(
|
||||||
padding: EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
|
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||||
maxCrossAxisExtent: 400,
|
maxCrossAxisExtent: 400,
|
||||||
childAspectRatio: 1.2,
|
childAspectRatio: 1.2,
|
||||||
crossAxisSpacing: 12,
|
crossAxisSpacing: 12,
|
||||||
@@ -190,28 +229,29 @@ class _ExploreViewState extends State<_ExploreView> {
|
|||||||
itemCount: _activeRooms.length,
|
itemCount: _activeRooms.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final room = _activeRooms[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(
|
return Card(
|
||||||
elevation: 4,
|
elevation: 4,
|
||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
final playbackUrl = settings.playbackUrl(room['room_id'].toString());
|
final playbackUrl = settings.playbackUrl(roomId);
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) => PlayerPage(
|
builder: (_) => PlayerPage(
|
||||||
title: room['title'],
|
title: room['title'],
|
||||||
playbackUrl: playbackUrl,
|
playbackUrl: playbackUrl,
|
||||||
roomId: room['room_id'].toString(),
|
roomId: roomId,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -224,26 +264,47 @@ class _ExploreViewState extends State<_ExploreView> {
|
|||||||
child: Stack(
|
child: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
settings.livePreviewThumbnailsEnabled
|
||||||
color: Theme.of(context).colorScheme.primaryContainer,
|
? Image.network(
|
||||||
child: Center(
|
settings.thumbnailUrl(
|
||||||
child: Icon(
|
roomId,
|
||||||
Icons.live_tv,
|
cacheBuster: _thumbnailCacheBuster,
|
||||||
size: 50,
|
),
|
||||||
color: Theme.of(context).colorScheme.primary,
|
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(
|
Positioned(
|
||||||
top: 8,
|
top: 8,
|
||||||
left: 8,
|
left: 8,
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.red,
|
color: Colors.red,
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: const Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.circle, size: 8, color: Colors.white),
|
Icon(Icons.circle, size: 8, color: Colors.white),
|
||||||
SizedBox(width: 4),
|
SizedBox(width: 4),
|
||||||
@@ -275,7 +336,7 @@ class _ExploreViewState extends State<_ExploreView> {
|
|||||||
radius: 16,
|
radius: 16,
|
||||||
child: Text(room['user_id'].toString().substring(0, 1)),
|
child: Text(room['user_id'].toString().substring(0, 1)),
|
||||||
),
|
),
|
||||||
SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
@@ -285,14 +346,14 @@ class _ExploreViewState extends State<_ExploreView> {
|
|||||||
room['title'],
|
room['title'],
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
"Host ID: ${room['user_id']}",
|
"${l10n.hostId}: ${room['user_id']}",
|
||||||
style: TextStyle(fontSize: 12, color: Colors.grey),
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import '../l10n/app_localizations.dart';
|
||||||
import '../providers/auth_provider.dart';
|
import '../providers/auth_provider.dart';
|
||||||
import '../providers/settings_provider.dart';
|
import '../providers/settings_provider.dart';
|
||||||
import '../services/api_service.dart';
|
import '../services/api_service.dart';
|
||||||
@@ -17,13 +18,23 @@ class LoginPage extends StatefulWidget {
|
|||||||
class _LoginPageState extends State<LoginPage> {
|
class _LoginPageState extends State<LoginPage> {
|
||||||
final _usernameController = TextEditingController();
|
final _usernameController = TextEditingController();
|
||||||
final _passwordController = TextEditingController();
|
final _passwordController = TextEditingController();
|
||||||
|
final _passwordFocusNode = FocusNode();
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_usernameController.dispose();
|
||||||
|
_passwordController.dispose();
|
||||||
|
_passwordFocusNode.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
void _handleLogin() async {
|
void _handleLogin() async {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
if (_usernameController.text.isEmpty || _passwordController.text.isEmpty) {
|
if (_usernameController.text.isEmpty || _passwordController.text.isEmpty) {
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(
|
||||||
context,
|
context,
|
||||||
).showSnackBar(SnackBar(content: Text("Please fill in all fields")));
|
).showSnackBar(SnackBar(content: Text(l10n.fillAllFields)));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,7 +55,7 @@ class _LoginPageState extends State<LoginPage> {
|
|||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final error = jsonDecode(response.body)['error'] ?? "Login Failed";
|
final error = jsonDecode(response.body)['error'] ?? l10n.loginFailed;
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(
|
||||||
context,
|
context,
|
||||||
).showSnackBar(SnackBar(content: Text(error)));
|
).showSnackBar(SnackBar(content: Text(error)));
|
||||||
@@ -54,7 +65,7 @@ class _LoginPageState extends State<LoginPage> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text("Network Error: Could not connect to server")),
|
SnackBar(content: Text(l10n.networkError)),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _isLoading = false);
|
if (mounted) setState(() => _isLoading = false);
|
||||||
@@ -63,11 +74,13 @@ class _LoginPageState extends State<LoginPage> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(Icons.settings),
|
icon: const Icon(Icons.settings),
|
||||||
onPressed: () => Navigator.push(
|
onPressed: () => Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(builder: (_) => const SettingsPage()),
|
MaterialPageRoute(builder: (_) => const SettingsPage()),
|
||||||
@@ -79,7 +92,7 @@ class _LoginPageState extends State<LoginPage> {
|
|||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 32.0),
|
padding: const EdgeInsets.symmetric(horizontal: 32.0),
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: BoxConstraints(maxWidth: 400),
|
constraints: const BoxConstraints(maxWidth: 400),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
@@ -89,7 +102,7 @@ class _LoginPageState extends State<LoginPage> {
|
|||||||
size: 80,
|
size: 80,
|
||||||
color: Theme.of(context).colorScheme.primary,
|
color: Theme.of(context).colorScheme.primary,
|
||||||
),
|
),
|
||||||
SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
"HIGHTUBE",
|
"HIGHTUBE",
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
@@ -99,36 +112,45 @@ class _LoginPageState extends State<LoginPage> {
|
|||||||
color: Theme.of(context).colorScheme.primary,
|
color: Theme.of(context).colorScheme.primary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
const Text(
|
||||||
"Open Source Live Platform",
|
"Open Source Live Platform",
|
||||||
style: TextStyle(color: Colors.grey),
|
style: TextStyle(color: Colors.grey),
|
||||||
),
|
),
|
||||||
SizedBox(height: 48),
|
const SizedBox(height: 48),
|
||||||
|
|
||||||
// Fields
|
// Fields
|
||||||
TextField(
|
TextField(
|
||||||
controller: _usernameController,
|
controller: _usernameController,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
onSubmitted: (_) => _passwordFocusNode.requestFocus(),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: "Username",
|
labelText: l10n.username,
|
||||||
prefixIcon: Icon(Icons.person),
|
prefixIcon: const Icon(Icons.person),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextField(
|
TextField(
|
||||||
controller: _passwordController,
|
controller: _passwordController,
|
||||||
|
focusNode: _passwordFocusNode,
|
||||||
obscureText: true,
|
obscureText: true,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
onSubmitted: (_) {
|
||||||
|
if (!_isLoading) {
|
||||||
|
_handleLogin();
|
||||||
|
}
|
||||||
|
},
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: "Password",
|
labelText: l10n.password,
|
||||||
prefixIcon: Icon(Icons.lock),
|
prefixIcon: const Icon(Icons.lock),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
// Login Button
|
// Login Button
|
||||||
SizedBox(
|
SizedBox(
|
||||||
@@ -142,14 +164,14 @@ class _LoginPageState extends State<LoginPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: _isLoading
|
child: _isLoading
|
||||||
? CircularProgressIndicator()
|
? const CircularProgressIndicator()
|
||||||
: Text(
|
: Text(
|
||||||
"LOGIN",
|
l10n.login,
|
||||||
style: TextStyle(fontWeight: FontWeight.bold),
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Register Link
|
// Register Link
|
||||||
TextButton(
|
TextButton(
|
||||||
@@ -157,7 +179,7 @@ class _LoginPageState extends State<LoginPage> {
|
|||||||
context,
|
context,
|
||||||
MaterialPageRoute(builder: (_) => RegisterPage()),
|
MaterialPageRoute(builder: (_) => RegisterPage()),
|
||||||
),
|
),
|
||||||
child: Text("Don't have an account? Create one"),
|
child: Text(l10n.dontHaveAccount),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -2,13 +2,17 @@ import 'dart:convert';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import '../l10n/app_localizations.dart';
|
||||||
import '../providers/auth_provider.dart';
|
import '../providers/auth_provider.dart';
|
||||||
import '../providers/settings_provider.dart';
|
import '../providers/settings_provider.dart';
|
||||||
import '../services/api_service.dart';
|
import '../services/api_service.dart';
|
||||||
|
import '../widgets/android_quick_stream_panel.dart';
|
||||||
|
|
||||||
class MyStreamPage extends StatefulWidget {
|
class MyStreamPage extends StatefulWidget {
|
||||||
|
const MyStreamPage({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_MyStreamPageState createState() => _MyStreamPageState();
|
State<MyStreamPage> createState() => _MyStreamPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MyStreamPageState extends State<MyStreamPage> {
|
class _MyStreamPageState extends State<MyStreamPage> {
|
||||||
@@ -29,78 +33,107 @@ class _MyStreamPageState extends State<MyStreamPage> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
final response = await api.getMyRoom();
|
final response = await api.getMyRoom();
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
setState(() => _roomInfo = jsonDecode(response.body));
|
setState(() => _roomInfo = jsonDecode(response.body));
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} 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 {
|
} finally {
|
||||||
setState(() => _isLoading = false);
|
if (mounted) {
|
||||||
|
setState(() => _isLoading = false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final settings = context.watch<SettingsProvider>();
|
final settings = context.watch<SettingsProvider>();
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: Text("My Stream Console")),
|
appBar: AppBar(title: Text(l10n.myStreamConsole)),
|
||||||
body: _isLoading
|
body: _isLoading
|
||||||
? Center(child: CircularProgressIndicator())
|
? const Center(child: CircularProgressIndicator())
|
||||||
: _roomInfo == null
|
: _roomInfo == null
|
||||||
? Center(child: Text("No room info found."))
|
? Center(child: Text(l10n.noRoomInfo))
|
||||||
: SingleChildScrollView(
|
: SingleChildScrollView(
|
||||||
padding: EdgeInsets.all(20),
|
padding: const EdgeInsets.all(20),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_buildInfoCard(
|
_buildInfoCard(
|
||||||
title: "Room Title",
|
title: l10n.roomTitle,
|
||||||
value: _roomInfo!['title'],
|
value: _roomInfo!['title'],
|
||||||
icon: Icons.edit,
|
icon: Icons.edit,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
// TODO: Implement title update API later
|
// TODO: Implement title update API later
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Title editing coming soon!")));
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
},
|
const 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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
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(
|
return Card(
|
||||||
child: ListTile(
|
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(
|
subtitle: Text(
|
||||||
isSecret ? "••••••••••••••••" : value,
|
isSecret ? "••••••••••••••••" : value,
|
||||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||||
),
|
),
|
||||||
trailing: IconButton(icon: Icon(icon), onPressed: onTap),
|
trailing: IconButton(icon: Icon(icon), onPressed: onTap),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -6,8 +7,10 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:video_player/video_player.dart';
|
import 'package:video_player/video_player.dart';
|
||||||
|
|
||||||
|
import '../l10n/app_localizations.dart';
|
||||||
import '../providers/auth_provider.dart';
|
import '../providers/auth_provider.dart';
|
||||||
import '../providers/settings_provider.dart';
|
import '../providers/settings_provider.dart';
|
||||||
|
import '../services/api_service.dart';
|
||||||
import '../services/chat_service.dart';
|
import '../services/chat_service.dart';
|
||||||
import '../widgets/web_stream_player.dart';
|
import '../widgets/web_stream_player.dart';
|
||||||
|
|
||||||
@@ -24,7 +27,7 @@ class PlayerPage extends StatefulWidget {
|
|||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_PlayerPageState createState() => _PlayerPageState();
|
State<PlayerPage> createState() => _PlayerPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PlayerPageState extends State<PlayerPage> {
|
class _PlayerPageState extends State<PlayerPage> {
|
||||||
@@ -40,13 +43,16 @@ class _PlayerPageState extends State<PlayerPage> {
|
|||||||
bool _isRefreshing = false;
|
bool _isRefreshing = false;
|
||||||
bool _isFullscreen = false;
|
bool _isFullscreen = false;
|
||||||
bool _controlsVisible = true;
|
bool _controlsVisible = true;
|
||||||
|
double _volume = kIsWeb ? 0.0 : 1.0;
|
||||||
int _playerVersion = 0;
|
int _playerVersion = 0;
|
||||||
String _selectedResolution = 'Source';
|
String _selectedResolution = 'Source';
|
||||||
|
List<String> _availableResolutions = const ['Source'];
|
||||||
Timer? _controlsHideTimer;
|
Timer? _controlsHideTimer;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_loadPlaybackOptions();
|
||||||
if (!kIsWeb) {
|
if (!kIsWeb) {
|
||||||
_initializePlayer();
|
_initializePlayer();
|
||||||
}
|
}
|
||||||
@@ -55,11 +61,11 @@ class _PlayerPageState extends State<PlayerPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _initializePlayer() async {
|
Future<void> _initializePlayer() async {
|
||||||
_controller = VideoPlayerController.networkUrl(
|
final playbackUrl = _currentPlaybackUrl();
|
||||||
Uri.parse(widget.playbackUrl),
|
_controller = VideoPlayerController.networkUrl(Uri.parse(playbackUrl));
|
||||||
);
|
|
||||||
try {
|
try {
|
||||||
await _controller!.initialize();
|
await _controller!.initialize();
|
||||||
|
await _controller!.setVolume(_volume);
|
||||||
_controller!.play();
|
_controller!.play();
|
||||||
if (mounted) setState(() {});
|
if (mounted) setState(() {});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -81,6 +87,55 @@ class _PlayerPageState extends State<PlayerPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _currentPlaybackUrl() {
|
||||||
|
final settings = context.read<SettingsProvider>();
|
||||||
|
final quality = _selectedResolution == 'Source'
|
||||||
|
? null
|
||||||
|
: _selectedResolution.toLowerCase();
|
||||||
|
return settings.playbackUrl(widget.roomId, quality: quality);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadPlaybackOptions() async {
|
||||||
|
final settings = context.read<SettingsProvider>();
|
||||||
|
final auth = context.read<AuthProvider>();
|
||||||
|
final api = ApiService(settings, auth.token);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final response = await api.getPlaybackOptions(widget.roomId);
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
final rawQualities =
|
||||||
|
(data['qualities'] as List<dynamic>? ?? const ['source'])
|
||||||
|
.map((item) => item.toString().trim().toLowerCase())
|
||||||
|
.where((item) => item.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
final normalized = <String>['Source'];
|
||||||
|
for (final quality in rawQualities) {
|
||||||
|
if (quality == 'source') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
normalized.add(quality);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_availableResolutions = normalized.toSet().toList();
|
||||||
|
if (!_availableResolutions.contains(_selectedResolution)) {
|
||||||
|
_selectedResolution = 'Source';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
// Keep source-only playback when the capability probe fails.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _initializeChat() {
|
void _initializeChat() {
|
||||||
final settings = context.read<SettingsProvider>();
|
final settings = context.read<SettingsProvider>();
|
||||||
final auth = context.read<AuthProvider>();
|
final auth = context.read<AuthProvider>();
|
||||||
@@ -141,6 +196,8 @@ class _PlayerPageState extends State<PlayerPage> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await _loadPlaybackOptions();
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_isRefreshing = true;
|
_isRefreshing = true;
|
||||||
_isError = false;
|
_isError = false;
|
||||||
@@ -200,6 +257,77 @@ class _PlayerPageState extends State<PlayerPage> {
|
|||||||
_showControls();
|
_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() {
|
void _showControls() {
|
||||||
_controlsHideTimer?.cancel();
|
_controlsHideTimer?.cancel();
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -223,22 +351,31 @@ class _PlayerPageState extends State<PlayerPage> {
|
|||||||
|
|
||||||
Future<void> _selectResolution() async {
|
Future<void> _selectResolution() async {
|
||||||
_showControls();
|
_showControls();
|
||||||
|
await _loadPlaybackOptions();
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
|
||||||
final nextResolution = await showModalBottomSheet<String>(
|
final nextResolution = await showModalBottomSheet<String>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
const options = ['Source', '720p', '480p'];
|
const options = ['Source', '720p', '480p'];
|
||||||
|
final available = _availableResolutions.toSet();
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const ListTile(
|
ListTile(
|
||||||
title: Text('Playback Resolution'),
|
title: Text(l10n.playbackResolution),
|
||||||
subtitle: Text(
|
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) {
|
...options.map((option) {
|
||||||
final enabled = option == 'Source';
|
final enabled = available.contains(option);
|
||||||
return ListTile(
|
return ListTile(
|
||||||
enabled: enabled,
|
enabled: enabled,
|
||||||
leading: Icon(
|
leading: Icon(
|
||||||
@@ -248,8 +385,8 @@ class _PlayerPageState extends State<PlayerPage> {
|
|||||||
),
|
),
|
||||||
title: Text(option),
|
title: Text(option),
|
||||||
subtitle: enabled
|
subtitle: enabled
|
||||||
? const Text('Available now')
|
? Text(l10n.availableNow)
|
||||||
: const Text('Requires backend transcoding support'),
|
: Text(l10n.waitingForTranscoding),
|
||||||
onTap: enabled ? () => Navigator.pop(context, option) : null,
|
onTap: enabled ? () => Navigator.pop(context, option) : null,
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
@@ -376,7 +513,8 @@ class _PlayerPageState extends State<PlayerPage> {
|
|||||||
: kIsWeb
|
: kIsWeb
|
||||||
? WebStreamPlayer(
|
? WebStreamPlayer(
|
||||||
key: ValueKey('web-player-$_playerVersion'),
|
key: ValueKey('web-player-$_playerVersion'),
|
||||||
streamUrl: widget.playbackUrl,
|
streamUrl: _currentPlaybackUrl(),
|
||||||
|
volume: _volume,
|
||||||
)
|
)
|
||||||
: _controller != null && _controller!.value.isInitialized
|
: _controller != null && _controller!.value.isInitialized
|
||||||
? AspectRatio(
|
? AspectRatio(
|
||||||
@@ -455,6 +593,7 @@ class _PlayerPageState extends State<PlayerPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildPlaybackControls() {
|
Widget _buildPlaybackControls() {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
return IgnorePointer(
|
return IgnorePointer(
|
||||||
ignoring: !_controlsVisible,
|
ignoring: !_controlsVisible,
|
||||||
child: AnimatedOpacity(
|
child: AnimatedOpacity(
|
||||||
@@ -485,19 +624,28 @@ class _PlayerPageState extends State<PlayerPage> {
|
|||||||
children: [
|
children: [
|
||||||
_buildControlButton(
|
_buildControlButton(
|
||||||
icon: Icons.refresh,
|
icon: Icons.refresh,
|
||||||
label: "Refresh",
|
label: l10n.refresh,
|
||||||
onPressed: _refreshPlayer,
|
onPressed: _refreshPlayer,
|
||||||
),
|
),
|
||||||
|
_buildControlButton(
|
||||||
|
icon: _volume == 0
|
||||||
|
? Icons.volume_off
|
||||||
|
: _volume < 0.5
|
||||||
|
? Icons.volume_down
|
||||||
|
: Icons.volume_up,
|
||||||
|
label: l10n.volume,
|
||||||
|
onPressed: _openVolumeSheet,
|
||||||
|
),
|
||||||
_buildControlButton(
|
_buildControlButton(
|
||||||
icon: _showDanmaku ? Icons.subtitles : Icons.subtitles_off,
|
icon: _showDanmaku ? Icons.subtitles : Icons.subtitles_off,
|
||||||
label: _showDanmaku ? "Danmaku On" : "Danmaku Off",
|
label: _showDanmaku ? l10n.danmakuOn : l10n.danmakuOff,
|
||||||
onPressed: _toggleDanmaku,
|
onPressed: _toggleDanmaku,
|
||||||
),
|
),
|
||||||
_buildControlButton(
|
_buildControlButton(
|
||||||
icon: _isFullscreen
|
icon: _isFullscreen
|
||||||
? Icons.fullscreen_exit
|
? Icons.fullscreen_exit
|
||||||
: Icons.fullscreen,
|
: Icons.fullscreen,
|
||||||
label: _isFullscreen ? "Exit Fullscreen" : "Fullscreen",
|
label: _isFullscreen ? l10n.exitFullscreen : l10n.fullscreen,
|
||||||
onPressed: _toggleFullscreen,
|
onPressed: _toggleFullscreen,
|
||||||
),
|
),
|
||||||
_buildControlButton(
|
_buildControlButton(
|
||||||
@@ -535,23 +683,24 @@ class _PlayerPageState extends State<PlayerPage> {
|
|||||||
|
|
||||||
// 抽离聊天区域组件
|
// 抽离聊天区域组件
|
||||||
Widget _buildChatSection() {
|
Widget _buildChatSection() {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.chat_bubble_outline, size: 16),
|
const Icon(Icons.chat_bubble_outline, size: 16),
|
||||||
SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text("Live Chat", style: TextStyle(fontWeight: FontWeight.bold)),
|
Text(l10n.liveChat, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
reverse: true,
|
reverse: true,
|
||||||
padding: EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
itemCount: _messages.length,
|
itemCount: _messages.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final m = _messages[index];
|
final m = _messages[index];
|
||||||
@@ -559,7 +708,7 @@ class _PlayerPageState extends State<PlayerPage> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Divider(height: 1),
|
const Divider(height: 1),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(8.0),
|
padding: const EdgeInsets.all(8.0),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -568,11 +717,11 @@ class _PlayerPageState extends State<PlayerPage> {
|
|||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _msgController,
|
controller: _msgController,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: "Send a message...",
|
hintText: l10n.sendMessage,
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
),
|
),
|
||||||
contentPadding: EdgeInsets.symmetric(
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
horizontal: 16,
|
horizontal: 16,
|
||||||
vertical: 8,
|
vertical: 8,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import '../l10n/app_localizations.dart';
|
||||||
import '../providers/settings_provider.dart';
|
import '../providers/settings_provider.dart';
|
||||||
import '../services/api_service.dart';
|
import '../services/api_service.dart';
|
||||||
|
|
||||||
@@ -15,8 +16,9 @@ class _RegisterPageState extends State<RegisterPage> {
|
|||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
|
|
||||||
void _handleRegister() async {
|
void _handleRegister() async {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
if (_usernameController.text.isEmpty || _passwordController.text.isEmpty) {
|
if (_usernameController.text.isEmpty || _passwordController.text.isEmpty) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Please fill in all fields")));
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(l10n.fillAllFields)));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,14 +29,14 @@ class _RegisterPageState extends State<RegisterPage> {
|
|||||||
try {
|
try {
|
||||||
final response = await api.register(_usernameController.text, _passwordController.text);
|
final response = await api.register(_usernameController.text, _passwordController.text);
|
||||||
if (response.statusCode == 201) {
|
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);
|
Navigator.pop(context);
|
||||||
} else {
|
} else {
|
||||||
final error = jsonDecode(response.body)['error'] ?? "Registration Failed";
|
final error = jsonDecode(response.body)['error'] ?? "Registration Failed";
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(error)));
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(error)));
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Network Error")));
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(l10n.networkError)));
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _isLoading = false);
|
if (mounted) setState(() => _isLoading = false);
|
||||||
}
|
}
|
||||||
@@ -42,42 +44,43 @@ class _RegisterPageState extends State<RegisterPage> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: Text("Create Account")),
|
appBar: AppBar(title: Text(l10n.createAccount)),
|
||||||
body: Center(
|
body: Center(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 32.0),
|
padding: const EdgeInsets.symmetric(horizontal: 32.0),
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: BoxConstraints(maxWidth: 400),
|
constraints: const BoxConstraints(maxWidth: 400),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.person_add_outlined, size: 64, color: Theme.of(context).colorScheme.primary),
|
Icon(Icons.person_add_outlined, size: 64, color: Theme.of(context).colorScheme.primary),
|
||||||
SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
Text(
|
Text(
|
||||||
"Join Hightube",
|
l10n.joinHightube,
|
||||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.bold),
|
style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
SizedBox(height: 48),
|
const SizedBox(height: 48),
|
||||||
TextField(
|
TextField(
|
||||||
controller: _usernameController,
|
controller: _usernameController,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: "Desired Username",
|
labelText: l10n.desiredUsername,
|
||||||
prefixIcon: Icon(Icons.person),
|
prefixIcon: const Icon(Icons.person),
|
||||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextField(
|
TextField(
|
||||||
controller: _passwordController,
|
controller: _passwordController,
|
||||||
obscureText: true,
|
obscureText: true,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: "Password",
|
labelText: l10n.password,
|
||||||
prefixIcon: Icon(Icons.lock),
|
prefixIcon: const Icon(Icons.lock),
|
||||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: 50,
|
height: 50,
|
||||||
@@ -86,13 +89,13 @@ class _RegisterPageState extends State<RegisterPage> {
|
|||||||
style: ElevatedButton.styleFrom(
|
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(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
child: Text("Already have an account? Login here"),
|
child: Text(l10n.alreadyHaveAccount),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'dart:convert';
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import '../l10n/app_localizations.dart';
|
||||||
|
|
||||||
import '../providers/auth_provider.dart';
|
import '../providers/auth_provider.dart';
|
||||||
import '../providers/settings_provider.dart';
|
import '../providers/settings_provider.dart';
|
||||||
@@ -11,7 +12,7 @@ class SettingsPage extends StatefulWidget {
|
|||||||
const SettingsPage({super.key});
|
const SettingsPage({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_SettingsPageState createState() => _SettingsPageState();
|
State<SettingsPage> createState() => _SettingsPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SettingsPageState extends State<SettingsPage> {
|
class _SettingsPageState extends State<SettingsPage> {
|
||||||
@@ -88,15 +89,42 @@ 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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final auth = context.watch<AuthProvider>();
|
final auth = context.watch<AuthProvider>();
|
||||||
final settings = context.watch<SettingsProvider>();
|
final settings = context.watch<SettingsProvider>();
|
||||||
final isAuthenticated = auth.isAuthenticated;
|
final isAuthenticated = auth.isAuthenticated;
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text("Settings", style: TextStyle(fontWeight: FontWeight.bold)),
|
title: Text(l10n.settings, style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
),
|
),
|
||||||
body: SingleChildScrollView(
|
body: SingleChildScrollView(
|
||||||
@@ -108,12 +136,58 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
_buildProfileSection(auth),
|
_buildProfileSection(auth),
|
||||||
const SizedBox(height: 32),
|
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),
|
const SizedBox(height: 16),
|
||||||
TextField(
|
TextField(
|
||||||
controller: _urlController,
|
controller: _urlController,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: "Backend Server URL",
|
labelText: l10n.backendServerUrl,
|
||||||
hintText: "http://127.0.0.1:8080",
|
hintText: "http://127.0.0.1:8080",
|
||||||
prefixIcon: Icon(Icons.lan),
|
prefixIcon: Icon(Icons.lan),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
@@ -131,13 +205,13 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
);
|
);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text("Server URL Updated"),
|
content: Text(l10n.serverUrlUpdated),
|
||||||
behavior: SnackBarBehavior.floating,
|
behavior: SnackBarBehavior.floating,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
icon: Icon(Icons.save),
|
icon: Icon(Icons.save),
|
||||||
label: Text("Save Network Settings"),
|
label: Text(l10n.saveNetworkSettings),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: Theme.of(
|
backgroundColor: Theme.of(
|
||||||
context,
|
context,
|
||||||
@@ -153,28 +227,28 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
_buildSectionTitle("Theme Customization"),
|
_buildSectionTitle(l10n.themeCustomization),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
"Appearance Mode",
|
l10n.appearanceMode,
|
||||||
style: Theme.of(context).textTheme.labelLarge,
|
style: Theme.of(context).textTheme.labelLarge,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
SegmentedButton<ThemeMode>(
|
SegmentedButton<ThemeMode>(
|
||||||
segments: const [
|
segments: [
|
||||||
ButtonSegment<ThemeMode>(
|
ButtonSegment<ThemeMode>(
|
||||||
value: ThemeMode.system,
|
value: ThemeMode.system,
|
||||||
label: Text("System"),
|
label: Text(l10n.system),
|
||||||
icon: Icon(Icons.brightness_auto),
|
icon: Icon(Icons.brightness_auto),
|
||||||
),
|
),
|
||||||
ButtonSegment<ThemeMode>(
|
ButtonSegment<ThemeMode>(
|
||||||
value: ThemeMode.light,
|
value: ThemeMode.light,
|
||||||
label: Text("Light"),
|
label: Text(l10n.light),
|
||||||
icon: Icon(Icons.light_mode),
|
icon: Icon(Icons.light_mode),
|
||||||
),
|
),
|
||||||
ButtonSegment<ThemeMode>(
|
ButtonSegment<ThemeMode>(
|
||||||
value: ThemeMode.dark,
|
value: ThemeMode.dark,
|
||||||
label: Text("Dark"),
|
label: Text(l10n.dark),
|
||||||
icon: Icon(Icons.dark_mode),
|
icon: Icon(Icons.dark_mode),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -184,7 +258,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
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),
|
const SizedBox(height: 12),
|
||||||
Wrap(
|
Wrap(
|
||||||
spacing: 12,
|
spacing: 12,
|
||||||
@@ -221,15 +295,27 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
);
|
);
|
||||||
}).toList(),
|
}).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) ...[
|
if (isAuthenticated) ...[
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
_buildSectionTitle("Security"),
|
_buildSectionTitle(l10n.security),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextField(
|
TextField(
|
||||||
controller: _oldPasswordController,
|
controller: _oldPasswordController,
|
||||||
obscureText: true,
|
obscureText: true,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: "Old Password",
|
labelText: l10n.oldPassword,
|
||||||
prefixIcon: const Icon(Icons.lock_outline),
|
prefixIcon: const Icon(Icons.lock_outline),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
@@ -241,7 +327,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
controller: _newPasswordController,
|
controller: _newPasswordController,
|
||||||
obscureText: true,
|
obscureText: true,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: "New Password",
|
labelText: l10n.newPassword,
|
||||||
prefixIcon: const Icon(Icons.lock_reset),
|
prefixIcon: const Icon(Icons.lock_reset),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
@@ -254,7 +340,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
child: OutlinedButton.icon(
|
child: OutlinedButton.icon(
|
||||||
onPressed: _handleChangePassword,
|
onPressed: _handleChangePassword,
|
||||||
icon: const Icon(Icons.update),
|
icon: const Icon(Icons.update),
|
||||||
label: const Text("Change Password"),
|
label: Text(l10n.changePassword),
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
@@ -266,9 +352,9 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: FilledButton.tonalIcon(
|
child: FilledButton.tonalIcon(
|
||||||
onPressed: auth.logout,
|
onPressed: () => _confirmLogout(auth, l10n),
|
||||||
icon: const Icon(Icons.logout),
|
icon: const Icon(Icons.logout),
|
||||||
label: const Text("Logout"),
|
label: Text(l10n.logout),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
foregroundColor: Colors.redAccent,
|
foregroundColor: Colors.redAccent,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
@@ -307,11 +393,11 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
"Version: 1.0.0-beta3.5",
|
"Version: 1.0.0-beta4.1",
|
||||||
style: TextStyle(color: Colors.grey),
|
style: TextStyle(color: Colors.grey),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
"Author: Highground-Soft & Minimax",
|
"Author: Highground-Soft",
|
||||||
style: TextStyle(color: Colors.grey),
|
style: TextStyle(color: Colors.grey),
|
||||||
),
|
),
|
||||||
SizedBox(height: 20),
|
SizedBox(height: 20),
|
||||||
|
|||||||
@@ -12,10 +12,14 @@ class SettingsProvider with ChangeNotifier {
|
|||||||
String _baseUrl = _defaultUrl;
|
String _baseUrl = _defaultUrl;
|
||||||
Color _themeColor = Colors.blue;
|
Color _themeColor = Colors.blue;
|
||||||
ThemeMode _themeMode = ThemeMode.system;
|
ThemeMode _themeMode = ThemeMode.system;
|
||||||
|
bool _livePreviewThumbnailsEnabled = false;
|
||||||
|
Locale? _locale;
|
||||||
|
|
||||||
String get baseUrl => _baseUrl;
|
String get baseUrl => _baseUrl;
|
||||||
Color get themeColor => _themeColor;
|
Color get themeColor => _themeColor;
|
||||||
ThemeMode get themeMode => _themeMode;
|
ThemeMode get themeMode => _themeMode;
|
||||||
|
bool get livePreviewThumbnailsEnabled => _livePreviewThumbnailsEnabled;
|
||||||
|
Locale? get locale => _locale;
|
||||||
|
|
||||||
SettingsProvider() {
|
SettingsProvider() {
|
||||||
_loadSettings();
|
_loadSettings();
|
||||||
@@ -32,6 +36,43 @@ class SettingsProvider with ChangeNotifier {
|
|||||||
if (savedThemeMode != null) {
|
if (savedThemeMode != null) {
|
||||||
_themeMode = _themeModeFromString(savedThemeMode);
|
_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();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,18 +97,49 @@ class SettingsProvider with ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setLivePreviewThumbnailsEnabled(bool enabled) async {
|
||||||
|
_livePreviewThumbnailsEnabled = enabled;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool('livePreviewThumbnailsEnabled', enabled);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
// Also provide the RTMP URL based on the same hostname
|
// Also provide the RTMP URL based on the same hostname
|
||||||
String get rtmpUrl {
|
String get rtmpUrl {
|
||||||
final uri = Uri.parse(_baseUrl);
|
final uri = Uri.parse(_baseUrl);
|
||||||
return "rtmp://${uri.host}:1935/live";
|
return "rtmp://${uri.host}:1935/live";
|
||||||
}
|
}
|
||||||
|
|
||||||
String playbackUrl(String roomId) {
|
String playbackUrl(String roomId, {String? quality}) {
|
||||||
final uri = Uri.parse(_baseUrl);
|
final uri = Uri.parse(_baseUrl);
|
||||||
|
final normalizedQuality = quality?.trim().toLowerCase();
|
||||||
|
|
||||||
if (kIsWeb) {
|
if (kIsWeb) {
|
||||||
return uri.replace(path: '/live/$roomId').toString();
|
return uri
|
||||||
|
.replace(
|
||||||
|
path: '/live/$roomId',
|
||||||
|
queryParameters:
|
||||||
|
normalizedQuality == null || normalizedQuality.isEmpty
|
||||||
|
? null
|
||||||
|
: {'quality': normalizedQuality},
|
||||||
|
)
|
||||||
|
.toString();
|
||||||
}
|
}
|
||||||
return "$rtmpUrl/$roomId";
|
|
||||||
|
if (normalizedQuality == null || normalizedQuality.isEmpty) {
|
||||||
|
return "$rtmpUrl/$roomId";
|
||||||
|
}
|
||||||
|
return "$rtmpUrl/$roomId/$normalizedQuality";
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
ThemeMode _themeModeFromString(String value) {
|
||||||
|
|||||||
@@ -43,11 +43,24 @@ class ApiService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<http.Response> changePassword(String oldPassword, String newPassword) async {
|
Future<http.Response> getPlaybackOptions(String roomId) async {
|
||||||
|
return await http.get(
|
||||||
|
Uri.parse("${settings.baseUrl}/api/rooms/$roomId/playback-options"),
|
||||||
|
headers: _headers,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<http.Response> changePassword(
|
||||||
|
String oldPassword,
|
||||||
|
String newPassword,
|
||||||
|
) async {
|
||||||
return await http.post(
|
return await http.post(
|
||||||
Uri.parse("${settings.baseUrl}/api/user/change-password"),
|
Uri.parse("${settings.baseUrl}/api/user/change-password"),
|
||||||
headers: _headers,
|
headers: _headers,
|
||||||
body: jsonEncode({"old_password": oldPassword, "new_password": newPassword}),
|
body: jsonEncode({
|
||||||
|
"old_password": oldPassword,
|
||||||
|
"new_password": newPassword,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
477
frontend/lib/widgets/android_quick_stream_panel.dart
Normal file
477
frontend/lib/widgets/android_quick_stream_panel.dart
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,11 +2,13 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
class WebStreamPlayer extends StatelessWidget {
|
class WebStreamPlayer extends StatelessWidget {
|
||||||
final String streamUrl;
|
final String streamUrl;
|
||||||
|
final double volume;
|
||||||
final int? refreshToken;
|
final int? refreshToken;
|
||||||
|
|
||||||
const WebStreamPlayer({
|
const WebStreamPlayer({
|
||||||
super.key,
|
super.key,
|
||||||
required this.streamUrl,
|
required this.streamUrl,
|
||||||
|
required this.volume,
|
||||||
this.refreshToken,
|
this.refreshToken,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// ignore_for_file: avoid_web_libraries_in_flutter, deprecated_member_use
|
||||||
|
|
||||||
import 'dart:html' as html;
|
import 'dart:html' as html;
|
||||||
import 'dart:ui_web' as ui_web;
|
import 'dart:ui_web' as ui_web;
|
||||||
|
|
||||||
@@ -5,11 +7,13 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
class WebStreamPlayer extends StatefulWidget {
|
class WebStreamPlayer extends StatefulWidget {
|
||||||
final String streamUrl;
|
final String streamUrl;
|
||||||
|
final double volume;
|
||||||
final int? refreshToken;
|
final int? refreshToken;
|
||||||
|
|
||||||
const WebStreamPlayer({
|
const WebStreamPlayer({
|
||||||
super.key,
|
super.key,
|
||||||
required this.streamUrl,
|
required this.streamUrl,
|
||||||
|
required this.volume,
|
||||||
this.refreshToken,
|
this.refreshToken,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -19,6 +23,7 @@ class WebStreamPlayer extends StatefulWidget {
|
|||||||
|
|
||||||
class _WebStreamPlayerState extends State<WebStreamPlayer> {
|
class _WebStreamPlayerState extends State<WebStreamPlayer> {
|
||||||
late final String _viewType;
|
late final String _viewType;
|
||||||
|
html.IFrameElement? _iframe;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -29,15 +34,30 @@ class _WebStreamPlayerState extends State<WebStreamPlayer> {
|
|||||||
ui_web.platformViewRegistry.registerViewFactory(_viewType, (int viewId) {
|
ui_web.platformViewRegistry.registerViewFactory(_viewType, (int viewId) {
|
||||||
final iframe = html.IFrameElement()
|
final iframe = html.IFrameElement()
|
||||||
..src =
|
..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.border = '0'
|
||||||
..style.width = '100%'
|
..style.width = '100%'
|
||||||
..style.height = '100%'
|
..style.height = '100%'
|
||||||
|
..style.pointerEvents = 'none'
|
||||||
..allow = 'autoplay; fullscreen';
|
..allow = 'autoplay; fullscreen';
|
||||||
|
_iframe = iframe;
|
||||||
return 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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return HtmlElementView(viewType: _viewType);
|
return HtmlElementView(viewType: _viewType);
|
||||||
|
|||||||
@@ -150,6 +150,11 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.0"
|
version: "6.0.0"
|
||||||
|
flutter_localizations:
|
||||||
|
dependency: "direct main"
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
flutter_test:
|
flutter_test:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -216,6 +221,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.8.0"
|
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:
|
json_annotation:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -368,6 +381,54 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.0"
|
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:
|
petitparser:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -416,6 +477,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
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:
|
shared_preferences:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 1.0.0-beta4.1
|
version: 1.0.0-beta4.7
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.11.1
|
sdk: ^3.11.1
|
||||||
@@ -40,6 +40,11 @@ dependencies:
|
|||||||
video_player: ^2.11.1
|
video_player: ^2.11.1
|
||||||
fvp: ^0.35.2
|
fvp: ^0.35.2
|
||||||
web_socket_channel: ^3.0.3
|
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:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
@@ -73,6 +78,7 @@ flutter_launcher_icons:
|
|||||||
|
|
||||||
# The following section is specific to Flutter packages.
|
# The following section is specific to Flutter packages.
|
||||||
flutter:
|
flutter:
|
||||||
|
generate: true
|
||||||
|
|
||||||
# The following line ensures that the Material Icons font is
|
# The following line ensures that the Material Icons font is
|
||||||
# included with your application, so that you can use the icons in
|
# included with your application, so that you can use the icons in
|
||||||
|
|||||||
@@ -45,14 +45,21 @@
|
|||||||
<script src="flv.min.js"></script>
|
<script src="flv.min.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<video id="player" controls autoplay muted playsinline></video>
|
<video id="player" autoplay muted playsinline></video>
|
||||||
<div id="message">Loading live stream...</div>
|
<div id="message">Loading live stream...</div>
|
||||||
<script>
|
<script>
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const streamUrl = params.get('src');
|
const streamUrl = params.get('src');
|
||||||
|
const initialVolume = Number.parseFloat(params.get('volume') || '1');
|
||||||
const video = document.getElementById('player');
|
const video = document.getElementById('player');
|
||||||
const message = document.getElementById('message');
|
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) {
|
function showMessage(text) {
|
||||||
video.style.display = 'none';
|
video.style.display = 'none';
|
||||||
message.style.display = 'flex';
|
message.style.display = 'flex';
|
||||||
@@ -66,6 +73,8 @@
|
|||||||
} else if (!flvjs.isSupported()) {
|
} else if (!flvjs.isSupported()) {
|
||||||
showMessage('This browser does not support FLV playback.');
|
showMessage('This browser does not support FLV playback.');
|
||||||
} else {
|
} else {
|
||||||
|
applyVolume(initialVolume);
|
||||||
|
|
||||||
const player = flvjs.createPlayer({
|
const player = flvjs.createPlayer({
|
||||||
type: 'flv',
|
type: 'flv',
|
||||||
url: streamUrl,
|
url: streamUrl,
|
||||||
@@ -90,6 +99,13 @@
|
|||||||
video.style.display = 'block';
|
video.style.display = 'block';
|
||||||
message.style.display = 'none';
|
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() {
|
window.addEventListener('beforeunload', function() {
|
||||||
player.destroy();
|
player.destroy();
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user