Compare commits
17 Commits
8b0cdd4744
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6eb0baf16e | |||
| 146f05388e | |||
| 98666ab1ea | |||
| 1cce5634b1 | |||
| 6b1c7242c7 | |||
| fa86c849ca | |||
| f97195d640 | |||
| 2d0acad161 | |||
| 48dc6c7b26 | |||
| 01b25883e1 | |||
| 6710aa0624 | |||
| a0c5e7590d | |||
| b2a27f7801 | |||
| f21498717b | |||
| 9c7261cbda | |||
| d05ec7ccdf | |||
| 38bc9526b2 |
1
.gitignore
vendored
@@ -3,6 +3,7 @@
|
|||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
docs/
|
docs/
|
||||||
|
.codex
|
||||||
|
|
||||||
# --- Backend (Go) ---
|
# --- Backend (Go) ---
|
||||||
backend/hightube.db
|
backend/hightube.db
|
||||||
|
|||||||
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 CGH0S7
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -1,32 +1,38 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
|
||||||
|
|
||||||
"hightube/internal/api"
|
"hightube/internal/api"
|
||||||
|
"hightube/internal/chat"
|
||||||
"hightube/internal/db"
|
"hightube/internal/db"
|
||||||
|
"hightube/internal/monitor"
|
||||||
"hightube/internal/stream"
|
"hightube/internal/stream"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
log.Println("Starting Hightube Server Version-1.0.2 ...")
|
monitor.Init(2000)
|
||||||
|
monitor.Infof("Starting Hightube Server v1.0.0-Beta4.1")
|
||||||
|
|
||||||
// Initialize Database and run auto-migrations
|
// Initialize Database and run auto-migrations
|
||||||
db.InitDB()
|
db.InitDB()
|
||||||
|
|
||||||
|
// Initialize Chat WebSocket Hub
|
||||||
|
chat.InitChat()
|
||||||
|
|
||||||
|
srv := stream.NewRTMPServer()
|
||||||
|
|
||||||
// Start the API server in a goroutine so it doesn't block the RTMP server
|
// Start the API server in a goroutine so it doesn't block the RTMP server
|
||||||
go func() {
|
go func() {
|
||||||
r := api.SetupRouter()
|
r := api.SetupRouter(srv)
|
||||||
log.Println("[INFO] API Server is listening on :8080...")
|
monitor.Infof("API server listening on :8080")
|
||||||
|
monitor.Infof("Web console listening on :8080/admin")
|
||||||
if err := r.Run(":8080"); err != nil {
|
if err := r.Run(":8080"); err != nil {
|
||||||
log.Fatalf("Failed to start API server: %v", err)
|
monitor.Errorf("Failed to start API server: %v", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Setup and start the RTMP server
|
// Setup and start the RTMP server
|
||||||
log.Println("[INFO] Ready to receive RTMP streams from OBS.")
|
monitor.Infof("Ready to receive RTMP streams from OBS")
|
||||||
srv := stream.NewRTMPServer()
|
|
||||||
if err := srv.Start(":1935"); err != nil {
|
if err := srv.Start(":1935"); err != nil {
|
||||||
log.Fatalf("Failed to start RTMP server: %v", err)
|
monitor.Errorf("Failed to start RTMP server: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,14 @@ module hightube
|
|||||||
|
|
||||||
go 1.26.1
|
go 1.26.1
|
||||||
|
|
||||||
require github.com/nareix/joy4 v0.0.0-20200507095837-05a4ffbb5369
|
require (
|
||||||
|
github.com/gin-gonic/gin v1.12.0
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
|
github.com/nareix/joy4 v0.0.0-20200507095837-05a4ffbb5369
|
||||||
|
golang.org/x/crypto v0.48.0
|
||||||
|
gorm.io/driver/sqlite v1.6.0
|
||||||
|
gorm.io/gorm v1.30.0
|
||||||
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||||
@@ -11,13 +18,12 @@ require (
|
|||||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||||
github.com/gin-gonic/gin v1.12.0 // indirect
|
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||||
github.com/goccy/go-json v0.10.5 // indirect
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
github.com/gorilla/websocket v1.5.3 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
@@ -34,11 +40,8 @@ require (
|
|||||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||||
golang.org/x/arch v0.22.0 // indirect
|
golang.org/x/arch v0.22.0 // indirect
|
||||||
golang.org/x/crypto v0.48.0 // indirect
|
|
||||||
golang.org/x/net v0.51.0 // indirect
|
golang.org/x/net v0.51.0 // indirect
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
golang.org/x/text v0.34.0 // indirect
|
golang.org/x/text v0.34.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.10 // indirect
|
google.golang.org/protobuf v1.36.10 // indirect
|
||||||
gorm.io/driver/sqlite v1.6.0 // indirect
|
|
||||||
gorm.io/gorm v1.30.0 // indirect
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCc
|
|||||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||||
@@ -14,6 +15,8 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w
|
|||||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
@@ -26,7 +29,11 @@ github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
|||||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
@@ -50,6 +57,7 @@ github.com/nareix/joy4 v0.0.0-20200507095837-05a4ffbb5369 h1:Yp0zFEufLz0H7jzffb4
|
|||||||
github.com/nareix/joy4 v0.0.0-20200507095837-05a4ffbb5369/go.mod h1:aFJ1ZwLjvHN4yEzE5Bkz8rD8/d8Vlj3UIuvz2yfET7I=
|
github.com/nareix/joy4 v0.0.0-20200507095837-05a4ffbb5369/go.mod h1:aFJ1ZwLjvHN4yEzE5Bkz8rD8/d8Vlj3UIuvz2yfET7I=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||||
@@ -64,12 +72,16 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
|||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||||
|
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||||
|
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||||
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
@@ -85,6 +97,7 @@ google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aO
|
|||||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||||
|
|||||||
354
backend/internal/api/admin.go
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"hightube/internal/chat"
|
||||||
|
"hightube/internal/db"
|
||||||
|
"hightube/internal/model"
|
||||||
|
"hightube/internal/monitor"
|
||||||
|
"hightube/internal/stream"
|
||||||
|
"hightube/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
var adminRTMP *stream.RTMPServer
|
||||||
|
|
||||||
|
func BindAdminDependencies(rtmpSrv *stream.RTMPServer) {
|
||||||
|
adminRTMP = rtmpSrv
|
||||||
|
}
|
||||||
|
|
||||||
|
func AdminLogin(c *gin.Context) {
|
||||||
|
var req LoginRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var user model.User
|
||||||
|
if err := db.DB.Where("username = ?", strings.TrimSpace(req.Username)).First(&user).Error; err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid username or password"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !utils.CheckPasswordHash(req.Password, user.Password) {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid username or password"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !user.Enabled {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Account is disabled"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.Role != "admin" {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := utils.GenerateToken(user.ID, user.Username, user.Role)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create admin session"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.SetCookie(adminSessionCookieName, token, 86400, "/", "", false, true)
|
||||||
|
monitor.Auditf("admin=%s signed in", user.Username)
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"username": user.Username,
|
||||||
|
"role": user.Role,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func AdminLogout(c *gin.Context) {
|
||||||
|
operator, _ := c.Get("username")
|
||||||
|
c.SetCookie(adminSessionCookieName, "", -1, "/", "", false, true)
|
||||||
|
monitor.Auditf("admin=%v signed out", operator)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "signed out"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetAdminSession(c *gin.Context) {
|
||||||
|
username, _ := c.Get("username")
|
||||||
|
role, _ := c.Get("role")
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"username": username,
|
||||||
|
"role": role,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetAdminOverview(c *gin.Context) {
|
||||||
|
stats := monitor.GetSnapshot()
|
||||||
|
chatStats := chat.StatsSnapshot{}
|
||||||
|
if chat.MainHub != nil {
|
||||||
|
chatStats = chat.MainHub.GetStatsSnapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
activeCount := 0
|
||||||
|
activePaths := []string{}
|
||||||
|
if adminRTMP != nil {
|
||||||
|
activeCount = adminRTMP.ActiveStreamCount()
|
||||||
|
activePaths = adminRTMP.ActiveStreamPaths()
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"system": stats,
|
||||||
|
"stream": gin.H{
|
||||||
|
"active_stream_count": activeCount,
|
||||||
|
"active_stream_paths": activePaths,
|
||||||
|
},
|
||||||
|
"chat": chatStats,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetAdminHealth(c *gin.Context) {
|
||||||
|
type dbHealth struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
health := gin.H{
|
||||||
|
"api": true,
|
||||||
|
"rtmp": adminRTMP != nil,
|
||||||
|
}
|
||||||
|
|
||||||
|
dbOK := dbHealth{OK: true}
|
||||||
|
if err := db.DB.Exec("SELECT 1").Error; err != nil {
|
||||||
|
dbOK.OK = false
|
||||||
|
dbOK.Error = err.Error()
|
||||||
|
}
|
||||||
|
health["db"] = dbOK
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, health)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListAdminLogs(c *gin.Context) {
|
||||||
|
level := c.Query("level")
|
||||||
|
keyword := c.Query("keyword")
|
||||||
|
page := parseIntWithDefault(c.Query("page"), 1)
|
||||||
|
pageSize := parseIntWithDefault(c.Query("page_size"), 20)
|
||||||
|
|
||||||
|
items, total := monitor.Query(level, keyword, page, pageSize)
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"items": items,
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"page_size": pageSize,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func StreamAdminLogs(c *gin.Context) {
|
||||||
|
c.Header("Content-Type", "text/event-stream")
|
||||||
|
c.Header("Cache-Control", "no-cache")
|
||||||
|
c.Header("Connection", "keep-alive")
|
||||||
|
c.Header("X-Accel-Buffering", "no")
|
||||||
|
|
||||||
|
flusher, ok := c.Writer.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "streaming unsupported"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ch := monitor.Subscribe()
|
||||||
|
defer monitor.Unsubscribe(ch)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case entry := <-ch:
|
||||||
|
payload, _ := json.Marshal(entry)
|
||||||
|
_, _ = c.Writer.Write([]byte("event: log\n"))
|
||||||
|
_, _ = c.Writer.Write([]byte("data: " + string(payload) + "\n\n"))
|
||||||
|
flusher.Flush()
|
||||||
|
case <-c.Request.Context().Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type updateRoleRequest struct {
|
||||||
|
Role string `json:"role" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdateUserRole(c *gin.Context) {
|
||||||
|
userID, ok := parsePathUint(c.Param("id"))
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid user id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req updateRoleRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
role := strings.ToLower(strings.TrimSpace(req.Role))
|
||||||
|
if role != "admin" && role != "user" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "role must be admin or user"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.DB.Model(&model.User{}).Where("id = ?", userID).Update("role", role).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update role"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
operator, _ := c.Get("username")
|
||||||
|
monitor.Auditf("admin=%v updated user_id=%d role=%s", operator, userID, role)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "role updated"})
|
||||||
|
}
|
||||||
|
|
||||||
|
type updateEnabledRequest struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdateUserEnabled(c *gin.Context) {
|
||||||
|
userID, ok := parsePathUint(c.Param("id"))
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid user id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req updateEnabledRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.DB.Model(&model.User{}).Where("id = ?", userID).Update("enabled", req.Enabled).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update enabled status"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
operator, _ := c.Get("username")
|
||||||
|
monitor.Auditf("admin=%v updated user_id=%d enabled=%v", operator, userID, req.Enabled)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "enabled status updated"})
|
||||||
|
}
|
||||||
|
|
||||||
|
type resetPasswordRequest struct {
|
||||||
|
NewPassword string `json:"new_password" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResetUserPassword(c *gin.Context) {
|
||||||
|
userID, ok := parsePathUint(c.Param("id"))
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid user id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req resetPasswordRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := utils.HashPassword(req.NewPassword)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.DB.Model(&model.User{}).Where("id = ?", userID).Update("password", hash).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reset password"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
operator, _ := c.Get("username")
|
||||||
|
monitor.Auditf("admin=%v reset password for user_id=%d", operator, userID)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "password reset"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeleteUser(c *gin.Context) {
|
||||||
|
userID, ok := parsePathUint(c.Param("id"))
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid user id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
operatorID, _ := c.Get("user_id")
|
||||||
|
if opID, ok := operatorID.(uint); ok && opID == userID {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot delete current admin account"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.DB.Where("user_id = ?", userID).Delete(&model.Room{}).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete rooms"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := db.DB.Delete(&model.User{}, userID).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete user"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
operator, _ := c.Get("username")
|
||||||
|
monitor.Auditf("admin=%v deleted user_id=%d", operator, userID)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "user deleted"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListUsers(c *gin.Context) {
|
||||||
|
keyword := strings.TrimSpace(c.Query("keyword"))
|
||||||
|
page := parseIntWithDefault(c.Query("page"), 1)
|
||||||
|
pageSize := parseIntWithDefault(c.Query("page_size"), 20)
|
||||||
|
if pageSize > 200 {
|
||||||
|
pageSize = 200
|
||||||
|
}
|
||||||
|
offset := (page - 1) * pageSize
|
||||||
|
if offset < 0 {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
query := db.DB.Model(&model.User{})
|
||||||
|
if keyword != "" {
|
||||||
|
query = query.Where("username LIKE ?", "%"+keyword+"%")
|
||||||
|
}
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
if err := query.Count(&total).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count users"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var users []model.User
|
||||||
|
if err := query.Order("id DESC").Offset(offset).Limit(pageSize).Find(&users).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query users"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]gin.H, 0, len(users))
|
||||||
|
for _, u := range users {
|
||||||
|
items = append(items, gin.H{
|
||||||
|
"id": u.ID,
|
||||||
|
"username": u.Username,
|
||||||
|
"role": u.Role,
|
||||||
|
"enabled": u.Enabled,
|
||||||
|
"created_at": u.CreatedAt,
|
||||||
|
"updated_at": u.UpdatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"items": items,
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"page_size": pageSize,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseIntWithDefault(v string, def int) int {
|
||||||
|
i, err := strconv.Atoi(v)
|
||||||
|
if err != nil || i <= 0 {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsePathUint(v string) (uint, bool) {
|
||||||
|
u64, err := strconv.ParseUint(v, 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return uint(u64), true
|
||||||
|
}
|
||||||
21
backend/internal/api/admin_ui.go
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed static/admin/*
|
||||||
|
var adminFS embed.FS
|
||||||
|
|
||||||
|
func AdminPage(c *gin.Context) {
|
||||||
|
content, err := fs.ReadFile(adminFS, "static/admin/index.html")
|
||||||
|
if err != nil {
|
||||||
|
c.String(http.StatusInternalServerError, "failed to load admin page")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Data(http.StatusOK, "text/html; charset=utf-8", content)
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
@@ -20,6 +22,11 @@ type LoginRequest struct {
|
|||||||
Password string `json:"password" binding:"required"`
|
Password string `json:"password" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ChangePasswordRequest struct {
|
||||||
|
OldPassword string `json:"old_password" binding:"required"`
|
||||||
|
NewPassword string `json:"new_password" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
func Register(c *gin.Context) {
|
func Register(c *gin.Context) {
|
||||||
var req RegisterRequest
|
var req RegisterRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
@@ -27,6 +34,12 @@ func Register(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
req.Username = strings.TrimSpace(req.Username)
|
||||||
|
if strings.EqualFold(req.Username, bootstrapAdminUsername()) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "This username is reserved"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Check if user exists
|
// Check if user exists
|
||||||
var existingUser model.User
|
var existingUser model.User
|
||||||
if err := db.DB.Where("username = ?", req.Username).First(&existingUser).Error; err == nil {
|
if err := db.DB.Where("username = ?", req.Username).First(&existingUser).Error; err == nil {
|
||||||
@@ -45,6 +58,8 @@ func Register(c *gin.Context) {
|
|||||||
user := model.User{
|
user := model.User{
|
||||||
Username: req.Username,
|
Username: req.Username,
|
||||||
Password: hashedPassword,
|
Password: hashedPassword,
|
||||||
|
Role: "user",
|
||||||
|
Enabled: true,
|
||||||
}
|
}
|
||||||
if err := db.DB.Create(&user).Error; err != nil {
|
if err := db.DB.Create(&user).Error; err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
|
||||||
@@ -84,13 +99,66 @@ func Login(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
token, err := utils.GenerateToken(user.ID)
|
if !user.Enabled {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Account is disabled"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := utils.GenerateToken(user.ID, user.Username, user.Role)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"token": token,
|
"token": token,
|
||||||
|
"username": user.Username,
|
||||||
|
"role": user.Role,
|
||||||
|
"enabled": user.Enabled,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ChangePassword(c *gin.Context) {
|
||||||
|
userID, _ := c.Get("user_id")
|
||||||
|
|
||||||
|
var req ChangePasswordRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var user model.User
|
||||||
|
if err := db.DB.First(&user, userID).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify old password
|
||||||
|
if !utils.CheckPasswordHash(req.OldPassword, user.Password) {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid old password"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash new password
|
||||||
|
hashedPassword, err := utils.HashPassword(req.NewPassword)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update user
|
||||||
|
if err := db.DB.Model(&user).Update("password", hashedPassword).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update password"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Password updated successfully"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func bootstrapAdminUsername() string {
|
||||||
|
adminUsername := strings.TrimSpace(os.Getenv("HIGHTUBE_ADMIN_USER"))
|
||||||
|
if adminUsername == "" {
|
||||||
|
return "admin"
|
||||||
|
}
|
||||||
|
return adminUsername
|
||||||
|
}
|
||||||
|
|||||||
54
backend/internal/api/chat_handler.go
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
|
||||||
|
"hightube/internal/chat"
|
||||||
|
"hightube/internal/monitor"
|
||||||
|
)
|
||||||
|
|
||||||
|
var upgrader = websocket.Upgrader{
|
||||||
|
CheckOrigin: func(r *http.Request) bool {
|
||||||
|
return true // Allow all connections
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// WSHandler handles websocket requests from clients
|
||||||
|
func WSHandler(c *gin.Context) {
|
||||||
|
roomID := c.Param("room_id")
|
||||||
|
username := c.DefaultQuery("username", "Anonymous")
|
||||||
|
|
||||||
|
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||||
|
if err != nil {
|
||||||
|
monitor.Errorf("WebSocket upgrade failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &chat.Client{
|
||||||
|
Hub: chat.MainHub,
|
||||||
|
Conn: conn,
|
||||||
|
Send: make(chan []byte, 256),
|
||||||
|
RoomID: roomID,
|
||||||
|
Username: username,
|
||||||
|
}
|
||||||
|
|
||||||
|
client.Hub.RegisterClient(client)
|
||||||
|
|
||||||
|
// Start reading and writing loops in goroutines
|
||||||
|
go client.WritePump()
|
||||||
|
go client.ReadPump()
|
||||||
|
|
||||||
|
// Optionally broadcast a system message: User Joined
|
||||||
|
chat.MainHub.BroadcastToRoom(chat.Message{
|
||||||
|
Type: "system",
|
||||||
|
Username: "System",
|
||||||
|
Content: fmt.Sprintf("%s joined the room", username),
|
||||||
|
RoomID: roomID,
|
||||||
|
})
|
||||||
|
|
||||||
|
monitor.Infof("WebSocket client joined room_id=%s username=%s", roomID, username)
|
||||||
|
}
|
||||||
@@ -1,42 +1,136 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"hightube/internal/db"
|
||||||
|
"hightube/internal/model"
|
||||||
|
"hightube/internal/monitor"
|
||||||
"hightube/internal/utils"
|
"hightube/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const adminSessionCookieName = "hightube_admin_session"
|
||||||
|
|
||||||
// AuthMiddleware intercepts requests, validates JWT, and injects user_id into context
|
// AuthMiddleware intercepts requests, validates JWT, and injects user_id into context
|
||||||
func AuthMiddleware() gin.HandlerFunc {
|
func AuthMiddleware() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
authHeader := c.GetHeader("Authorization")
|
user, err := authenticateRequest(c)
|
||||||
if authHeader == "" {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header is required"})
|
|
||||||
c.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
parts := strings.Split(authHeader, " ")
|
|
||||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header format must be Bearer {token}"})
|
|
||||||
c.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
tokenStr := parts[1]
|
|
||||||
userIDStr, err := utils.ParseToken(tokenStr)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token"})
|
switch {
|
||||||
|
case errors.Is(err, errMissingToken):
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization is required"})
|
||||||
|
case errors.Is(err, errInvalidToken):
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token"})
|
||||||
|
case errors.Is(err, errUserNotFound):
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not found"})
|
||||||
|
case errors.Is(err, errDisabledAccount):
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Account is disabled"})
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication failed"})
|
||||||
|
}
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
userID, _ := strconv.ParseUint(userIDStr, 10, 32)
|
c.Set("user_id", user.ID)
|
||||||
c.Set("user_id", uint(userID))
|
c.Set("username", user.Username)
|
||||||
|
c.Set("role", user.Role)
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func AdminMiddleware() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
role, ok := c.Get("role")
|
||||||
|
if !ok || role != "admin" {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "admin access required"})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func RequestMetricsMiddleware() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
monitor.IncrementRequestCount()
|
||||||
|
c.Next()
|
||||||
|
if c.Writer.Status() >= http.StatusBadRequest {
|
||||||
|
monitor.IncrementErrorCount()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
errMissingToken = errors.New("missing token")
|
||||||
|
errInvalidToken = errors.New("invalid token")
|
||||||
|
errUserNotFound = errors.New("user not found")
|
||||||
|
errDisabledAccount = errors.New("disabled account")
|
||||||
|
)
|
||||||
|
|
||||||
|
func authenticateRequest(c *gin.Context) (*model.User, error) {
|
||||||
|
tokenStr := extractToken(c)
|
||||||
|
if tokenStr == "" {
|
||||||
|
return nil, errMissingToken
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := utils.ParseToken(tokenStr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errInvalidToken
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, err := strconv.ParseUint(claims.Subject, 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errInvalidToken
|
||||||
|
}
|
||||||
|
|
||||||
|
var user model.User
|
||||||
|
if err := db.DB.First(&user, uint(userID)).Error; err != nil {
|
||||||
|
return nil, errUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if !user.Enabled {
|
||||||
|
return nil, errDisabledAccount
|
||||||
|
}
|
||||||
|
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractToken(c *gin.Context) string {
|
||||||
|
authHeader := strings.TrimSpace(c.GetHeader("Authorization"))
|
||||||
|
if authHeader != "" {
|
||||||
|
parts := strings.Split(authHeader, " ")
|
||||||
|
if len(parts) == 2 && parts[0] == "Bearer" {
|
||||||
|
return strings.TrimSpace(parts[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cookieToken, err := c.Cookie(adminSessionCookieName)
|
||||||
|
if err == nil {
|
||||||
|
return strings.TrimSpace(cookieToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// CORSMiddleware handles cross-origin requests from web clients
|
||||||
|
func CORSMiddleware() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||||
|
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With")
|
||||||
|
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE")
|
||||||
|
|
||||||
|
if c.Request.Method == "OPTIONS" {
|
||||||
|
c.AbortWithStatus(204)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,14 +2,20 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"hightube/internal/stream"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SetupRouter configures the Gin router and defines API endpoints
|
// SetupRouter configures the Gin router and defines API endpoints
|
||||||
func SetupRouter() *gin.Engine {
|
func SetupRouter(streamServer *stream.RTMPServer) *gin.Engine {
|
||||||
// 设置为发布模式,消除 "[WARNING] Running in debug mode" 警告
|
// 设置为发布模式,消除 "[WARNING] Running in debug mode" 警告
|
||||||
gin.SetMode(gin.ReleaseMode)
|
gin.SetMode(gin.ReleaseMode)
|
||||||
|
|
||||||
r := gin.Default()
|
r := gin.Default()
|
||||||
|
BindAdminDependencies(streamServer)
|
||||||
|
|
||||||
|
// Use CORS middleware to allow web access
|
||||||
|
r.Use(CORSMiddleware(), RequestMetricsMiddleware())
|
||||||
|
|
||||||
// 清除代理信任警告 "[WARNING] You trusted all proxies"
|
// 清除代理信任警告 "[WARNING] You trusted all proxies"
|
||||||
r.SetTrustedProxies(nil)
|
r.SetTrustedProxies(nil)
|
||||||
@@ -17,13 +23,37 @@ func SetupRouter() *gin.Engine {
|
|||||||
// Public routes
|
// Public routes
|
||||||
r.POST("/api/register", Register)
|
r.POST("/api/register", Register)
|
||||||
r.POST("/api/login", Login)
|
r.POST("/api/login", Login)
|
||||||
|
r.POST("/api/admin/login", AdminLogin)
|
||||||
r.GET("/api/rooms/active", GetActiveRooms)
|
r.GET("/api/rooms/active", GetActiveRooms)
|
||||||
|
r.GET("/live/:room_id", streamServer.HandleHTTPFLV)
|
||||||
|
|
||||||
|
// WebSocket endpoint for live chat
|
||||||
|
r.GET("/api/ws/room/:room_id", WSHandler)
|
||||||
|
r.GET("/admin", AdminPage)
|
||||||
|
r.GET("/api/admin/logs/stream", AuthMiddleware(), AdminMiddleware(), StreamAdminLogs)
|
||||||
|
|
||||||
// Protected routes (require JWT)
|
// Protected routes (require JWT)
|
||||||
authGroup := r.Group("/api")
|
authGroup := r.Group("/api")
|
||||||
authGroup.Use(AuthMiddleware())
|
authGroup.Use(AuthMiddleware())
|
||||||
{
|
{
|
||||||
authGroup.GET("/room/my", GetMyRoom)
|
authGroup.GET("/room/my", GetMyRoom)
|
||||||
|
authGroup.GET("/rooms/:room_id/playback-options", GetRoomPlaybackOptions)
|
||||||
|
authGroup.POST("/user/change-password", ChangePassword)
|
||||||
|
|
||||||
|
adminGroup := authGroup.Group("/admin")
|
||||||
|
adminGroup.Use(AdminMiddleware())
|
||||||
|
{
|
||||||
|
adminGroup.GET("/session", GetAdminSession)
|
||||||
|
adminGroup.GET("/overview", GetAdminOverview)
|
||||||
|
adminGroup.GET("/health", GetAdminHealth)
|
||||||
|
adminGroup.GET("/logs", ListAdminLogs)
|
||||||
|
adminGroup.POST("/logout", AdminLogout)
|
||||||
|
adminGroup.GET("/users", ListUsers)
|
||||||
|
adminGroup.PATCH("/users/:id/role", UpdateUserRole)
|
||||||
|
adminGroup.PATCH("/users/:id/enabled", UpdateUserEnabled)
|
||||||
|
adminGroup.POST("/users/:id/reset-password", ResetUserPassword)
|
||||||
|
adminGroup.DELETE("/users/:id", DeleteUser)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return r
|
return r
|
||||||
|
|||||||
677
backend/internal/api/static/admin/index.html
Normal file
@@ -0,0 +1,677 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<title>Hightube Admin Console</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=IBM+Plex+Mono:wght@400;600&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #eef4fb;
|
||||||
|
--bg-accent: #dbe9f7;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-soft: #f6f9fc;
|
||||||
|
--ink: #19324d;
|
||||||
|
--muted: #5a718a;
|
||||||
|
--line: #d7e1eb;
|
||||||
|
--primary: #2f6fed;
|
||||||
|
--primary-strong: #1d4ed8;
|
||||||
|
--primary-soft: #dce8ff;
|
||||||
|
--danger: #dc2626;
|
||||||
|
--danger-soft: #fee2e2;
|
||||||
|
--success: #15803d;
|
||||||
|
--success-soft: #dcfce7;
|
||||||
|
--warning: #b45309;
|
||||||
|
--shadow: 0 18px 44px rgba(29, 78, 216, 0.10);
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
font-family: "Space Grotesk", "Segoe UI", sans-serif;
|
||||||
|
color: var(--ink);
|
||||||
|
background:
|
||||||
|
radial-gradient(900px 460px at -10% -20%, rgba(47,111,237,0.16) 0%, transparent 60%),
|
||||||
|
radial-gradient(800px 420px at 110% 0%, rgba(61,187,167,0.12) 0%, transparent 55%),
|
||||||
|
linear-gradient(180deg, var(--bg) 0%, #f8fbff 100%);
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(28px, 5vw, 42px);
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand p {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
max-width: 620px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255,255,255,0.78);
|
||||||
|
border: 1px solid rgba(47,111,237,0.12);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
color: var(--primary-strong);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(12, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: rgba(255,255,255,0.88);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 18px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
grid-column: 4 / span 6;
|
||||||
|
padding: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.full { grid-column: 1 / -1; }
|
||||||
|
.col4 { grid-column: span 4; }
|
||||||
|
.col5 { grid-column: span 5; }
|
||||||
|
.col7 { grid-column: span 7; }
|
||||||
|
.col8 { grid-column: span 8; }
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 19px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row.between {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, select, button {
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, select {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
background: linear-gradient(120deg, var(--primary), #5994ff);
|
||||||
|
font-weight: 700;
|
||||||
|
transition: transform 140ms ease, opacity 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.secondary {
|
||||||
|
background: linear-gradient(120deg, #7ea7ff, #4b7df2);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.subtle {
|
||||||
|
color: var(--ink);
|
||||||
|
background: var(--surface-soft);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.danger {
|
||||||
|
background: linear-gradient(120deg, #ef4444, #dc2626);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric {
|
||||||
|
background: var(--surface-soft);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric .k {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric .v {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-strip {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-chip {
|
||||||
|
min-width: 120px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: var(--surface-soft);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-chip .k {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: "IBM Plex Mono", monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#logs {
|
||||||
|
height: 300px;
|
||||||
|
overflow: auto;
|
||||||
|
background: #f5f9ff;
|
||||||
|
color: #17304d;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
font-family: "IBM Plex Mono", monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
padding: 10px 8px;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill.ok {
|
||||||
|
background: var(--success-soft);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill.off {
|
||||||
|
background: var(--danger-soft);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill.admin {
|
||||||
|
background: var(--primary-soft);
|
||||||
|
color: var(--primary-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill.user {
|
||||||
|
background: #edf2f7;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #fff7ed;
|
||||||
|
border: 1px solid #fed7aa;
|
||||||
|
color: var(--warning);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-info {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.login-card, .col4, .col5, .col7, .col8 {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-strip {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="shell">
|
||||||
|
<div class="hero">
|
||||||
|
<div class="brand">
|
||||||
|
<div class="badge">Admin Panel</div>
|
||||||
|
<h1>Hightube Control Console</h1>
|
||||||
|
<p>Lightweight operations dashboard for stream status, runtime health, audit logs, and account management.</p>
|
||||||
|
</div>
|
||||||
|
<div class="session-info" id="sessionInfo">Not signed in</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-grid" id="loginView">
|
||||||
|
<div class="card login-card">
|
||||||
|
<h2>Admin Sign In</h2>
|
||||||
|
<p class="muted">Use the administrator account to access monitoring, logs, and user controls. Default bootstrap credentials are <b>admin / admin</b> unless changed by environment variables.</p>
|
||||||
|
<div class="stack">
|
||||||
|
<input id="loginUsername" placeholder="Admin username" value="admin" />
|
||||||
|
<input id="loginPassword" type="password" placeholder="Password" value="admin" />
|
||||||
|
<div class="row">
|
||||||
|
<button onclick="login()">Sign In</button>
|
||||||
|
</div>
|
||||||
|
<div class="notice">
|
||||||
|
Change the default admin password immediately after first login.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-grid hidden" id="appView">
|
||||||
|
<div class="card full">
|
||||||
|
<div class="row between">
|
||||||
|
<div>
|
||||||
|
<h2 style="margin-bottom:6px;">Session</h2>
|
||||||
|
<div class="muted">Authenticated through a server-managed admin session cookie.</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<button class="subtle" onclick="refreshAll()">Refresh Now</button>
|
||||||
|
<button class="danger" onclick="logout()">Sign Out</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card col7">
|
||||||
|
<h2>System Overview</h2>
|
||||||
|
<div class="stats" id="stats"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card col5">
|
||||||
|
<h2>Admin Password</h2>
|
||||||
|
<div class="stack">
|
||||||
|
<input id="oldPassword" type="password" placeholder="Current password" />
|
||||||
|
<input id="newPassword" type="password" placeholder="New password" />
|
||||||
|
<div class="row">
|
||||||
|
<button onclick="changePassword()">Update Password</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card col4">
|
||||||
|
<h2>Live Status</h2>
|
||||||
|
<div id="online"></div>
|
||||||
|
<div class="health-strip" id="health" style="margin-top:14px;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card col8">
|
||||||
|
<div class="row between" style="margin-bottom:10px;">
|
||||||
|
<h2 style="margin:0;">Audit and Runtime Logs</h2>
|
||||||
|
<div class="row">
|
||||||
|
<select id="logLevel">
|
||||||
|
<option value="">All levels</option>
|
||||||
|
<option value="info">info</option>
|
||||||
|
<option value="warn">warn</option>
|
||||||
|
<option value="error">error</option>
|
||||||
|
<option value="audit">audit</option>
|
||||||
|
</select>
|
||||||
|
<input id="logKeyword" placeholder="Filter keyword" style="width:220px;" />
|
||||||
|
<button class="secondary" onclick="loadHistory()">Load History</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="logs"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card full">
|
||||||
|
<div class="row between" style="margin-bottom:10px;">
|
||||||
|
<h2 style="margin:0;">User Management</h2>
|
||||||
|
<div class="row">
|
||||||
|
<input id="userKeyword" placeholder="Search by username" style="width:220px;" />
|
||||||
|
<button class="secondary" onclick="loadUsers()">Search</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Username</th>
|
||||||
|
<th>Role</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="usersBody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let evt = null;
|
||||||
|
let overviewTimer = null;
|
||||||
|
let healthTimer = null;
|
||||||
|
let currentAdmin = null;
|
||||||
|
|
||||||
|
function setSessionText(text) {
|
||||||
|
document.getElementById('sessionInfo').textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLogLine(text) {
|
||||||
|
const box = document.getElementById('logs');
|
||||||
|
box.textContent += text + '\n';
|
||||||
|
box.scrollTop = box.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(path, options = {}) {
|
||||||
|
const response = await fetch(path, {
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(options.headers || {}),
|
||||||
|
},
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
|
||||||
|
let data = null;
|
||||||
|
const text = await response.text();
|
||||||
|
if (text) {
|
||||||
|
try {
|
||||||
|
data = JSON.parse(text);
|
||||||
|
} catch (_) {
|
||||||
|
data = { raw: text };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = (data && (data.error || data.message)) || `Request failed (${response.status})`;
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPolling() {
|
||||||
|
if (overviewTimer) clearInterval(overviewTimer);
|
||||||
|
if (healthTimer) clearInterval(healthTimer);
|
||||||
|
overviewTimer = null;
|
||||||
|
healthTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnectLogs() {
|
||||||
|
if (evt) {
|
||||||
|
evt.close();
|
||||||
|
evt = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLogin() {
|
||||||
|
clearPolling();
|
||||||
|
disconnectLogs();
|
||||||
|
currentAdmin = null;
|
||||||
|
setSessionText('Not signed in');
|
||||||
|
document.getElementById('loginView').classList.remove('hidden');
|
||||||
|
document.getElementById('appView').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showApp(session) {
|
||||||
|
currentAdmin = session;
|
||||||
|
setSessionText(`Signed in as ${session.username}`);
|
||||||
|
document.getElementById('loginView').classList.add('hidden');
|
||||||
|
document.getElementById('appView').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureSession() {
|
||||||
|
try {
|
||||||
|
const session = await api('/api/admin/session');
|
||||||
|
showApp(session);
|
||||||
|
await refreshAll();
|
||||||
|
connectLiveLogs();
|
||||||
|
clearPolling();
|
||||||
|
overviewTimer = setInterval(loadOverview, 1000);
|
||||||
|
healthTimer = setInterval(loadHealth, 1000);
|
||||||
|
} catch (_) {
|
||||||
|
showLogin();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login() {
|
||||||
|
const username = document.getElementById('loginUsername').value.trim();
|
||||||
|
const password = document.getElementById('loginPassword').value;
|
||||||
|
try {
|
||||||
|
await api('/api/admin/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
document.getElementById('loginPassword').value = '';
|
||||||
|
await ensureSession();
|
||||||
|
} catch (error) {
|
||||||
|
alert(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
try {
|
||||||
|
await api('/api/admin/logout', { method: 'POST' });
|
||||||
|
} catch (_) {
|
||||||
|
} finally {
|
||||||
|
showLogin();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAll() {
|
||||||
|
await Promise.all([loadOverview(), loadHealth(), loadUsers(), loadHistory()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOverview() {
|
||||||
|
const data = await api('/api/admin/overview');
|
||||||
|
const sys = data.system || {};
|
||||||
|
const stream = data.stream || {};
|
||||||
|
const chat = data.chat || {};
|
||||||
|
|
||||||
|
document.getElementById('stats').innerHTML = `
|
||||||
|
<div class="metric"><div class="k">Uptime (s)</div><div class="v">${sys.uptime_seconds ?? '-'}</div></div>
|
||||||
|
<div class="metric"><div class="k">Requests</div><div class="v">${sys.requests_total ?? '-'}</div></div>
|
||||||
|
<div class="metric"><div class="k">Errors</div><div class="v">${sys.errors_total ?? '-'}</div></div>
|
||||||
|
<div class="metric"><div class="k">Goroutines</div><div class="v">${sys.goroutines ?? '-'}</div></div>
|
||||||
|
<div class="metric"><div class="k">Alloc (MB)</div><div class="v">${(sys.memory_alloc_mb || 0).toFixed(1)}</div></div>
|
||||||
|
<div class="metric"><div class="k">System Mem (MB)</div><div class="v">${(sys.memory_sys_mb || 0).toFixed(1)}</div></div>
|
||||||
|
<div class="metric"><div class="k">CPU Cores</div><div class="v">${sys.cpu_cores ?? '-'}</div></div>
|
||||||
|
<div class="metric"><div class="k">Disk Free / Total (GB)</div><div class="v">${(sys.disk_free_gb || 0).toFixed(1)} / ${(sys.disk_total_gb || 0).toFixed(1)}</div></div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.getElementById('online').innerHTML = `
|
||||||
|
<p>Active streams: <b>${stream.active_stream_count ?? 0}</b></p>
|
||||||
|
<p>Active chat rooms: <b>${chat.room_count ?? 0}</b></p>
|
||||||
|
<p>Connected chat clients: <b>${chat.total_connected_client ?? 0}</b></p>
|
||||||
|
<div class="mono">Stream paths: ${(stream.active_stream_paths || []).join(', ') || 'none'}</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadHealth() {
|
||||||
|
const h = await api('/api/admin/health');
|
||||||
|
const dbOk = h.db && h.db.ok;
|
||||||
|
document.getElementById('health').innerHTML =
|
||||||
|
`<div class="health-chip"><div class="k">API</div><span class="pill ${h.api ? 'ok' : 'off'}">${h.api ? 'UP' : 'DOWN'}</span></div>` +
|
||||||
|
`<div class="health-chip"><div class="k">RTMP</div><span class="pill ${h.rtmp ? 'ok' : 'off'}">${h.rtmp ? 'UP' : 'DOWN'}</span></div>` +
|
||||||
|
`<div class="health-chip"><div class="k">Database</div><span class="pill ${dbOk ? 'ok' : 'off'}">${dbOk ? 'UP' : 'DOWN'}</span></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadHistory() {
|
||||||
|
const level = encodeURIComponent(document.getElementById('logLevel').value || '');
|
||||||
|
const keyword = encodeURIComponent(document.getElementById('logKeyword').value || '');
|
||||||
|
const data = await api(`/api/admin/logs?page=1&page_size=100&level=${level}&keyword=${keyword}`);
|
||||||
|
const items = data.items || [];
|
||||||
|
const box = document.getElementById('logs');
|
||||||
|
box.textContent = '';
|
||||||
|
items.forEach(it => addLogLine(`[${it.time}] [${it.level}] ${it.message}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectLiveLogs() {
|
||||||
|
disconnectLogs();
|
||||||
|
evt = new EventSource('/api/admin/logs/stream', { withCredentials: true });
|
||||||
|
evt.onmessage = () => {};
|
||||||
|
evt.addEventListener('log', (e) => {
|
||||||
|
const item = JSON.parse(e.data);
|
||||||
|
addLogLine(`[${item.time}] [${item.level}] ${item.message}`);
|
||||||
|
});
|
||||||
|
evt.onerror = () => {
|
||||||
|
addLogLine('[warn] Live log stream disconnected.');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUsers() {
|
||||||
|
const keyword = encodeURIComponent(document.getElementById('userKeyword').value || '');
|
||||||
|
const data = await api(`/api/admin/users?page=1&page_size=50&keyword=${keyword}`);
|
||||||
|
const body = document.getElementById('usersBody');
|
||||||
|
body.innerHTML = '';
|
||||||
|
(data.items || []).forEach((u) => {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td>${u.id}</td>
|
||||||
|
<td>${u.username}</td>
|
||||||
|
<td><span class="pill ${u.role === 'admin' ? 'admin' : 'user'}">${u.role}</span></td>
|
||||||
|
<td><span class="pill ${u.enabled ? 'ok' : 'off'}">${u.enabled ? 'enabled' : 'disabled'}</span></td>
|
||||||
|
<td>${new Date(u.created_at).toLocaleString()}</td>
|
||||||
|
<td class="actions">
|
||||||
|
<button class="subtle" onclick="toggleRole(${u.id}, '${u.role}')">Toggle Role</button>
|
||||||
|
<button class="subtle" onclick="toggleEnabled(${u.id}, ${u.enabled})">Enable / Disable</button>
|
||||||
|
<button class="secondary" onclick="resetPwd(${u.id})">Reset Password</button>
|
||||||
|
<button class="danger" onclick="deleteUser(${u.id})">Delete</button>
|
||||||
|
</td>
|
||||||
|
`;
|
||||||
|
body.appendChild(tr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleRole(id, role) {
|
||||||
|
const next = role === 'admin' ? 'user' : 'admin';
|
||||||
|
await api(`/api/admin/users/${id}/role`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ role: next }),
|
||||||
|
});
|
||||||
|
await loadUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleEnabled(id, enabled) {
|
||||||
|
await api(`/api/admin/users/${id}/enabled`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ enabled: !enabled }),
|
||||||
|
});
|
||||||
|
await loadUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetPwd(id) {
|
||||||
|
const newPwd = prompt('Enter a new password for this user');
|
||||||
|
if (!newPwd) return;
|
||||||
|
await api(`/api/admin/users/${id}/reset-password`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ new_password: newPwd }),
|
||||||
|
});
|
||||||
|
addLogLine(`[audit] password reset requested for user ${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteUser(id) {
|
||||||
|
if (!confirm('Delete this user account?')) return;
|
||||||
|
await api(`/api/admin/users/${id}`, { method: 'DELETE' });
|
||||||
|
await loadUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changePassword() {
|
||||||
|
const oldPassword = document.getElementById('oldPassword').value;
|
||||||
|
const newPassword = document.getElementById('newPassword').value;
|
||||||
|
await api('/api/user/change-password', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
old_password: oldPassword,
|
||||||
|
new_password: newPassword,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
document.getElementById('oldPassword').value = '';
|
||||||
|
document.getElementById('newPassword').value = '';
|
||||||
|
alert('Password updated successfully.');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('load', ensureSession);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
222
backend/internal/chat/hub.go
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
package chat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
writeWait = 10 * time.Second
|
||||||
|
pongWait = 60 * time.Second
|
||||||
|
pingPeriod = (pongWait * 9) / 10
|
||||||
|
maxMessageSize = 512
|
||||||
|
)
|
||||||
|
|
||||||
|
type Message struct {
|
||||||
|
Type string `json:"type"` // "chat", "system", "danmaku"
|
||||||
|
Username string `json:"username"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
RoomID string `json:"room_id"`
|
||||||
|
IsHistory bool `json:"is_history"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
Hub *Hub
|
||||||
|
Conn *websocket.Conn
|
||||||
|
Send chan []byte
|
||||||
|
RoomID string
|
||||||
|
Username string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Hub struct {
|
||||||
|
rooms map[string]map[*Client]bool
|
||||||
|
roomsHistory map[string][]Message
|
||||||
|
broadcast chan Message
|
||||||
|
register chan *Client
|
||||||
|
unregister chan *Client
|
||||||
|
mutex sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
type StatsSnapshot struct {
|
||||||
|
RoomCount int `json:"room_count"`
|
||||||
|
TotalConnectedClient int `json:"total_connected_client"`
|
||||||
|
RoomClients map[string]int `json:"room_clients"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHub() *Hub {
|
||||||
|
return &Hub{
|
||||||
|
broadcast: make(chan Message),
|
||||||
|
register: make(chan *Client),
|
||||||
|
unregister: make(chan *Client),
|
||||||
|
rooms: make(map[string]map[*Client]bool),
|
||||||
|
roomsHistory: make(map[string][]Message),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) Run() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case client := <-h.register:
|
||||||
|
h.mutex.Lock()
|
||||||
|
if h.rooms[client.RoomID] == nil {
|
||||||
|
h.rooms[client.RoomID] = make(map[*Client]bool)
|
||||||
|
}
|
||||||
|
h.rooms[client.RoomID][client] = true
|
||||||
|
|
||||||
|
// Send existing history to the newly joined client
|
||||||
|
if history, ok := h.roomsHistory[client.RoomID]; ok {
|
||||||
|
for _, msg := range history {
|
||||||
|
msg.IsHistory = true
|
||||||
|
msgBytes, _ := json.Marshal(msg)
|
||||||
|
// Use select to avoid blocking if client's send channel is full
|
||||||
|
select {
|
||||||
|
case client.Send <- msgBytes:
|
||||||
|
default:
|
||||||
|
// If send fails, we could potentially log or ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.mutex.Unlock()
|
||||||
|
|
||||||
|
case client := <-h.unregister:
|
||||||
|
h.mutex.Lock()
|
||||||
|
if rooms, ok := h.rooms[client.RoomID]; ok {
|
||||||
|
if _, ok := rooms[client]; ok {
|
||||||
|
delete(rooms, client)
|
||||||
|
close(client.Send)
|
||||||
|
// We no longer delete the room from h.rooms here if we want history to persist
|
||||||
|
// even if everyone leaves (as long as it's active in DB).
|
||||||
|
// But we should clean up if the room is empty and we want to save memory.
|
||||||
|
// However, the history is what matters.
|
||||||
|
if len(rooms) == 0 {
|
||||||
|
delete(h.rooms, client.RoomID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.mutex.Unlock()
|
||||||
|
|
||||||
|
case message := <-h.broadcast:
|
||||||
|
h.mutex.Lock()
|
||||||
|
// Only store "chat" and "danmaku" messages in history
|
||||||
|
if message.Type == "chat" || message.Type == "danmaku" {
|
||||||
|
h.roomsHistory[message.RoomID] = append(h.roomsHistory[message.RoomID], message)
|
||||||
|
// Limit history size to avoid memory leak (e.g., last 100 messages)
|
||||||
|
if len(h.roomsHistory[message.RoomID]) > 100 {
|
||||||
|
h.roomsHistory[message.RoomID] = h.roomsHistory[message.RoomID][1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clients := h.rooms[message.RoomID]
|
||||||
|
if clients != nil {
|
||||||
|
msgBytes, _ := json.Marshal(message)
|
||||||
|
for client := range clients {
|
||||||
|
select {
|
||||||
|
case client.Send <- msgBytes:
|
||||||
|
default:
|
||||||
|
close(client.Send)
|
||||||
|
delete(clients, client)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.mutex.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearRoomHistory removes history for a room, should be called when stream ends
|
||||||
|
func (h *Hub) ClearRoomHistory(roomID string) {
|
||||||
|
h.mutex.Lock()
|
||||||
|
defer h.mutex.Unlock()
|
||||||
|
delete(h.roomsHistory, roomID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) RegisterClient(c *Client) {
|
||||||
|
h.register <- c
|
||||||
|
}
|
||||||
|
|
||||||
|
// BroadcastToRoom sends a message to the broadcast channel
|
||||||
|
func (h *Hub) BroadcastToRoom(msg Message) {
|
||||||
|
h.broadcast <- msg
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ReadPump() {
|
||||||
|
defer func() {
|
||||||
|
c.Hub.unregister <- c
|
||||||
|
c.Conn.Close()
|
||||||
|
}()
|
||||||
|
c.Conn.SetReadLimit(maxMessageSize)
|
||||||
|
c.Conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||||
|
c.Conn.SetPongHandler(func(string) error { c.Conn.SetReadDeadline(time.Now().Add(pongWait)); return nil })
|
||||||
|
for {
|
||||||
|
_, message, err := c.Conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
var msg Message
|
||||||
|
if err := json.Unmarshal(message, &msg); err == nil {
|
||||||
|
msg.RoomID = c.RoomID
|
||||||
|
msg.Username = c.Username
|
||||||
|
c.Hub.broadcast <- msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) WritePump() {
|
||||||
|
ticker := time.NewTicker(pingPeriod)
|
||||||
|
defer func() {
|
||||||
|
ticker.Stop()
|
||||||
|
c.Conn.Close()
|
||||||
|
}()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case message, ok := <-c.Send:
|
||||||
|
c.Conn.SetWriteDeadline(time.Now().Add(writeWait))
|
||||||
|
if !ok {
|
||||||
|
c.Conn.WriteMessage(websocket.CloseMessage, []byte{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w, err := c.Conn.NextWriter(websocket.TextMessage)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Write(message)
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case <-ticker.C:
|
||||||
|
c.Conn.SetWriteDeadline(time.Now().Add(writeWait))
|
||||||
|
if err := c.Conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var MainHub *Hub
|
||||||
|
|
||||||
|
func InitChat() {
|
||||||
|
MainHub = NewHub()
|
||||||
|
go MainHub.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) GetStatsSnapshot() StatsSnapshot {
|
||||||
|
h.mutex.RLock()
|
||||||
|
defer h.mutex.RUnlock()
|
||||||
|
|
||||||
|
roomClients := make(map[string]int, len(h.rooms))
|
||||||
|
totalClients := 0
|
||||||
|
for roomID, clients := range h.rooms {
|
||||||
|
count := len(clients)
|
||||||
|
roomClients[roomID] = count
|
||||||
|
totalClients += count
|
||||||
|
}
|
||||||
|
|
||||||
|
return StatsSnapshot{
|
||||||
|
RoomCount: len(h.rooms),
|
||||||
|
TotalConnectedClient: totalClients,
|
||||||
|
RoomClients: roomClients,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@ import (
|
|||||||
"gorm.io/gorm/logger"
|
"gorm.io/gorm/logger"
|
||||||
|
|
||||||
"hightube/internal/model"
|
"hightube/internal/model"
|
||||||
|
"hightube/internal/monitor"
|
||||||
|
"hightube/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
var DB *gorm.DB
|
var DB *gorm.DB
|
||||||
@@ -19,10 +21,10 @@ func InitDB() {
|
|||||||
newLogger := logger.New(
|
newLogger := logger.New(
|
||||||
log.New(os.Stdout, "\r\n", log.LstdFlags), // io writer
|
log.New(os.Stdout, "\r\n", log.LstdFlags), // io writer
|
||||||
logger.Config{
|
logger.Config{
|
||||||
SlowThreshold: time.Second, // Slow SQL threshold
|
SlowThreshold: time.Second, // Slow SQL threshold
|
||||||
LogLevel: logger.Warn, // Log level
|
LogLevel: logger.Warn, // Log level
|
||||||
IgnoreRecordNotFoundError: true, // Ignore ErrRecordNotFound error for logger
|
IgnoreRecordNotFoundError: true, // Ignore ErrRecordNotFound error for logger
|
||||||
Colorful: true, // Disable color
|
Colorful: true, // Disable color
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -41,5 +43,68 @@ func InitDB() {
|
|||||||
log.Fatalf("Failed to migrate database: %v", err)
|
log.Fatalf("Failed to migrate database: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Println("Database initialized successfully.")
|
// Phase 3.5 Fix: Reset all rooms to inactive on startup using explicit map to ensure false is updated
|
||||||
|
DB.Model(&model.Room{}).Where("1 = 1").Updates(map[string]interface{}{"is_active": false})
|
||||||
|
|
||||||
|
ensureAdminUser()
|
||||||
|
|
||||||
|
monitor.Infof("Database initialized successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureAdminUser() {
|
||||||
|
adminUsername := os.Getenv("HIGHTUBE_ADMIN_USER")
|
||||||
|
if adminUsername == "" {
|
||||||
|
adminUsername = "admin"
|
||||||
|
}
|
||||||
|
|
||||||
|
adminPassword := os.Getenv("HIGHTUBE_ADMIN_PASS")
|
||||||
|
if adminPassword == "" {
|
||||||
|
adminPassword = "admin"
|
||||||
|
}
|
||||||
|
|
||||||
|
var user model.User
|
||||||
|
err := DB.Where("username = ?", adminUsername).First(&user).Error
|
||||||
|
if err == nil {
|
||||||
|
updates := map[string]interface{}{}
|
||||||
|
if user.Role != "admin" {
|
||||||
|
updates["role"] = "admin"
|
||||||
|
}
|
||||||
|
if !user.Enabled {
|
||||||
|
updates["enabled"] = true
|
||||||
|
}
|
||||||
|
if len(updates) > 0 {
|
||||||
|
DB.Model(&user).Updates(updates)
|
||||||
|
monitor.Warnf("Admin account normalized for username=%s", adminUsername)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, hashErr := utils.HashPassword(adminPassword)
|
||||||
|
if hashErr != nil {
|
||||||
|
monitor.Errorf("Failed to hash default admin password: %v", hashErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
newAdmin := model.User{
|
||||||
|
Username: adminUsername,
|
||||||
|
Password: hash,
|
||||||
|
Role: "admin",
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
if createErr := DB.Create(&newAdmin).Error; createErr != nil {
|
||||||
|
monitor.Errorf("Failed to create admin account: %v", createErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
room := model.Room{
|
||||||
|
UserID: newAdmin.ID,
|
||||||
|
Title: newAdmin.Username + "'s Live Room",
|
||||||
|
StreamKey: utils.GenerateStreamKey(),
|
||||||
|
IsActive: false,
|
||||||
|
}
|
||||||
|
if roomErr := DB.Create(&room).Error; roomErr != nil {
|
||||||
|
monitor.Warnf("Failed to create default admin room: %v", roomErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
monitor.Warnf("Default admin created for username=%s; change the password after first login", adminUsername)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,4 +9,6 @@ type User struct {
|
|||||||
gorm.Model
|
gorm.Model
|
||||||
Username string `gorm:"uniqueIndex;not null"`
|
Username string `gorm:"uniqueIndex;not null"`
|
||||||
Password string `gorm:"not null"` // Hashed password
|
Password string `gorm:"not null"` // Hashed password
|
||||||
|
Role string `gorm:"type:varchar(20);not null;default:user"`
|
||||||
|
Enabled bool `gorm:"not null;default:true"`
|
||||||
}
|
}
|
||||||
|
|||||||
7
backend/internal/monitor/disk_other.go
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package monitor
|
||||||
|
|
||||||
|
func getDiskSpaceGB() (float64, float64) {
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
38
backend/internal/monitor/disk_windows.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package monitor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
func getDiskSpaceGB() (float64, float64) {
|
||||||
|
wd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
vol := filepath.VolumeName(wd)
|
||||||
|
if vol == "" {
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
root := vol + `\\`
|
||||||
|
|
||||||
|
pathPtr, err := windows.UTF16PtrFromString(root)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
var freeBytesAvailable uint64
|
||||||
|
var totalBytes uint64
|
||||||
|
var totalFreeBytes uint64
|
||||||
|
if err := windows.GetDiskFreeSpaceEx(pathPtr, &freeBytesAvailable, &totalBytes, &totalFreeBytes); err != nil {
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const gb = 1024.0 * 1024.0 * 1024.0
|
||||||
|
return float64(totalBytes) / gb, float64(totalFreeBytes) / gb
|
||||||
|
}
|
||||||
130
backend/internal/monitor/logs.go
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
package monitor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LogEntry struct {
|
||||||
|
Time string `json:"time"`
|
||||||
|
Level string `json:"level"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type logHub struct {
|
||||||
|
mutex sync.RWMutex
|
||||||
|
entries []LogEntry
|
||||||
|
maxEntries int
|
||||||
|
subscribers map[chan LogEntry]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var hub = &logHub{
|
||||||
|
maxEntries: 1000,
|
||||||
|
subscribers: make(map[chan LogEntry]struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
func Init(maxEntries int) {
|
||||||
|
if maxEntries > 0 {
|
||||||
|
hub.maxEntries = maxEntries
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Infof(format string, args ...interface{}) {
|
||||||
|
appendEntry("info", fmt.Sprintf(format, args...))
|
||||||
|
}
|
||||||
|
|
||||||
|
func Warnf(format string, args ...interface{}) {
|
||||||
|
appendEntry("warn", fmt.Sprintf(format, args...))
|
||||||
|
}
|
||||||
|
|
||||||
|
func Errorf(format string, args ...interface{}) {
|
||||||
|
appendEntry("error", fmt.Sprintf(format, args...))
|
||||||
|
}
|
||||||
|
|
||||||
|
func Auditf(format string, args ...interface{}) {
|
||||||
|
appendEntry("audit", fmt.Sprintf(format, args...))
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendEntry(level, message string) {
|
||||||
|
entry := LogEntry{
|
||||||
|
Time: time.Now().Format(time.RFC3339),
|
||||||
|
Level: strings.ToLower(level),
|
||||||
|
Message: message,
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[%s] %s", strings.ToUpper(entry.Level), entry.Message)
|
||||||
|
|
||||||
|
hub.mutex.Lock()
|
||||||
|
hub.entries = append(hub.entries, entry)
|
||||||
|
if len(hub.entries) > hub.maxEntries {
|
||||||
|
hub.entries = hub.entries[len(hub.entries)-hub.maxEntries:]
|
||||||
|
}
|
||||||
|
|
||||||
|
for ch := range hub.subscribers {
|
||||||
|
select {
|
||||||
|
case ch <- entry:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hub.mutex.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func Subscribe() chan LogEntry {
|
||||||
|
ch := make(chan LogEntry, 100)
|
||||||
|
hub.mutex.Lock()
|
||||||
|
hub.subscribers[ch] = struct{}{}
|
||||||
|
hub.mutex.Unlock()
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
|
||||||
|
func Unsubscribe(ch chan LogEntry) {
|
||||||
|
hub.mutex.Lock()
|
||||||
|
if _, ok := hub.subscribers[ch]; ok {
|
||||||
|
delete(hub.subscribers, ch)
|
||||||
|
close(ch)
|
||||||
|
}
|
||||||
|
hub.mutex.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func Query(level, keyword string, page, pageSize int) ([]LogEntry, int) {
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if pageSize < 1 {
|
||||||
|
pageSize = 20
|
||||||
|
}
|
||||||
|
if pageSize > 200 {
|
||||||
|
pageSize = 200
|
||||||
|
}
|
||||||
|
|
||||||
|
level = strings.TrimSpace(strings.ToLower(level))
|
||||||
|
keyword = strings.TrimSpace(strings.ToLower(keyword))
|
||||||
|
|
||||||
|
hub.mutex.RLock()
|
||||||
|
defer hub.mutex.RUnlock()
|
||||||
|
|
||||||
|
filtered := make([]LogEntry, 0, len(hub.entries))
|
||||||
|
for _, e := range hub.entries {
|
||||||
|
if level != "" && e.Level != level {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if keyword != "" && !strings.Contains(strings.ToLower(e.Message), keyword) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered = append(filtered, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
total := len(filtered)
|
||||||
|
start := (page - 1) * pageSize
|
||||||
|
if start >= total {
|
||||||
|
return []LogEntry{}, total
|
||||||
|
}
|
||||||
|
end := start + pageSize
|
||||||
|
if end > total {
|
||||||
|
end = total
|
||||||
|
}
|
||||||
|
return filtered[start:end], total
|
||||||
|
}
|
||||||
55
backend/internal/monitor/metrics.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
package monitor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"runtime"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var startedAt = time.Now()
|
||||||
|
|
||||||
|
var totalRequests uint64
|
||||||
|
var totalErrors uint64
|
||||||
|
|
||||||
|
type Snapshot struct {
|
||||||
|
UptimeSeconds int64 `json:"uptime_seconds"`
|
||||||
|
Goroutines int `json:"goroutines"`
|
||||||
|
MemoryAllocMB float64 `json:"memory_alloc_mb"`
|
||||||
|
MemorySysMB float64 `json:"memory_sys_mb"`
|
||||||
|
CPUCores int `json:"cpu_cores"`
|
||||||
|
DiskTotalGB float64 `json:"disk_total_gb"`
|
||||||
|
DiskFreeGB float64 `json:"disk_free_gb"`
|
||||||
|
RequestsTotal uint64 `json:"requests_total"`
|
||||||
|
ErrorsTotal uint64 `json:"errors_total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func IncrementRequestCount() {
|
||||||
|
atomic.AddUint64(&totalRequests, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func IncrementErrorCount() {
|
||||||
|
atomic.AddUint64(&totalErrors, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetSnapshot() Snapshot {
|
||||||
|
var mem runtime.MemStats
|
||||||
|
runtime.ReadMemStats(&mem)
|
||||||
|
|
||||||
|
diskTotal, diskFree := getDiskSpaceGB()
|
||||||
|
|
||||||
|
return Snapshot{
|
||||||
|
UptimeSeconds: int64(time.Since(startedAt).Seconds()),
|
||||||
|
Goroutines: runtime.NumGoroutine(),
|
||||||
|
MemoryAllocMB: bytesToMB(mem.Alloc),
|
||||||
|
MemorySysMB: bytesToMB(mem.Sys),
|
||||||
|
CPUCores: runtime.NumCPU(),
|
||||||
|
DiskTotalGB: diskTotal,
|
||||||
|
DiskFreeGB: diskFree,
|
||||||
|
RequestsTotal: atomic.LoadUint64(&totalRequests),
|
||||||
|
ErrorsTotal: atomic.LoadUint64(&totalErrors),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func bytesToMB(v uint64) float64 {
|
||||||
|
return float64(v) / 1024.0 / 1024.0
|
||||||
|
}
|
||||||
@@ -1,18 +1,28 @@
|
|||||||
package stream
|
package stream
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/nareix/joy4/av/avutil"
|
"github.com/nareix/joy4/av/avutil"
|
||||||
"github.com/nareix/joy4/av/pubsub"
|
"github.com/nareix/joy4/av/pubsub"
|
||||||
"github.com/nareix/joy4/format"
|
"github.com/nareix/joy4/format"
|
||||||
|
"github.com/nareix/joy4/format/flv"
|
||||||
"github.com/nareix/joy4/format/rtmp"
|
"github.com/nareix/joy4/format/rtmp"
|
||||||
|
|
||||||
|
"hightube/internal/chat"
|
||||||
"hightube/internal/db"
|
"hightube/internal/db"
|
||||||
"hightube/internal/model"
|
"hightube/internal/model"
|
||||||
|
"hightube/internal/monitor"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -22,67 +32,117 @@ func init() {
|
|||||||
|
|
||||||
// RTMPServer manages all active live streams
|
// RTMPServer manages all active live streams
|
||||||
type RTMPServer struct {
|
type RTMPServer struct {
|
||||||
server *rtmp.Server
|
server *rtmp.Server
|
||||||
channels map[string]*pubsub.Queue
|
channels map[string]*pubsub.Queue
|
||||||
mutex sync.RWMutex
|
transcoders map[string][]*variantTranscoder
|
||||||
|
internalPublishKey string
|
||||||
|
mutex sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
type variantTranscoder struct {
|
||||||
|
quality string
|
||||||
|
cancel context.CancelFunc
|
||||||
|
cmd *exec.Cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
type qualityProfile struct {
|
||||||
|
scale string
|
||||||
|
videoBitrate string
|
||||||
|
audioBitrate string
|
||||||
|
}
|
||||||
|
|
||||||
|
var qualityOrder = []string{"source", "720p", "480p"}
|
||||||
|
|
||||||
|
var supportedQualities = map[string]qualityProfile{
|
||||||
|
"720p": {
|
||||||
|
scale: "1280:-2",
|
||||||
|
videoBitrate: "2500k",
|
||||||
|
audioBitrate: "128k",
|
||||||
|
},
|
||||||
|
"480p": {
|
||||||
|
scale: "854:-2",
|
||||||
|
videoBitrate: "1200k",
|
||||||
|
audioBitrate: "96k",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
type writeFlusher struct {
|
||||||
|
httpFlusher http.Flusher
|
||||||
|
io.Writer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w writeFlusher) Flush() error {
|
||||||
|
w.httpFlusher.Flush()
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRTMPServer creates and initializes a new media server
|
// NewRTMPServer creates and initializes a new media server
|
||||||
func NewRTMPServer() *RTMPServer {
|
func NewRTMPServer() *RTMPServer {
|
||||||
s := &RTMPServer{
|
s := &RTMPServer{
|
||||||
channels: make(map[string]*pubsub.Queue),
|
channels: make(map[string]*pubsub.Queue),
|
||||||
server: &rtmp.Server{},
|
transcoders: make(map[string][]*variantTranscoder),
|
||||||
|
internalPublishKey: generateInternalPublishKey(),
|
||||||
|
server: &rtmp.Server{},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Triggered when a broadcaster (e.g., OBS) starts publishing
|
// Triggered when a broadcaster (e.g., OBS) starts publishing
|
||||||
s.server.HandlePublish = func(conn *rtmp.Conn) {
|
s.server.HandlePublish = func(conn *rtmp.Conn) {
|
||||||
streamPath := conn.URL.Path // Expected format: /live/{stream_key}
|
streamPath := conn.URL.Path // Expected format: /live/{stream_key} or /variant/{room_id}/{quality}/{token}
|
||||||
fmt.Printf("[INFO] OBS is attempting to publish to: %s\n", streamPath)
|
monitor.Infof("OBS publish attempt: %s", streamPath)
|
||||||
|
|
||||||
// Extract stream key from path
|
|
||||||
parts := strings.Split(streamPath, "/")
|
parts := strings.Split(streamPath, "/")
|
||||||
if len(parts) < 3 || parts[1] != "live" {
|
if len(parts) < 3 {
|
||||||
fmt.Printf("[WARN] Invalid publish path format: %s\n", streamPath)
|
monitor.Warnf("Invalid publish path format: %s", streamPath)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
streamKey := parts[2]
|
|
||||||
|
|
||||||
// Authenticate stream key
|
roomID, channelPath, isSource, ok := s.resolvePublishPath(parts)
|
||||||
var room model.Room
|
if !ok {
|
||||||
if err := db.DB.Where("stream_key = ?", streamKey).First(&room).Error; err != nil {
|
monitor.Warnf("Invalid publish key/path: %s", streamPath)
|
||||||
fmt.Printf("[WARN] Authentication failed, invalid stream key: %s\n", streamKey)
|
return
|
||||||
return // Reject connection
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("[INFO] Stream authenticated for Room ID: %d\n", room.ID)
|
|
||||||
|
|
||||||
// 1. Get audio/video stream metadata
|
// 1. Get audio/video stream metadata
|
||||||
streams, err := conn.Streams()
|
streams, err := conn.Streams()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("[ERROR] Failed to parse stream headers: %v\n", err)
|
monitor.Errorf("Failed to parse stream headers: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Map the active stream by Room ID so viewers can use /live/{room_id}
|
monitor.Infof("Stream authenticated for room_id=%s path=%s", roomID, channelPath)
|
||||||
roomLivePath := fmt.Sprintf("/live/%d", room.ID)
|
|
||||||
|
|
||||||
s.mutex.Lock()
|
s.mutex.Lock()
|
||||||
q := pubsub.NewQueue()
|
q := pubsub.NewQueue()
|
||||||
q.WriteHeader(streams)
|
q.WriteHeader(streams)
|
||||||
s.channels[roomLivePath] = q
|
s.channels[channelPath] = q
|
||||||
s.mutex.Unlock()
|
s.mutex.Unlock()
|
||||||
|
|
||||||
// Mark room as active in DB
|
if isSource {
|
||||||
db.DB.Model(&room).Update("is_active", true)
|
roomIDUint := parseRoomID(roomID)
|
||||||
|
if roomIDUint != 0 {
|
||||||
|
db.DB.Model(&model.Room{}).Where("id = ?", roomIDUint).Updates(map[string]interface{}{"is_active": true})
|
||||||
|
}
|
||||||
|
s.startVariantTranscoders(roomID)
|
||||||
|
}
|
||||||
|
|
||||||
// 3. Cleanup on end
|
// 3. Cleanup on end
|
||||||
defer func() {
|
defer func() {
|
||||||
s.mutex.Lock()
|
s.mutex.Lock()
|
||||||
delete(s.channels, roomLivePath)
|
delete(s.channels, channelPath)
|
||||||
s.mutex.Unlock()
|
s.mutex.Unlock()
|
||||||
q.Close()
|
q.Close()
|
||||||
db.DB.Model(&room).Update("is_active", false) // Mark room as inactive
|
|
||||||
fmt.Printf("[INFO] Publishing ended for Room ID: %d\n", room.ID)
|
if isSource {
|
||||||
|
s.stopVariantTranscoders(roomID)
|
||||||
|
roomIDUint := parseRoomID(roomID)
|
||||||
|
if roomIDUint != 0 {
|
||||||
|
db.DB.Model(&model.Room{}).Where("id = ?", roomIDUint).Updates(map[string]interface{}{"is_active": false})
|
||||||
|
}
|
||||||
|
chat.MainHub.ClearRoomHistory(roomID)
|
||||||
|
monitor.Infof("Publishing ended for room_id=%s", roomID)
|
||||||
|
} else {
|
||||||
|
monitor.Infof("Variant publishing ended for room_id=%s path=%s", roomID, channelPath)
|
||||||
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// 4. Continuously copy data packets to our broadcast queue
|
// 4. Continuously copy data packets to our broadcast queue
|
||||||
@@ -92,7 +152,7 @@ func NewRTMPServer() *RTMPServer {
|
|||||||
// Triggered when a viewer (e.g., VLC) requests playback
|
// Triggered when a viewer (e.g., VLC) requests playback
|
||||||
s.server.HandlePlay = func(conn *rtmp.Conn) {
|
s.server.HandlePlay = func(conn *rtmp.Conn) {
|
||||||
streamPath := conn.URL.Path // Expected format: /live/{room_id}
|
streamPath := conn.URL.Path // Expected format: /live/{room_id}
|
||||||
fmt.Printf("[INFO] VLC is pulling stream from: %s\n", streamPath)
|
monitor.Infof("RTMP play requested: %s", streamPath)
|
||||||
|
|
||||||
// 1. Look for the requested room's data queue
|
// 1. Look for the requested room's data queue
|
||||||
s.mutex.RLock()
|
s.mutex.RLock()
|
||||||
@@ -100,7 +160,7 @@ func NewRTMPServer() *RTMPServer {
|
|||||||
s.mutex.RUnlock()
|
s.mutex.RUnlock()
|
||||||
|
|
||||||
if !ok {
|
if !ok {
|
||||||
fmt.Printf("[WARN] Stream not found or inactive: %s\n", streamPath)
|
monitor.Warnf("Stream not found or inactive: %s", streamPath)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +170,7 @@ func NewRTMPServer() *RTMPServer {
|
|||||||
conn.WriteHeader(streams)
|
conn.WriteHeader(streams)
|
||||||
|
|
||||||
// 3. Cleanup on end
|
// 3. Cleanup on end
|
||||||
defer fmt.Printf("[INFO] Playback ended: %s\n", streamPath)
|
defer monitor.Infof("Playback ended: %s", streamPath)
|
||||||
|
|
||||||
// 4. Continuously copy data packets to the viewer
|
// 4. Continuously copy data packets to the viewer
|
||||||
err := avutil.CopyPackets(conn, cursor)
|
err := avutil.CopyPackets(conn, cursor)
|
||||||
@@ -118,9 +178,9 @@ func NewRTMPServer() *RTMPServer {
|
|||||||
// 如果是客户端主动断开连接引起的错误,不将其作为严重错误打印
|
// 如果是客户端主动断开连接引起的错误,不将其作为严重错误打印
|
||||||
errStr := err.Error()
|
errStr := err.Error()
|
||||||
if strings.Contains(errStr, "broken pipe") || strings.Contains(errStr, "connection reset by peer") {
|
if strings.Contains(errStr, "broken pipe") || strings.Contains(errStr, "connection reset by peer") {
|
||||||
fmt.Printf("[INFO] Viewer disconnected normally: %s\n", streamPath)
|
monitor.Infof("Viewer disconnected: %s", streamPath)
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf("[ERROR] Error occurred during playback: %v\n", err)
|
monitor.Errorf("Playback error on %s: %v", streamPath, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -131,6 +191,210 @@ func NewRTMPServer() *RTMPServer {
|
|||||||
// Start launches the RTMP server
|
// Start launches the RTMP server
|
||||||
func (s *RTMPServer) Start(addr string) error {
|
func (s *RTMPServer) Start(addr string) error {
|
||||||
s.server.Addr = addr
|
s.server.Addr = addr
|
||||||
fmt.Printf("[INFO] RTMP Server is listening on %s...\n", addr)
|
monitor.Infof("RTMP server listening on %s", addr)
|
||||||
return s.server.ListenAndServe()
|
return s.server.ListenAndServe()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HandleHTTPFLV serves browser-compatible HTTP-FLV playback for web clients.
|
||||||
|
func (s *RTMPServer) HandleHTTPFLV(c *gin.Context) {
|
||||||
|
streamPath := fmt.Sprintf("/live/%s", c.Param("room_id"))
|
||||||
|
if quality := normalizeQuality(c.Query("quality")); quality != "" {
|
||||||
|
streamPath = fmt.Sprintf("%s/%s", streamPath, quality)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mutex.RLock()
|
||||||
|
q, ok := s.channels[streamPath]
|
||||||
|
s.mutex.RUnlock()
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Stream not found or inactive"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
flusher, ok := c.Writer.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Streaming is not supported by the current server"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Header("Content-Type", "video/x-flv")
|
||||||
|
c.Header("Transfer-Encoding", "chunked")
|
||||||
|
c.Header("Cache-Control", "no-cache")
|
||||||
|
c.Header("Connection", "keep-alive")
|
||||||
|
c.Header("Access-Control-Allow-Origin", "*")
|
||||||
|
c.Status(http.StatusOK)
|
||||||
|
flusher.Flush()
|
||||||
|
|
||||||
|
muxer := flv.NewMuxerWriteFlusher(writeFlusher{
|
||||||
|
httpFlusher: flusher,
|
||||||
|
Writer: c.Writer,
|
||||||
|
})
|
||||||
|
cursor := q.Latest()
|
||||||
|
|
||||||
|
if err := avutil.CopyFile(muxer, cursor); err != nil && err != io.EOF {
|
||||||
|
errStr := err.Error()
|
||||||
|
if strings.Contains(errStr, "broken pipe") || strings.Contains(errStr, "connection reset by peer") {
|
||||||
|
monitor.Infof("HTTP-FLV viewer disconnected: %s", streamPath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
monitor.Errorf("HTTP-FLV playback error on %s: %v", streamPath, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *RTMPServer) resolvePublishPath(parts []string) (roomID string, channelPath string, isSource bool, ok bool) {
|
||||||
|
if parts[1] == "live" && len(parts) == 3 {
|
||||||
|
var room model.Room
|
||||||
|
if err := db.DB.Where("stream_key = ?", parts[2]).First(&room).Error; err != nil {
|
||||||
|
return "", "", false, false
|
||||||
|
}
|
||||||
|
roomID = fmt.Sprintf("%d", room.ID)
|
||||||
|
channelPath = fmt.Sprintf("/live/%s", roomID)
|
||||||
|
return roomID, channelPath, true, true
|
||||||
|
}
|
||||||
|
|
||||||
|
if parts[1] == "variant" && len(parts) == 5 {
|
||||||
|
roomID = parts[2]
|
||||||
|
quality := normalizeQuality(parts[3])
|
||||||
|
token := parts[4]
|
||||||
|
if quality == "" || token != s.internalPublishKey {
|
||||||
|
return "", "", false, false
|
||||||
|
}
|
||||||
|
return roomID, fmt.Sprintf("/live/%s/%s", roomID, quality), false, true
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", "", false, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *RTMPServer) startVariantTranscoders(roomID string) {
|
||||||
|
s.stopVariantTranscoders(roomID)
|
||||||
|
|
||||||
|
launch := make([]*variantTranscoder, 0, len(supportedQualities))
|
||||||
|
for quality, profile := range supportedQualities {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
inputURL := fmt.Sprintf("rtmp://127.0.0.1:1935/live/%s", roomID)
|
||||||
|
outputURL := fmt.Sprintf("rtmp://127.0.0.1:1935/variant/%s/%s/%s", roomID, quality, s.internalPublishKey)
|
||||||
|
cmd := exec.CommandContext(
|
||||||
|
ctx,
|
||||||
|
"ffmpeg",
|
||||||
|
"-nostdin",
|
||||||
|
"-loglevel", "error",
|
||||||
|
"-i", inputURL,
|
||||||
|
"-vf", "scale="+profile.scale+":force_original_aspect_ratio=decrease",
|
||||||
|
"-c:v", "libx264",
|
||||||
|
"-preset", "veryfast",
|
||||||
|
"-tune", "zerolatency",
|
||||||
|
"-g", "48",
|
||||||
|
"-keyint_min", "48",
|
||||||
|
"-sc_threshold", "0",
|
||||||
|
"-b:v", profile.videoBitrate,
|
||||||
|
"-maxrate", profile.videoBitrate,
|
||||||
|
"-bufsize", profile.videoBitrate,
|
||||||
|
"-c:a", "aac",
|
||||||
|
"-b:a", profile.audioBitrate,
|
||||||
|
"-ar", "44100",
|
||||||
|
"-ac", "2",
|
||||||
|
"-f", "flv",
|
||||||
|
outputURL,
|
||||||
|
)
|
||||||
|
|
||||||
|
transcoder := &variantTranscoder{
|
||||||
|
quality: quality,
|
||||||
|
cancel: cancel,
|
||||||
|
cmd: cmd,
|
||||||
|
}
|
||||||
|
launch = append(launch, transcoder)
|
||||||
|
|
||||||
|
go func(roomID string, tr *variantTranscoder) {
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
monitor.Infof("Starting transcoder room_id=%s quality=%s", roomID, tr.quality)
|
||||||
|
if err := tr.cmd.Start(); err != nil {
|
||||||
|
monitor.Errorf("Failed to start transcoder room_id=%s quality=%s: %v", roomID, tr.quality, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := tr.cmd.Wait(); err != nil && ctx.Err() == nil {
|
||||||
|
monitor.Warnf("Transcoder exited room_id=%s quality=%s: %v", roomID, tr.quality, err)
|
||||||
|
}
|
||||||
|
}(roomID, transcoder)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mutex.Lock()
|
||||||
|
s.transcoders[roomID] = launch
|
||||||
|
s.mutex.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *RTMPServer) stopVariantTranscoders(roomID string) {
|
||||||
|
s.mutex.Lock()
|
||||||
|
transcoders := s.transcoders[roomID]
|
||||||
|
delete(s.transcoders, roomID)
|
||||||
|
s.mutex.Unlock()
|
||||||
|
|
||||||
|
for _, transcoder := range transcoders {
|
||||||
|
transcoder.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeQuality(value string) string {
|
||||||
|
value = strings.ToLower(strings.TrimSpace(value))
|
||||||
|
if _, ok := supportedQualities[value]; ok {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseRoomID(value string) uint {
|
||||||
|
var roomID uint
|
||||||
|
_, _ = fmt.Sscanf(value, "%d", &roomID)
|
||||||
|
return roomID
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateInternalPublishKey() string {
|
||||||
|
buf := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
return "internal_publish_fallback_key"
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *RTMPServer) ActiveStreamCount() int {
|
||||||
|
s.mutex.RLock()
|
||||||
|
defer s.mutex.RUnlock()
|
||||||
|
|
||||||
|
count := 0
|
||||||
|
for path := range s.channels {
|
||||||
|
if strings.Count(path, "/") == 2 {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *RTMPServer) AvailablePlaybackQualities(roomID string) []string {
|
||||||
|
s.mutex.RLock()
|
||||||
|
defer s.mutex.RUnlock()
|
||||||
|
|
||||||
|
basePath := fmt.Sprintf("/live/%s", roomID)
|
||||||
|
available := make([]string, 0, len(qualityOrder))
|
||||||
|
for _, quality := range qualityOrder {
|
||||||
|
streamPath := basePath
|
||||||
|
if quality != "source" {
|
||||||
|
streamPath = fmt.Sprintf("%s/%s", basePath, quality)
|
||||||
|
}
|
||||||
|
if _, ok := s.channels[streamPath]; ok {
|
||||||
|
available = append(available, quality)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return available
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *RTMPServer) ActiveStreamPaths() []string {
|
||||||
|
s.mutex.RLock()
|
||||||
|
defer s.mutex.RUnlock()
|
||||||
|
|
||||||
|
paths := make([]string, 0, len(s.channels))
|
||||||
|
for path := range s.channels {
|
||||||
|
if strings.Count(path, "/") == 2 {
|
||||||
|
paths = append(paths, path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return paths
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,26 +13,36 @@ import (
|
|||||||
// In production, load this from environment variables
|
// In production, load this from environment variables
|
||||||
var jwtKey = []byte("hightube_super_secret_key_MVP_only")
|
var jwtKey = []byte("hightube_super_secret_key_MVP_only")
|
||||||
|
|
||||||
|
type TokenClaims struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
// GenerateToken generates a JWT token for a given user ID
|
// GenerateToken generates a JWT token for a given user ID
|
||||||
func GenerateToken(userID uint) (string, error) {
|
func GenerateToken(userID uint, username, role string) (string, error) {
|
||||||
claims := &jwt.RegisteredClaims{
|
claims := &TokenClaims{
|
||||||
|
Username: username,
|
||||||
|
Role: role,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
Subject: fmt.Sprintf("%d", userID),
|
Subject: fmt.Sprintf("%d", userID),
|
||||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
return token.SignedString(jwtKey)
|
return token.SignedString(jwtKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseToken parses the JWT string and returns the user ID (Subject)
|
// ParseToken parses the JWT string and returns the user ID (Subject)
|
||||||
func ParseToken(tokenStr string) (string, error) {
|
func ParseToken(tokenStr string) (*TokenClaims, error) {
|
||||||
claims := &jwt.RegisteredClaims{}
|
claims := &TokenClaims{}
|
||||||
token, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) {
|
token, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) {
|
||||||
return jwtKey, nil
|
return jwtKey, nil
|
||||||
})
|
})
|
||||||
if err != nil || !token.Valid {
|
if err != nil || !token.Valid {
|
||||||
return "", err
|
return nil, err
|
||||||
}
|
}
|
||||||
return claims.Subject, nil
|
return claims, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// HashPassword creates a bcrypt hash of the password
|
// HashPassword creates a bcrypt hash of the password
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# frontend
|
# Hightube
|
||||||
|
|
||||||
A new Flutter project.
|
A new Flutter project.
|
||||||
|
|
||||||
|
|||||||
@@ -6,22 +6,22 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.example.frontend"
|
namespace = "com.example.hightube"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
ndkVersion = flutter.ndkVersion
|
ndkVersion = flutter.ndkVersion
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_1_8
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
jvmTarget = JavaVersion.VERSION_17.toString()
|
jvmTarget = JavaVersion.VERSION_1_8.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||||
applicationId = "com.example.frontend"
|
applicationId = "com.example.hightube"
|
||||||
// You can update the following values to match your application needs.
|
// You can update the following values to match your application needs.
|
||||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||||
minSdk = flutter.minSdkVersion
|
minSdk = flutter.minSdkVersion
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<application
|
<application
|
||||||
android:label="frontend"
|
android:label="Hightube"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/launcher_icon"
|
||||||
|
android:usesCleartextTraffic="true">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.example.frontend
|
package com.example.hightube
|
||||||
|
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
|
||||||
BIN
frontend/android/app/src/main/res/mipmap-hdpi/launcher_icon.png
Normal file
|
After Width: | Height: | Size: 948 B |
BIN
frontend/android/app/src/main/res/mipmap-mdpi/launcher_icon.png
Normal file
|
After Width: | Height: | Size: 681 B |
BIN
frontend/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png
Normal file
|
After Width: | Height: | Size: 882 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
663
frontend/android/build/reports/problems/problems-report.html
Normal file
BIN
frontend/assets/icon/app_icon.png
Normal file
|
After Width: | Height: | Size: 8.9 KiB |
@@ -1,13 +1,12 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:fvp/fvp.dart' as fvp; // 使用 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';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
// 初始化播放器引擎
|
|
||||||
fvp.registerWith();
|
fvp.registerWith();
|
||||||
|
|
||||||
runApp(
|
runApp(
|
||||||
@@ -22,16 +21,30 @@ void main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class HightubeApp extends StatelessWidget {
|
class HightubeApp extends StatelessWidget {
|
||||||
|
const HightubeApp({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final auth = context.watch<AuthProvider>();
|
final auth = context.watch<AuthProvider>();
|
||||||
|
final settings = context.watch<SettingsProvider>();
|
||||||
|
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
title: 'Hightube',
|
title: 'Hightube',
|
||||||
theme: ThemeData(
|
theme: ThemeData(
|
||||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
|
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
|
colorScheme: ColorScheme.fromSeed(
|
||||||
|
seedColor: settings.themeColor,
|
||||||
|
brightness: Brightness.light,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
darkTheme: ThemeData(
|
||||||
|
useMaterial3: true,
|
||||||
|
colorScheme: ColorScheme.fromSeed(
|
||||||
|
seedColor: settings.themeColor,
|
||||||
|
brightness: Brightness.dark,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
themeMode: settings.themeMode,
|
||||||
home: auth.isAuthenticated ? HomePage() : LoginPage(),
|
home: auth.isAuthenticated ? HomePage() : LoginPage(),
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import '../providers/settings_provider.dart';
|
|||||||
import '../services/api_service.dart';
|
import '../services/api_service.dart';
|
||||||
import 'settings_page.dart';
|
import 'settings_page.dart';
|
||||||
import 'player_page.dart';
|
import 'player_page.dart';
|
||||||
|
import 'my_stream_page.dart';
|
||||||
|
|
||||||
class HomePage extends StatefulWidget {
|
class HomePage extends StatefulWidget {
|
||||||
@override
|
@override
|
||||||
@@ -14,6 +15,79 @@ class HomePage extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _HomePageState extends State<HomePage> {
|
class _HomePageState extends State<HomePage> {
|
||||||
|
int _selectedIndex = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
bool isWide = MediaQuery.of(context).size.width > 600;
|
||||||
|
|
||||||
|
final List<Widget> _pages = [
|
||||||
|
_ExploreView(onGoLive: () => setState(() => _selectedIndex = 1)),
|
||||||
|
MyStreamPage(),
|
||||||
|
SettingsPage(),
|
||||||
|
];
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
body: Row(
|
||||||
|
children: [
|
||||||
|
if (isWide)
|
||||||
|
NavigationRail(
|
||||||
|
selectedIndex: _selectedIndex,
|
||||||
|
onDestinationSelected: (int index) =>
|
||||||
|
setState(() => _selectedIndex = index),
|
||||||
|
labelType: NavigationRailLabelType.all,
|
||||||
|
destinations: const [
|
||||||
|
NavigationRailDestination(
|
||||||
|
icon: Icon(Icons.explore),
|
||||||
|
label: Text('Explore'),
|
||||||
|
),
|
||||||
|
NavigationRailDestination(
|
||||||
|
icon: Icon(Icons.videocam),
|
||||||
|
label: Text('Console'),
|
||||||
|
),
|
||||||
|
NavigationRailDestination(
|
||||||
|
icon: Icon(Icons.settings),
|
||||||
|
label: Text('Settings'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Expanded(child: _pages[_selectedIndex]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
bottomNavigationBar: !isWide
|
||||||
|
? NavigationBar(
|
||||||
|
selectedIndex: _selectedIndex,
|
||||||
|
onDestinationSelected: (int index) =>
|
||||||
|
setState(() => _selectedIndex = index),
|
||||||
|
destinations: const [
|
||||||
|
NavigationDestination(
|
||||||
|
icon: Icon(Icons.explore),
|
||||||
|
label: 'Explore',
|
||||||
|
),
|
||||||
|
NavigationDestination(
|
||||||
|
icon: Icon(Icons.videocam),
|
||||||
|
label: 'Console',
|
||||||
|
),
|
||||||
|
NavigationDestination(
|
||||||
|
icon: Icon(Icons.settings),
|
||||||
|
label: 'Settings',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ExploreView extends StatefulWidget {
|
||||||
|
final VoidCallback onGoLive;
|
||||||
|
const _ExploreView({required this.onGoLive});
|
||||||
|
|
||||||
|
@override
|
||||||
|
_ExploreViewState createState() => _ExploreViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ExploreViewState extends State<_ExploreView> {
|
||||||
List<dynamic> _activeRooms = [];
|
List<dynamic> _activeRooms = [];
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
Timer? _refreshTimer;
|
Timer? _refreshTimer;
|
||||||
@@ -22,7 +96,6 @@ class _HomePageState extends State<HomePage> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_refreshRooms();
|
_refreshRooms();
|
||||||
// 启动自动刷新定时器 (每 10 秒自动更新列表)
|
|
||||||
_refreshTimer = Timer.periodic(Duration(seconds: 10), (timer) {
|
_refreshTimer = Timer.periodic(Duration(seconds: 10), (timer) {
|
||||||
if (mounted) _refreshRooms(isAuto: true);
|
if (mounted) _refreshRooms(isAuto: true);
|
||||||
});
|
});
|
||||||
@@ -35,8 +108,7 @@ class _HomePageState extends State<HomePage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _refreshRooms({bool isAuto = false}) async {
|
Future<void> _refreshRooms({bool isAuto = false}) async {
|
||||||
if (!isAuto) setState(() => _isLoading = true);
|
if (!isAuto && mounted) setState(() => _isLoading = true);
|
||||||
|
|
||||||
final settings = context.read<SettingsProvider>();
|
final settings = context.read<SettingsProvider>();
|
||||||
final auth = context.read<AuthProvider>();
|
final auth = context.read<AuthProvider>();
|
||||||
final api = ApiService(settings, auth.token);
|
final api = ApiService(settings, auth.token);
|
||||||
@@ -45,14 +117,13 @@ class _HomePageState extends State<HomePage> {
|
|||||||
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) {
|
if (mounted) setState(() => _activeRooms = data['active_rooms'] ?? []);
|
||||||
setState(() => _activeRooms = data['active_rooms'] ?? []);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!isAuto && mounted) {
|
if (!isAuto && mounted)
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Failed to load rooms")));
|
ScaffoldMessenger.of(
|
||||||
}
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text("Failed to load rooms")));
|
||||||
} finally {
|
} finally {
|
||||||
if (!isAuto && mounted) setState(() => _isLoading = false);
|
if (!isAuto && mounted) setState(() => _isLoading = false);
|
||||||
}
|
}
|
||||||
@@ -64,56 +135,174 @@ class _HomePageState extends State<HomePage> {
|
|||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text("Hightube Live"),
|
title: Text("Explore", style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(Icons.refresh),
|
icon: Icon(Icons.refresh),
|
||||||
tooltip: "Manual Refresh",
|
|
||||||
onPressed: () => _refreshRooms(),
|
onPressed: () => _refreshRooms(),
|
||||||
),
|
),
|
||||||
IconButton(
|
|
||||||
icon: Icon(Icons.settings),
|
|
||||||
tooltip: "Settings",
|
|
||||||
onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (_) => SettingsPage())),
|
|
||||||
),
|
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(Icons.logout),
|
icon: Icon(Icons.logout),
|
||||||
tooltip: "Logout",
|
|
||||||
onPressed: () => context.read<AuthProvider>().logout(),
|
onPressed: () => context.read<AuthProvider>().logout(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
floatingActionButton: FloatingActionButton.extended(
|
||||||
|
onPressed: widget.onGoLive,
|
||||||
|
label: Text("Go Live"),
|
||||||
|
icon: Icon(Icons.videocam),
|
||||||
|
),
|
||||||
body: RefreshIndicator(
|
body: RefreshIndicator(
|
||||||
onRefresh: _refreshRooms,
|
onRefresh: _refreshRooms,
|
||||||
child: _isLoading && _activeRooms.isEmpty
|
child: _isLoading && _activeRooms.isEmpty
|
||||||
? Center(child: CircularProgressIndicator())
|
? Center(child: CircularProgressIndicator())
|
||||||
: _activeRooms.isEmpty
|
: _activeRooms.isEmpty
|
||||||
? ListView(children: [Padding(padding: EdgeInsets.only(top: 50), child: Center(child: Text("No active rooms. Be the first!")))])
|
? ListView(
|
||||||
: ListView.builder(
|
children: [
|
||||||
itemCount: _activeRooms.length,
|
Padding(
|
||||||
itemBuilder: (context, index) {
|
padding: EdgeInsets.only(top: 100),
|
||||||
final room = _activeRooms[index];
|
child: Column(
|
||||||
return ListTile(
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
leading: CircleAvatar(child: Icon(Icons.live_tv)),
|
children: [
|
||||||
title: Text(room['title']),
|
Icon(
|
||||||
subtitle: Text("User ID: ${room['user_id']} (Streaming now)"),
|
Icons.live_tv_outlined,
|
||||||
trailing: Icon(Icons.play_circle_filled, color: Colors.blue),
|
size: 80,
|
||||||
onTap: () {
|
color: Colors.grey,
|
||||||
// 动态构建播放链接:rtmp://{host}:1935/live/{room_id}
|
),
|
||||||
final rtmpUrl = "${settings.rtmpUrl}/${room['room_id']}";
|
SizedBox(height: 16),
|
||||||
Navigator.push(
|
Text(
|
||||||
context,
|
"No active rooms. Be the first!",
|
||||||
MaterialPageRoute(
|
style: TextStyle(color: Colors.grey, fontSize: 16),
|
||||||
builder: (_) => PlayerPage(
|
),
|
||||||
title: room['title'],
|
],
|
||||||
rtmpUrl: rtmpUrl,
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: GridView.builder(
|
||||||
|
padding: EdgeInsets.all(12),
|
||||||
|
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
|
||||||
|
maxCrossAxisExtent: 400,
|
||||||
|
childAspectRatio: 1.2,
|
||||||
|
crossAxisSpacing: 12,
|
||||||
|
mainAxisSpacing: 12,
|
||||||
|
),
|
||||||
|
itemCount: _activeRooms.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final room = _activeRooms[index];
|
||||||
|
return _buildRoomCard(room, settings);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildRoomCard(dynamic room, SettingsProvider settings) {
|
||||||
|
return Card(
|
||||||
|
elevation: 4,
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () {
|
||||||
|
final playbackUrl = settings.playbackUrl(room['room_id'].toString());
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => PlayerPage(
|
||||||
|
title: room['title'],
|
||||||
|
playbackUrl: playbackUrl,
|
||||||
|
roomId: room['room_id'].toString(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
flex: 3,
|
||||||
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
color: Theme.of(context).colorScheme.primaryContainer,
|
||||||
|
child: Center(
|
||||||
|
child: Icon(
|
||||||
|
Icons.live_tv,
|
||||||
|
size: 50,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
top: 8,
|
||||||
|
left: 8,
|
||||||
|
child: Container(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.red,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.circle, size: 8, color: Colors.white),
|
||||||
|
SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
"LIVE",
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
flex: 1,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
CircleAvatar(
|
||||||
|
radius: 16,
|
||||||
|
child: Text(room['user_id'].toString().substring(0, 1)),
|
||||||
|
),
|
||||||
|
SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
room['title'],
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
"Host ID: ${room['user_id']}",
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ 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 'register_page.dart';
|
import 'register_page.dart';
|
||||||
|
import 'settings_page.dart';
|
||||||
|
|
||||||
class LoginPage extends StatefulWidget {
|
class LoginPage extends StatefulWidget {
|
||||||
|
const LoginPage({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_LoginPageState createState() => _LoginPageState();
|
_LoginPageState createState() => _LoginPageState();
|
||||||
}
|
}
|
||||||
@@ -14,9 +17,25 @@ class LoginPage extends StatefulWidget {
|
|||||||
class _LoginPageState extends State<LoginPage> {
|
class _LoginPageState extends State<LoginPage> {
|
||||||
final _usernameController = TextEditingController();
|
final _usernameController = TextEditingController();
|
||||||
final _passwordController = TextEditingController();
|
final _passwordController = TextEditingController();
|
||||||
|
final _passwordFocusNode = FocusNode();
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_usernameController.dispose();
|
||||||
|
_passwordController.dispose();
|
||||||
|
_passwordFocusNode.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
void _handleLogin() async {
|
void _handleLogin() async {
|
||||||
|
if (_usernameController.text.isEmpty || _passwordController.text.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text("Please fill in all fields")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
final settings = context.read<SettingsProvider>();
|
final settings = context.read<SettingsProvider>();
|
||||||
final auth = context.read<AuthProvider>();
|
final auth = context.read<AuthProvider>();
|
||||||
@@ -27,38 +46,140 @@ class _LoginPageState extends State<LoginPage> {
|
|||||||
_usernameController.text,
|
_usernameController.text,
|
||||||
_passwordController.text,
|
_passwordController.text,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
final data = jsonDecode(response.body);
|
final data = jsonDecode(response.body);
|
||||||
await auth.login(data['token']);
|
await auth.login(data['token'], data['username']);
|
||||||
} else {
|
} else {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
final error = jsonDecode(response.body)['error'] ?? "Login Failed";
|
final error = jsonDecode(response.body)['error'] ?? "Login Failed";
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(error)));
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text(error)));
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Network Error")));
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text("Network Error: Could not connect to server")),
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setState(() => _isLoading = false);
|
if (mounted) setState(() => _isLoading = false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: Text("Login")),
|
appBar: AppBar(
|
||||||
body: Padding(
|
actions: [
|
||||||
padding: const EdgeInsets.all(16.0),
|
IconButton(
|
||||||
child: Column(
|
icon: Icon(Icons.settings),
|
||||||
children: [
|
onPressed: () => Navigator.push(
|
||||||
TextField(controller: _usernameController, decoration: InputDecoration(labelText: "Username")),
|
context,
|
||||||
TextField(controller: _passwordController, decoration: InputDecoration(labelText: "Password"), obscureText: true),
|
MaterialPageRoute(builder: (_) => const SettingsPage()),
|
||||||
SizedBox(height: 20),
|
|
||||||
_isLoading ? CircularProgressIndicator() : ElevatedButton(onPressed: _handleLogin, child: Text("Login")),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (_) => RegisterPage())),
|
|
||||||
child: Text("Don't have an account? Register"),
|
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Center(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 32.0),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(maxWidth: 400),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
// Logo & Name
|
||||||
|
Icon(
|
||||||
|
Icons.flutter_dash,
|
||||||
|
size: 80,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
"HIGHTUBE",
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
letterSpacing: 4,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
"Open Source Live Platform",
|
||||||
|
style: TextStyle(color: Colors.grey),
|
||||||
|
),
|
||||||
|
SizedBox(height: 48),
|
||||||
|
|
||||||
|
// Fields
|
||||||
|
TextField(
|
||||||
|
controller: _usernameController,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
onSubmitted: (_) => _passwordFocusNode.requestFocus(),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: "Username",
|
||||||
|
prefixIcon: Icon(Icons.person),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 16),
|
||||||
|
TextField(
|
||||||
|
controller: _passwordController,
|
||||||
|
focusNode: _passwordFocusNode,
|
||||||
|
obscureText: true,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
onSubmitted: (_) {
|
||||||
|
if (!_isLoading) {
|
||||||
|
_handleLogin();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: "Password",
|
||||||
|
prefixIcon: Icon(Icons.lock),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 32),
|
||||||
|
|
||||||
|
// Login Button
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 50,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: _isLoading ? null : _handleLogin,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: _isLoading
|
||||||
|
? CircularProgressIndicator()
|
||||||
|
: Text(
|
||||||
|
"LOGIN",
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Register Link
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (_) => RegisterPage()),
|
||||||
|
),
|
||||||
|
child: Text("Don't have an account? Create one"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
125
frontend/lib/pages/my_stream_page.dart
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import '../providers/auth_provider.dart';
|
||||||
|
import '../providers/settings_provider.dart';
|
||||||
|
import '../services/api_service.dart';
|
||||||
|
|
||||||
|
class MyStreamPage extends StatefulWidget {
|
||||||
|
@override
|
||||||
|
_MyStreamPageState createState() => _MyStreamPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MyStreamPageState extends State<MyStreamPage> {
|
||||||
|
Map<String, dynamic>? _roomInfo;
|
||||||
|
bool _isLoading = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_fetchMyRoom();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchMyRoom() async {
|
||||||
|
setState(() => _isLoading = true);
|
||||||
|
final settings = context.read<SettingsProvider>();
|
||||||
|
final auth = context.read<AuthProvider>();
|
||||||
|
final api = ApiService(settings, auth.token);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final response = await api.getMyRoom();
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
setState(() => _roomInfo = jsonDecode(response.body));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Failed to fetch room info")));
|
||||||
|
} finally {
|
||||||
|
setState(() => _isLoading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final settings = context.watch<SettingsProvider>();
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: Text("My Stream Console")),
|
||||||
|
body: _isLoading
|
||||||
|
? Center(child: CircularProgressIndicator())
|
||||||
|
: _roomInfo == null
|
||||||
|
? Center(child: Text("No room info found."))
|
||||||
|
: SingleChildScrollView(
|
||||||
|
padding: EdgeInsets.all(20),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildInfoCard(
|
||||||
|
title: "Room Title",
|
||||||
|
value: _roomInfo!['title'],
|
||||||
|
icon: Icons.edit,
|
||||||
|
onTap: () {
|
||||||
|
// TODO: Implement title update API later
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Title editing coming soon!")));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
SizedBox(height: 20),
|
||||||
|
_buildInfoCard(
|
||||||
|
title: "RTMP Server URL",
|
||||||
|
value: settings.rtmpUrl,
|
||||||
|
icon: Icons.copy,
|
||||||
|
onTap: () {
|
||||||
|
Clipboard.setData(ClipboardData(text: settings.rtmpUrl));
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Server URL copied to clipboard")));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
SizedBox(height: 20),
|
||||||
|
_buildInfoCard(
|
||||||
|
title: "Stream Key (Keep Secret!)",
|
||||||
|
value: _roomInfo!['stream_key'],
|
||||||
|
icon: Icons.copy,
|
||||||
|
isSecret: true,
|
||||||
|
onTap: () {
|
||||||
|
Clipboard.setData(ClipboardData(text: _roomInfo!['stream_key']));
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Stream Key copied to clipboard")));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
SizedBox(height: 30),
|
||||||
|
Center(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.info_outline, color: Colors.grey),
|
||||||
|
SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
"Use OBS or other tools to stream to this address.",
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(color: Colors.grey, fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildInfoCard({
|
||||||
|
required String title,
|
||||||
|
required String value,
|
||||||
|
required IconData icon,
|
||||||
|
bool isSecret = false,
|
||||||
|
VoidCallback? onTap,
|
||||||
|
}) {
|
||||||
|
return Card(
|
||||||
|
child: ListTile(
|
||||||
|
title: Text(title, style: TextStyle(fontSize: 12, color: Colors.grey)),
|
||||||
|
subtitle: Text(
|
||||||
|
isSecret ? "••••••••••••••••" : value,
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||||
|
),
|
||||||
|
trailing: IconButton(icon: Icon(icon), onPressed: onTap),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,82 +1,749 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
import 'package:video_player/video_player.dart';
|
import 'package:video_player/video_player.dart';
|
||||||
|
|
||||||
|
import '../providers/auth_provider.dart';
|
||||||
|
import '../providers/settings_provider.dart';
|
||||||
|
import '../services/api_service.dart';
|
||||||
|
import '../services/chat_service.dart';
|
||||||
|
import '../widgets/web_stream_player.dart';
|
||||||
|
|
||||||
class PlayerPage extends StatefulWidget {
|
class PlayerPage extends StatefulWidget {
|
||||||
final String title;
|
final String title;
|
||||||
final String rtmpUrl;
|
final String playbackUrl;
|
||||||
|
final String roomId;
|
||||||
|
|
||||||
const PlayerPage({Key? key, required this.title, required this.rtmpUrl}) : super(key: key);
|
const PlayerPage({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
required this.playbackUrl,
|
||||||
|
required this.roomId,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_PlayerPageState createState() => _PlayerPageState();
|
State<PlayerPage> createState() => _PlayerPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PlayerPageState extends State<PlayerPage> {
|
class _PlayerPageState extends State<PlayerPage> {
|
||||||
late VideoPlayerController _controller;
|
VideoPlayerController? _controller;
|
||||||
|
final ChatService _chatService = ChatService();
|
||||||
|
final TextEditingController _msgController = TextEditingController();
|
||||||
|
final List<ChatMessage> _messages = [];
|
||||||
|
final List<_DanmakuEntry> _danmakus = [];
|
||||||
|
|
||||||
bool _isError = false;
|
bool _isError = false;
|
||||||
String? _errorMessage;
|
String? _errorMessage;
|
||||||
|
bool _showDanmaku = true;
|
||||||
|
bool _isRefreshing = false;
|
||||||
|
bool _isFullscreen = false;
|
||||||
|
bool _controlsVisible = true;
|
||||||
|
int _playerVersion = 0;
|
||||||
|
String _selectedResolution = 'Source';
|
||||||
|
List<String> _availableResolutions = const ['Source'];
|
||||||
|
Timer? _controlsHideTimer;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_initializePlayer();
|
_loadPlaybackOptions();
|
||||||
|
if (!kIsWeb) {
|
||||||
|
_initializePlayer();
|
||||||
|
}
|
||||||
|
_initializeChat();
|
||||||
|
_showControls();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _initializePlayer() async {
|
Future<void> _initializePlayer() async {
|
||||||
print("[INFO] Playing stream: ${widget.rtmpUrl}");
|
final playbackUrl = _currentPlaybackUrl();
|
||||||
_controller = VideoPlayerController.networkUrl(Uri.parse(widget.rtmpUrl));
|
_controller = VideoPlayerController.networkUrl(Uri.parse(playbackUrl));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await _controller.initialize();
|
await _controller!.initialize();
|
||||||
_controller.play();
|
_controller!.play();
|
||||||
setState(() {}); // 更新状态以渲染画面
|
if (mounted) setState(() {});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print("[ERROR] Player initialization failed: $e");
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isError = true;
|
||||||
|
_errorMessage = e.toString();
|
||||||
|
_isRefreshing = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isError = true;
|
_isError = false;
|
||||||
_errorMessage = e.toString();
|
_errorMessage = null;
|
||||||
|
_isRefreshing = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _currentPlaybackUrl() {
|
||||||
|
final settings = context.read<SettingsProvider>();
|
||||||
|
final quality = _selectedResolution == 'Source'
|
||||||
|
? null
|
||||||
|
: _selectedResolution.toLowerCase();
|
||||||
|
return settings.playbackUrl(widget.roomId, quality: quality);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadPlaybackOptions() async {
|
||||||
|
final settings = context.read<SettingsProvider>();
|
||||||
|
final auth = context.read<AuthProvider>();
|
||||||
|
final api = ApiService(settings, auth.token);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final response = await api.getPlaybackOptions(widget.roomId);
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
final rawQualities =
|
||||||
|
(data['qualities'] as List<dynamic>? ?? const ['source'])
|
||||||
|
.map((item) => item.toString().trim().toLowerCase())
|
||||||
|
.where((item) => item.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
final normalized = <String>['Source'];
|
||||||
|
for (final quality in rawQualities) {
|
||||||
|
if (quality == 'source') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
normalized.add(quality);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_availableResolutions = normalized.toSet().toList();
|
||||||
|
if (!_availableResolutions.contains(_selectedResolution)) {
|
||||||
|
_selectedResolution = 'Source';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
// Keep source-only playback when the capability probe fails.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _initializeChat() {
|
||||||
|
final settings = context.read<SettingsProvider>();
|
||||||
|
final auth = context.read<AuthProvider>();
|
||||||
|
|
||||||
|
// 使用真实用户名建立连接
|
||||||
|
final currentUsername = auth.username ?? "Guest_${widget.roomId}";
|
||||||
|
_chatService.connect(settings.baseUrl, widget.roomId, currentUsername);
|
||||||
|
|
||||||
|
_chatService.messages.listen((msg) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_messages.insert(0, msg);
|
||||||
|
if (!msg.isHistory && (msg.type == "chat" || msg.type == "danmaku")) {
|
||||||
|
if (_showDanmaku) {
|
||||||
|
_addDanmaku(msg.content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _addDanmaku(String text) {
|
||||||
|
final key = UniqueKey();
|
||||||
|
final lane = DateTime.now().millisecondsSinceEpoch % 8;
|
||||||
|
final topFactor = 0.06 + lane * 0.045;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_danmakus.add(
|
||||||
|
_DanmakuEntry(
|
||||||
|
key: key,
|
||||||
|
text: text,
|
||||||
|
topFactor: topFactor,
|
||||||
|
onFinished: () {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _danmakus.removeWhere((w) => w.key == key));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _sendMsg() {
|
||||||
|
if (_msgController.text.isNotEmpty) {
|
||||||
|
final auth = context.read<AuthProvider>();
|
||||||
|
_chatService.sendMessage(
|
||||||
|
_msgController.text,
|
||||||
|
auth.username ?? "Anonymous",
|
||||||
|
widget.roomId,
|
||||||
|
);
|
||||||
|
_msgController.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _refreshPlayer() async {
|
||||||
|
if (_isRefreshing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _loadPlaybackOptions();
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isRefreshing = true;
|
||||||
|
_isError = false;
|
||||||
|
_errorMessage = null;
|
||||||
|
_danmakus.clear();
|
||||||
|
_playerVersion++;
|
||||||
|
});
|
||||||
|
_showControls();
|
||||||
|
|
||||||
|
if (kIsWeb) {
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 150));
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _isRefreshing = false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_controller != null) {
|
||||||
|
await _controller!.dispose();
|
||||||
|
}
|
||||||
|
_controller = null;
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
await _initializePlayer();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _toggleFullscreen() async {
|
||||||
|
final nextValue = !_isFullscreen;
|
||||||
|
if (!kIsWeb) {
|
||||||
|
await SystemChrome.setEnabledSystemUIMode(
|
||||||
|
nextValue ? SystemUiMode.immersiveSticky : SystemUiMode.edgeToEdge,
|
||||||
|
);
|
||||||
|
await SystemChrome.setPreferredOrientations(
|
||||||
|
nextValue
|
||||||
|
? const [
|
||||||
|
DeviceOrientation.landscapeLeft,
|
||||||
|
DeviceOrientation.landscapeRight,
|
||||||
|
]
|
||||||
|
: DeviceOrientation.values,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _isFullscreen = nextValue);
|
||||||
|
}
|
||||||
|
_showControls();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleDanmaku() {
|
||||||
|
setState(() {
|
||||||
|
_showDanmaku = !_showDanmaku;
|
||||||
|
if (!_showDanmaku) {
|
||||||
|
_danmakus.clear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_showControls();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showControls() {
|
||||||
|
_controlsHideTimer?.cancel();
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _controlsVisible = true);
|
||||||
|
}
|
||||||
|
_controlsHideTimer = Timer(const Duration(seconds: 3), () {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _controlsVisible = false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleControlsVisibility() {
|
||||||
|
if (_controlsVisible) {
|
||||||
|
_controlsHideTimer?.cancel();
|
||||||
|
setState(() => _controlsVisible = false);
|
||||||
|
} else {
|
||||||
|
_showControls();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _selectResolution() async {
|
||||||
|
_showControls();
|
||||||
|
await _loadPlaybackOptions();
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final nextResolution = await showModalBottomSheet<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) {
|
||||||
|
const options = ['Source', '720p', '480p'];
|
||||||
|
final available = _availableResolutions.toSet();
|
||||||
|
return SafeArea(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
ListTile(
|
||||||
|
title: Text('Playback Resolution'),
|
||||||
|
subtitle: Text(
|
||||||
|
available.length > 1
|
||||||
|
? 'Select an available transcoded stream.'
|
||||||
|
: 'Only the source stream is available right now.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
...options.map((option) {
|
||||||
|
final enabled = available.contains(option);
|
||||||
|
return ListTile(
|
||||||
|
enabled: enabled,
|
||||||
|
leading: Icon(
|
||||||
|
option == _selectedResolution
|
||||||
|
? Icons.radio_button_checked
|
||||||
|
: Icons.radio_button_off,
|
||||||
|
),
|
||||||
|
title: Text(option),
|
||||||
|
subtitle: enabled
|
||||||
|
? const Text('Available now')
|
||||||
|
: const Text('Waiting for backend transcoding output'),
|
||||||
|
onTap: enabled ? () => Navigator.pop(context, option) : null,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (nextResolution == null || nextResolution == _selectedResolution) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() => _selectedResolution = nextResolution);
|
||||||
|
await _refreshPlayer();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_controller.dispose();
|
if (!kIsWeb && _isFullscreen) {
|
||||||
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||||
|
SystemChrome.setPreferredOrientations(DeviceOrientation.values);
|
||||||
|
}
|
||||||
|
_controlsHideTimer?.cancel();
|
||||||
|
_controller?.dispose();
|
||||||
|
_chatService.dispose();
|
||||||
|
_msgController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
bool isWide = MediaQuery.of(context).size.width > 900;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Colors.black,
|
backgroundColor: _isFullscreen
|
||||||
appBar: AppBar(
|
? Colors.black
|
||||||
title: Text(widget.title),
|
: Theme.of(context).colorScheme.surface,
|
||||||
backgroundColor: Colors.transparent,
|
appBar: _isFullscreen ? null : AppBar(title: Text(widget.title)),
|
||||||
foregroundColor: Colors.white,
|
body: _isFullscreen
|
||||||
),
|
? _buildFullscreenLayout()
|
||||||
body: Center(
|
: isWide
|
||||||
child: _isError
|
? _buildWideLayout()
|
||||||
? Column(
|
: _buildMobileLayout(),
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
);
|
||||||
children: [
|
}
|
||||||
Icon(Icons.error_outline, color: Colors.red, size: 60),
|
|
||||||
SizedBox(height: 16),
|
Widget _buildFullscreenLayout() {
|
||||||
Text(
|
return SizedBox.expand(child: _buildVideoPanel());
|
||||||
"Failed to load stream.",
|
}
|
||||||
style: TextStyle(color: Colors.white, fontSize: 18),
|
|
||||||
),
|
// 宽屏布局:左右分栏
|
||||||
SizedBox(height: 8),
|
Widget _buildWideLayout() {
|
||||||
Text(_errorMessage ?? "Unknown error", style: TextStyle(color: Colors.grey)),
|
return Row(
|
||||||
TextButton(onPressed: () => Navigator.pop(context), child: Text("Go Back")),
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
],
|
children: [
|
||||||
)
|
// 左侧视频区 (占比 75%)
|
||||||
: _controller.value.isInitialized
|
Expanded(flex: 3, child: _buildVideoPanel()),
|
||||||
? AspectRatio(
|
// 右侧聊天区 (占比 25%)
|
||||||
aspectRatio: _controller.value.aspectRatio,
|
Container(
|
||||||
child: VideoPlayer(_controller),
|
width: 350,
|
||||||
)
|
decoration: BoxDecoration(
|
||||||
: CircularProgressIndicator(color: Colors.white),
|
border: Border(
|
||||||
|
left: BorderSide(color: Theme.of(context).dividerColor),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: _buildChatSection(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移动端布局:上下堆叠
|
||||||
|
Widget _buildMobileLayout() {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
height: 310,
|
||||||
|
width: double.infinity,
|
||||||
|
child: _buildVideoPanel(),
|
||||||
|
),
|
||||||
|
Expanded(child: _buildChatSection()),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildVideoPanel() {
|
||||||
|
return MouseRegion(
|
||||||
|
onHover: (_) => _showControls(),
|
||||||
|
onEnter: (_) => _showControls(),
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTap: _toggleControlsVisibility,
|
||||||
|
onDoubleTap: _toggleFullscreen,
|
||||||
|
child: Container(
|
||||||
|
color: Colors.black,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Positioned.fill(child: _buildVideoWithDanmaku()),
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
child: _buildPlaybackControls(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildVideoWithDanmaku() {
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
Center(
|
||||||
|
child: _isError
|
||||||
|
? Text(
|
||||||
|
"Error: $_errorMessage",
|
||||||
|
style: TextStyle(color: Colors.white),
|
||||||
|
)
|
||||||
|
: kIsWeb
|
||||||
|
? WebStreamPlayer(
|
||||||
|
key: ValueKey('web-player-$_playerVersion'),
|
||||||
|
streamUrl: _currentPlaybackUrl(),
|
||||||
|
)
|
||||||
|
: _controller != null && _controller!.value.isInitialized
|
||||||
|
? AspectRatio(
|
||||||
|
aspectRatio: _controller!.value.aspectRatio,
|
||||||
|
child: VideoPlayer(_controller!),
|
||||||
|
)
|
||||||
|
: CircularProgressIndicator(),
|
||||||
|
),
|
||||||
|
if (_showDanmaku)
|
||||||
|
ClipRect(
|
||||||
|
child: Stack(
|
||||||
|
children: _danmakus
|
||||||
|
.map(
|
||||||
|
(item) => _DanmakuItem(
|
||||||
|
key: item.key,
|
||||||
|
text: item.text,
|
||||||
|
topFactor: item.topFactor,
|
||||||
|
containerWidth: constraints.maxWidth,
|
||||||
|
containerHeight: constraints.maxHeight,
|
||||||
|
onFinished: item.onFinished,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_isRefreshing)
|
||||||
|
const Positioned(
|
||||||
|
top: 16,
|
||||||
|
right: 16,
|
||||||
|
child: SizedBox(
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _usernameColor(String username, String type) {
|
||||||
|
if (type == "system") {
|
||||||
|
return Colors.blue;
|
||||||
|
}
|
||||||
|
final normalized = username.trim().toLowerCase();
|
||||||
|
var hash = 5381;
|
||||||
|
for (final codeUnit in normalized.codeUnits) {
|
||||||
|
hash = ((hash << 5) + hash) ^ codeUnit;
|
||||||
|
}
|
||||||
|
final hue = (hash.abs() % 360).toDouble();
|
||||||
|
return HSLColor.fromAHSL(1, hue, 0.72, 0.68).toColor();
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMessageItem(ChatMessage message) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
||||||
|
child: RichText(
|
||||||
|
text: TextSpan(
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).textTheme.bodyMedium?.color,
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
TextSpan(
|
||||||
|
text: "${message.username}: ",
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: _usernameColor(message.username, message.type),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextSpan(text: message.content),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPlaybackControls() {
|
||||||
|
return IgnorePointer(
|
||||||
|
ignoring: !_controlsVisible,
|
||||||
|
child: AnimatedOpacity(
|
||||||
|
opacity: _controlsVisible ? 1 : 0,
|
||||||
|
duration: const Duration(milliseconds: 220),
|
||||||
|
child: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.bottomCenter,
|
||||||
|
end: Alignment.topCenter,
|
||||||
|
colors: [
|
||||||
|
Colors.black.withValues(alpha: 0.9),
|
||||||
|
Colors.black.withValues(alpha: 0.55),
|
||||||
|
Colors.transparent,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: SafeArea(
|
||||||
|
top: false,
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.bottomCenter,
|
||||||
|
child: Wrap(
|
||||||
|
spacing: 10,
|
||||||
|
runSpacing: 10,
|
||||||
|
alignment: WrapAlignment.center,
|
||||||
|
children: [
|
||||||
|
_buildControlButton(
|
||||||
|
icon: Icons.refresh,
|
||||||
|
label: "Refresh",
|
||||||
|
onPressed: _refreshPlayer,
|
||||||
|
),
|
||||||
|
_buildControlButton(
|
||||||
|
icon: _showDanmaku ? Icons.subtitles : Icons.subtitles_off,
|
||||||
|
label: _showDanmaku ? "Danmaku On" : "Danmaku Off",
|
||||||
|
onPressed: _toggleDanmaku,
|
||||||
|
),
|
||||||
|
_buildControlButton(
|
||||||
|
icon: _isFullscreen
|
||||||
|
? Icons.fullscreen_exit
|
||||||
|
: Icons.fullscreen,
|
||||||
|
label: _isFullscreen ? "Exit Fullscreen" : "Fullscreen",
|
||||||
|
onPressed: _toggleFullscreen,
|
||||||
|
),
|
||||||
|
_buildControlButton(
|
||||||
|
icon: Icons.high_quality,
|
||||||
|
label: _selectedResolution,
|
||||||
|
onPressed: _selectResolution,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildControlButton({
|
||||||
|
required IconData icon,
|
||||||
|
required String label,
|
||||||
|
required FutureOr<void> Function() onPressed,
|
||||||
|
}) {
|
||||||
|
return FilledButton.tonalIcon(
|
||||||
|
onPressed: () async {
|
||||||
|
_showControls();
|
||||||
|
await onPressed();
|
||||||
|
},
|
||||||
|
icon: Icon(icon, size: 18),
|
||||||
|
label: Text(label),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
backgroundColor: Colors.white.withValues(alpha: 0.12),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 抽离聊天区域组件
|
||||||
|
Widget _buildChatSection() {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.chat_bubble_outline, size: 16),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text("Live Chat", style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: ListView.builder(
|
||||||
|
reverse: true,
|
||||||
|
padding: EdgeInsets.all(8),
|
||||||
|
itemCount: _messages.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final m = _messages[index];
|
||||||
|
return _buildMessageItem(m);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Divider(height: 1),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(8.0),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _msgController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: "Send a message...",
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
contentPadding: EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onSubmitted: (_) => _sendMsg(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
Icons.send,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
onPressed: _sendMsg,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DanmakuEntry {
|
||||||
|
final Key key;
|
||||||
|
final String text;
|
||||||
|
final double topFactor;
|
||||||
|
final VoidCallback onFinished;
|
||||||
|
|
||||||
|
const _DanmakuEntry({
|
||||||
|
required this.key,
|
||||||
|
required this.text,
|
||||||
|
required this.topFactor,
|
||||||
|
required this.onFinished,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DanmakuItem extends StatefulWidget {
|
||||||
|
final String text;
|
||||||
|
final double topFactor;
|
||||||
|
final double containerWidth;
|
||||||
|
final double containerHeight;
|
||||||
|
final VoidCallback onFinished;
|
||||||
|
|
||||||
|
const _DanmakuItem({
|
||||||
|
super.key,
|
||||||
|
required this.text,
|
||||||
|
required this.topFactor,
|
||||||
|
required this.containerWidth,
|
||||||
|
required this.containerHeight,
|
||||||
|
required this.onFinished,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
__DanmakuItemState createState() => __DanmakuItemState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class __DanmakuItemState extends State<_DanmakuItem>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late AnimationController _animationController;
|
||||||
|
late Animation<double> _animation;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_animationController = AnimationController(
|
||||||
|
duration: const Duration(seconds: 10),
|
||||||
|
vsync: this,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 使用相对位置:从右向左
|
||||||
|
_animation = Tween<double>(
|
||||||
|
begin: 1.0,
|
||||||
|
end: -0.5,
|
||||||
|
).animate(_animationController);
|
||||||
|
|
||||||
|
_animationController.forward().then((_) => widget.onFinished());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_animationController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: _animation,
|
||||||
|
builder: (context, child) {
|
||||||
|
return Positioned(
|
||||||
|
top: widget.containerHeight * widget.topFactor,
|
||||||
|
left: widget.containerWidth * _animation.value,
|
||||||
|
child: Text(
|
||||||
|
widget.text,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 18,
|
||||||
|
shadows: [
|
||||||
|
Shadow(
|
||||||
|
blurRadius: 4,
|
||||||
|
color: Colors.black,
|
||||||
|
offset: Offset(1, 1),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,18 +15,19 @@ class _RegisterPageState extends State<RegisterPage> {
|
|||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
|
|
||||||
void _handleRegister() async {
|
void _handleRegister() async {
|
||||||
|
if (_usernameController.text.isEmpty || _passwordController.text.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Please fill in all fields")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
final settings = context.read<SettingsProvider>();
|
final settings = context.read<SettingsProvider>();
|
||||||
final api = ApiService(settings, null);
|
final api = ApiService(settings, null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final response = await api.register(
|
final response = await api.register(_usernameController.text, _passwordController.text);
|
||||||
_usernameController.text,
|
|
||||||
_passwordController.text,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.statusCode == 201) {
|
if (response.statusCode == 201) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Registered! Please login.")));
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Account created! Please login.")));
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
} else {
|
} else {
|
||||||
final error = jsonDecode(response.body)['error'] ?? "Registration Failed";
|
final error = jsonDecode(response.body)['error'] ?? "Registration Failed";
|
||||||
@@ -35,23 +36,67 @@ class _RegisterPageState extends State<RegisterPage> {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Network Error")));
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Network Error")));
|
||||||
} finally {
|
} finally {
|
||||||
setState(() => _isLoading = false);
|
if (mounted) setState(() => _isLoading = false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: Text("Register")),
|
appBar: AppBar(title: Text("Create Account")),
|
||||||
body: Padding(
|
body: Center(
|
||||||
padding: const EdgeInsets.all(16.0),
|
child: SingleChildScrollView(
|
||||||
child: Column(
|
padding: const EdgeInsets.symmetric(horizontal: 32.0),
|
||||||
children: [
|
child: ConstrainedBox(
|
||||||
TextField(controller: _usernameController, decoration: InputDecoration(labelText: "Username")),
|
constraints: BoxConstraints(maxWidth: 400),
|
||||||
TextField(controller: _passwordController, decoration: InputDecoration(labelText: "Password"), obscureText: true),
|
child: Column(
|
||||||
SizedBox(height: 20),
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
_isLoading ? CircularProgressIndicator() : ElevatedButton(onPressed: _handleRegister, child: Text("Register")),
|
children: [
|
||||||
],
|
Icon(Icons.person_add_outlined, size: 64, color: Theme.of(context).colorScheme.primary),
|
||||||
|
SizedBox(height: 24),
|
||||||
|
Text(
|
||||||
|
"Join Hightube",
|
||||||
|
style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
SizedBox(height: 48),
|
||||||
|
TextField(
|
||||||
|
controller: _usernameController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: "Desired Username",
|
||||||
|
prefixIcon: Icon(Icons.person),
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 16),
|
||||||
|
TextField(
|
||||||
|
controller: _passwordController,
|
||||||
|
obscureText: true,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: "Password",
|
||||||
|
prefixIcon: Icon(Icons.lock),
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 32),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 50,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: _isLoading ? null : _handleRegister,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
),
|
||||||
|
child: _isLoading ? CircularProgressIndicator() : Text("REGISTER", style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 16),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: Text("Already have an account? Login here"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,48 +1,385 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../providers/auth_provider.dart';
|
||||||
import '../providers/settings_provider.dart';
|
import '../providers/settings_provider.dart';
|
||||||
|
import '../services/api_service.dart';
|
||||||
|
|
||||||
class SettingsPage extends StatefulWidget {
|
class SettingsPage extends StatefulWidget {
|
||||||
|
const SettingsPage({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_SettingsPageState createState() => _SettingsPageState();
|
_SettingsPageState createState() => _SettingsPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SettingsPageState extends State<SettingsPage> {
|
class _SettingsPageState extends State<SettingsPage> {
|
||||||
final _urlController = TextEditingController();
|
late TextEditingController _urlController;
|
||||||
|
final TextEditingController _oldPasswordController = TextEditingController();
|
||||||
|
final TextEditingController _newPasswordController = TextEditingController();
|
||||||
|
|
||||||
|
final List<Color> _availableColors = [
|
||||||
|
Colors.blue,
|
||||||
|
Colors.deepPurple,
|
||||||
|
Colors.red,
|
||||||
|
Colors.green,
|
||||||
|
Colors.orange,
|
||||||
|
Colors.teal,
|
||||||
|
Colors.pink,
|
||||||
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_urlController.text = context.read<SettingsProvider>().baseUrl;
|
_urlController = TextEditingController(
|
||||||
|
text: context.read<SettingsProvider>().baseUrl,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_urlController.dispose();
|
||||||
|
_oldPasswordController.dispose();
|
||||||
|
_newPasswordController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleChangePassword() async {
|
||||||
|
final settings = context.read<SettingsProvider>();
|
||||||
|
final auth = context.read<AuthProvider>();
|
||||||
|
final api = ApiService(settings, auth.token);
|
||||||
|
|
||||||
|
if (_oldPasswordController.text.isEmpty ||
|
||||||
|
_newPasswordController.text.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text("Please fill in both password fields")),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final resp = await api.changePassword(
|
||||||
|
_oldPasswordController.text,
|
||||||
|
_newPasswordController.text,
|
||||||
|
);
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final data = jsonDecode(resp.body);
|
||||||
|
if (resp.statusCode == 200) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text("Password updated successfully")),
|
||||||
|
);
|
||||||
|
_oldPasswordController.clear();
|
||||||
|
_newPasswordController.clear();
|
||||||
|
} else {
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text("Error: ${data['error']}")));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text("Failed to connect to server")));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final auth = context.watch<AuthProvider>();
|
||||||
|
final settings = context.watch<SettingsProvider>();
|
||||||
|
final isAuthenticated = auth.isAuthenticated;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: Text("Server Settings")),
|
appBar: AppBar(
|
||||||
body: Padding(
|
title: Text("Settings", style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
padding: const EdgeInsets.all(16.0),
|
centerTitle: true,
|
||||||
|
),
|
||||||
|
body: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(24.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
if (isAuthenticated) ...[
|
||||||
|
_buildProfileSection(auth),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
],
|
||||||
|
_buildSectionTitle("Network Configuration"),
|
||||||
|
const SizedBox(height: 16),
|
||||||
TextField(
|
TextField(
|
||||||
controller: _urlController,
|
controller: _urlController,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: "Backend URL (e.g., http://127.0.0.1:8080)",
|
labelText: "Backend Server URL",
|
||||||
|
hintText: "http://127.0.0.1:8080",
|
||||||
|
prefixIcon: Icon(Icons.lan),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(height: 20),
|
const SizedBox(height: 12),
|
||||||
ElevatedButton(
|
SizedBox(
|
||||||
onPressed: () {
|
width: double.infinity,
|
||||||
context.read<SettingsProvider>().setBaseUrl(_urlController.text);
|
child: ElevatedButton.icon(
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
onPressed: () {
|
||||||
SnackBar(content: Text("Server URL Updated")),
|
context.read<SettingsProvider>().setBaseUrl(
|
||||||
);
|
_urlController.text,
|
||||||
},
|
);
|
||||||
child: Text("Save Settings"),
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text("Server URL Updated"),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
icon: Icon(Icons.save),
|
||||||
|
label: Text("Save Network Settings"),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.primaryContainer,
|
||||||
|
foregroundColor: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.onPrimaryContainer,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
|
_buildSectionTitle("Theme Customization"),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
"Appearance Mode",
|
||||||
|
style: Theme.of(context).textTheme.labelLarge,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SegmentedButton<ThemeMode>(
|
||||||
|
segments: const [
|
||||||
|
ButtonSegment<ThemeMode>(
|
||||||
|
value: ThemeMode.system,
|
||||||
|
label: Text("System"),
|
||||||
|
icon: Icon(Icons.brightness_auto),
|
||||||
|
),
|
||||||
|
ButtonSegment<ThemeMode>(
|
||||||
|
value: ThemeMode.light,
|
||||||
|
label: Text("Light"),
|
||||||
|
icon: Icon(Icons.light_mode),
|
||||||
|
),
|
||||||
|
ButtonSegment<ThemeMode>(
|
||||||
|
value: ThemeMode.dark,
|
||||||
|
label: Text("Dark"),
|
||||||
|
icon: Icon(Icons.dark_mode),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
selected: {settings.themeMode},
|
||||||
|
onSelectionChanged: (selection) {
|
||||||
|
settings.setThemeMode(selection.first);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Text("Accent Color", style: Theme.of(context).textTheme.labelLarge),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Wrap(
|
||||||
|
spacing: 12,
|
||||||
|
runSpacing: 12,
|
||||||
|
children: _availableColors.map((color) {
|
||||||
|
final isSelected =
|
||||||
|
settings.themeColor.toARGB32() == color.toARGB32();
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => settings.setThemeColor(color),
|
||||||
|
child: Container(
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: isSelected
|
||||||
|
? Border.all(
|
||||||
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
|
width: 3,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black26,
|
||||||
|
blurRadius: 4,
|
||||||
|
offset: Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: isSelected
|
||||||
|
? Icon(Icons.check, color: Colors.white)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
if (isAuthenticated) ...[
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
_buildSectionTitle("Security"),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextField(
|
||||||
|
controller: _oldPasswordController,
|
||||||
|
obscureText: true,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: "Old Password",
|
||||||
|
prefixIcon: const Icon(Icons.lock_outline),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: _newPasswordController,
|
||||||
|
obscureText: true,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: "New Password",
|
||||||
|
prefixIcon: const Icon(Icons.lock_reset),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: _handleChangePassword,
|
||||||
|
icon: const Icon(Icons.update),
|
||||||
|
label: const Text("Change Password"),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton.tonalIcon(
|
||||||
|
onPressed: auth.logout,
|
||||||
|
icon: const Icon(Icons.logout),
|
||||||
|
label: const Text("Logout"),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
foregroundColor: Colors.redAccent,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 40),
|
||||||
|
|
||||||
|
const Divider(),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Center(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 60,
|
||||||
|
height: 60,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: settings.themeColor,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
"H",
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
"Hightube",
|
||||||
|
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
"Version: 1.0.0-beta4.1",
|
||||||
|
style: TextStyle(color: Colors.grey),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
"Author: Highground-Soft",
|
||||||
|
style: TextStyle(color: Colors.grey),
|
||||||
|
),
|
||||||
|
SizedBox(height: 20),
|
||||||
|
Text(
|
||||||
|
"© 2026 Hightube Project",
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 40),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildSectionTitle(String title) {
|
||||||
|
return Text(
|
||||||
|
title,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildProfileSection(AuthProvider auth) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
CircleAvatar(
|
||||||
|
radius: 35,
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||||
|
child: Text(
|
||||||
|
(auth.username ?? "U")[0].toUpperCase(),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 32,
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 20),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
auth.username ?? "Unknown User",
|
||||||
|
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
"Self-hosted Streamer",
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.outline,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,35 +3,42 @@ import 'package:shared_preferences/shared_preferences.dart';
|
|||||||
|
|
||||||
class AuthProvider with ChangeNotifier {
|
class AuthProvider with ChangeNotifier {
|
||||||
String? _token;
|
String? _token;
|
||||||
|
String? _username; // 新增用户名状态
|
||||||
bool _isAuthenticated = false;
|
bool _isAuthenticated = false;
|
||||||
|
|
||||||
bool get isAuthenticated => _isAuthenticated;
|
bool get isAuthenticated => _isAuthenticated;
|
||||||
String? get token => _token;
|
String? get token => _token;
|
||||||
|
String? get username => _username;
|
||||||
|
|
||||||
AuthProvider() {
|
AuthProvider() {
|
||||||
_loadToken();
|
_loadAuth();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _loadToken() async {
|
void _loadAuth() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
_token = prefs.getString('token');
|
_token = prefs.getString('token');
|
||||||
|
_username = prefs.getString('username');
|
||||||
_isAuthenticated = _token != null;
|
_isAuthenticated = _token != null;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> login(String token) async {
|
Future<void> login(String token, String username) async {
|
||||||
_token = token;
|
_token = token;
|
||||||
|
_username = username;
|
||||||
_isAuthenticated = true;
|
_isAuthenticated = true;
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setString('token', token);
|
await prefs.setString('token', token);
|
||||||
|
await prefs.setString('username', username);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> logout() async {
|
Future<void> logout() async {
|
||||||
_token = null;
|
_token = null;
|
||||||
|
_username = null;
|
||||||
_isAuthenticated = false;
|
_isAuthenticated = false;
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.remove('token');
|
await prefs.remove('token');
|
||||||
|
await prefs.remove('username');
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
class SettingsProvider with ChangeNotifier {
|
class SettingsProvider with ChangeNotifier {
|
||||||
// Default server address for local development.
|
// Use 10.0.2.2 for Android emulator to access host's localhost
|
||||||
// Using 10.0.2.2 for Android emulator or localhost for Desktop.
|
static String get _defaultUrl =>
|
||||||
String _baseUrl = "http://localhost:8080";
|
(defaultTargetPlatform == TargetPlatform.android && !kIsWeb)
|
||||||
|
? "http://10.0.2.2:8080"
|
||||||
|
: "http://localhost:8080";
|
||||||
|
|
||||||
|
String _baseUrl = _defaultUrl;
|
||||||
|
Color _themeColor = Colors.blue;
|
||||||
|
ThemeMode _themeMode = ThemeMode.system;
|
||||||
|
|
||||||
String get baseUrl => _baseUrl;
|
String get baseUrl => _baseUrl;
|
||||||
|
Color get themeColor => _themeColor;
|
||||||
|
ThemeMode get themeMode => _themeMode;
|
||||||
|
|
||||||
SettingsProvider() {
|
SettingsProvider() {
|
||||||
_loadSettings();
|
_loadSettings();
|
||||||
@@ -15,6 +24,14 @@ class SettingsProvider with ChangeNotifier {
|
|||||||
void _loadSettings() async {
|
void _loadSettings() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
_baseUrl = prefs.getString('baseUrl') ?? _baseUrl;
|
_baseUrl = prefs.getString('baseUrl') ?? _baseUrl;
|
||||||
|
final colorValue = prefs.getInt('themeColor');
|
||||||
|
if (colorValue != null) {
|
||||||
|
_themeColor = Color(colorValue);
|
||||||
|
}
|
||||||
|
final savedThemeMode = prefs.getString('themeMode');
|
||||||
|
if (savedThemeMode != null) {
|
||||||
|
_themeMode = _themeModeFromString(savedThemeMode);
|
||||||
|
}
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,9 +42,56 @@ class SettingsProvider with ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setThemeColor(Color color) async {
|
||||||
|
_themeColor = color;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setInt('themeColor', color.toARGB32());
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setThemeMode(ThemeMode mode) async {
|
||||||
|
_themeMode = mode;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString('themeMode', mode.name);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
// Also provide the RTMP URL based on the same hostname
|
// Also provide the RTMP URL based on the same hostname
|
||||||
String get rtmpUrl {
|
String get rtmpUrl {
|
||||||
final uri = Uri.parse(_baseUrl);
|
final uri = Uri.parse(_baseUrl);
|
||||||
return "rtmp://${uri.host}:1935/live";
|
return "rtmp://${uri.host}:1935/live";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String playbackUrl(String roomId, {String? quality}) {
|
||||||
|
final uri = Uri.parse(_baseUrl);
|
||||||
|
final normalizedQuality = quality?.trim().toLowerCase();
|
||||||
|
|
||||||
|
if (kIsWeb) {
|
||||||
|
return uri
|
||||||
|
.replace(
|
||||||
|
path: '/live/$roomId',
|
||||||
|
queryParameters:
|
||||||
|
normalizedQuality == null || normalizedQuality.isEmpty
|
||||||
|
? null
|
||||||
|
: {'quality': normalizedQuality},
|
||||||
|
)
|
||||||
|
.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedQuality == null || normalizedQuality.isEmpty) {
|
||||||
|
return "$rtmpUrl/$roomId";
|
||||||
|
}
|
||||||
|
return "$rtmpUrl/$roomId/$normalizedQuality";
|
||||||
|
}
|
||||||
|
|
||||||
|
ThemeMode _themeModeFromString(String value) {
|
||||||
|
switch (value) {
|
||||||
|
case 'light':
|
||||||
|
return ThemeMode.light;
|
||||||
|
case 'dark':
|
||||||
|
return ThemeMode.dark;
|
||||||
|
default:
|
||||||
|
return ThemeMode.system;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,4 +42,25 @@ class ApiService {
|
|||||||
headers: _headers,
|
headers: _headers,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<http.Response> getPlaybackOptions(String roomId) async {
|
||||||
|
return await http.get(
|
||||||
|
Uri.parse("${settings.baseUrl}/api/rooms/$roomId/playback-options"),
|
||||||
|
headers: _headers,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<http.Response> changePassword(
|
||||||
|
String oldPassword,
|
||||||
|
String newPassword,
|
||||||
|
) async {
|
||||||
|
return await http.post(
|
||||||
|
Uri.parse("${settings.baseUrl}/api/user/change-password"),
|
||||||
|
headers: _headers,
|
||||||
|
body: jsonEncode({
|
||||||
|
"old_password": oldPassword,
|
||||||
|
"new_password": newPassword,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
70
frontend/lib/services/chat_service.dart
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||||
|
|
||||||
|
class ChatMessage {
|
||||||
|
final String type;
|
||||||
|
final String username;
|
||||||
|
final String content;
|
||||||
|
final String roomId;
|
||||||
|
final bool isHistory;
|
||||||
|
|
||||||
|
ChatMessage({
|
||||||
|
required this.type,
|
||||||
|
required this.username,
|
||||||
|
required this.content,
|
||||||
|
required this.roomId,
|
||||||
|
this.isHistory = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ChatMessage.fromJson(Map<String, dynamic> json) {
|
||||||
|
return ChatMessage(
|
||||||
|
type: json['type'] ?? 'chat',
|
||||||
|
username: json['username'] ?? 'Anonymous',
|
||||||
|
content: json['content'] ?? '',
|
||||||
|
roomId: json['room_id'] ?? '',
|
||||||
|
isHistory: json['is_history'] ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'type': type,
|
||||||
|
'username': username,
|
||||||
|
'content': content,
|
||||||
|
'room_id': roomId,
|
||||||
|
'is_history': isHistory,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class ChatService {
|
||||||
|
WebSocketChannel? _channel;
|
||||||
|
final StreamController<ChatMessage> _messageController = StreamController<ChatMessage>.broadcast();
|
||||||
|
|
||||||
|
Stream<ChatMessage> get messages => _messageController.stream;
|
||||||
|
|
||||||
|
void connect(String baseUrl, String roomId, String username) {
|
||||||
|
final wsUri = Uri.parse(baseUrl).replace(scheme: 'ws', path: '/api/ws/room/$roomId', queryParameters: {'username': username});
|
||||||
|
_channel = WebSocketChannel.connect(wsUri);
|
||||||
|
|
||||||
|
_channel!.stream.listen((data) {
|
||||||
|
final json = jsonDecode(data);
|
||||||
|
_messageController.add(ChatMessage.fromJson(json));
|
||||||
|
}, onError: (err) {
|
||||||
|
print("[WS ERROR] $err");
|
||||||
|
}, onDone: () {
|
||||||
|
print("[WS DONE] Connection closed");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void sendMessage(String content, String username, String roomId, {String type = 'chat'}) {
|
||||||
|
if (_channel != null) {
|
||||||
|
final msg = ChatMessage(type: type, username: username, content: content, roomId: roomId);
|
||||||
|
_channel!.sink.add(jsonEncode(msg.toJson()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
_channel?.sink.close();
|
||||||
|
_messageController.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
2
frontend/lib/widgets/web_stream_player.dart
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export 'web_stream_player_stub.dart'
|
||||||
|
if (dart.library.html) 'web_stream_player_web.dart';
|
||||||
21
frontend/lib/widgets/web_stream_player_stub.dart
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class WebStreamPlayer extends StatelessWidget {
|
||||||
|
final String streamUrl;
|
||||||
|
final int? refreshToken;
|
||||||
|
|
||||||
|
const WebStreamPlayer({
|
||||||
|
super.key,
|
||||||
|
required this.streamUrl,
|
||||||
|
this.refreshToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return const Text(
|
||||||
|
'Web playback is unavailable on this platform.',
|
||||||
|
style: TextStyle(color: Colors.white),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
45
frontend/lib/widgets/web_stream_player_web.dart
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import 'dart:html' as html;
|
||||||
|
import 'dart:ui_web' as ui_web;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class WebStreamPlayer extends StatefulWidget {
|
||||||
|
final String streamUrl;
|
||||||
|
final int? refreshToken;
|
||||||
|
|
||||||
|
const WebStreamPlayer({
|
||||||
|
super.key,
|
||||||
|
required this.streamUrl,
|
||||||
|
this.refreshToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<WebStreamPlayer> createState() => _WebStreamPlayerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WebStreamPlayerState extends State<WebStreamPlayer> {
|
||||||
|
late final String _viewType;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final cacheBuster = DateTime.now().microsecondsSinceEpoch;
|
||||||
|
_viewType = 'flv-player-$cacheBuster';
|
||||||
|
|
||||||
|
ui_web.platformViewRegistry.registerViewFactory(_viewType, (int viewId) {
|
||||||
|
final iframe = html.IFrameElement()
|
||||||
|
..src =
|
||||||
|
'flv_player.html?v=$cacheBuster&src=${Uri.encodeComponent(widget.streamUrl)}'
|
||||||
|
..style.border = '0'
|
||||||
|
..style.width = '100%'
|
||||||
|
..style.height = '100%'
|
||||||
|
..allow = 'autoplay; fullscreen';
|
||||||
|
return iframe;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return HtmlElementView(viewType: _viewType);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,10 +4,10 @@ project(runner LANGUAGES CXX)
|
|||||||
|
|
||||||
# The name of the executable created for the application. Change this to change
|
# The name of the executable created for the application. Change this to change
|
||||||
# the on-disk name of your application.
|
# the on-disk name of your application.
|
||||||
set(BINARY_NAME "frontend")
|
set(BINARY_NAME "hightube")
|
||||||
# The unique GTK application identifier for this application. See:
|
# The unique GTK application identifier for this application. See:
|
||||||
# https://wiki.gnome.org/HowDoI/ChooseApplicationID
|
# https://wiki.gnome.org/HowDoI/ChooseApplicationID
|
||||||
set(APPLICATION_ID "com.example.frontend")
|
set(APPLICATION_ID "com.example.hightube")
|
||||||
|
|
||||||
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
|
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
|
||||||
# versions of CMake.
|
# versions of CMake.
|
||||||
|
|||||||
@@ -45,11 +45,11 @@ static void my_application_activate(GApplication* application) {
|
|||||||
if (use_header_bar) {
|
if (use_header_bar) {
|
||||||
GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
|
GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
|
||||||
gtk_widget_show(GTK_WIDGET(header_bar));
|
gtk_widget_show(GTK_WIDGET(header_bar));
|
||||||
gtk_header_bar_set_title(header_bar, "frontend");
|
gtk_header_bar_set_title(header_bar, "Hightube");
|
||||||
gtk_header_bar_set_show_close_button(header_bar, TRUE);
|
gtk_header_bar_set_show_close_button(header_bar, TRUE);
|
||||||
gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
|
gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
|
||||||
} else {
|
} else {
|
||||||
gtk_window_set_title(window, "frontend");
|
gtk_window_set_title(window, "Hightube");
|
||||||
}
|
}
|
||||||
|
|
||||||
gtk_window_set_default_size(window, 1280, 720);
|
gtk_window_set_default_size(window, 1280, 720);
|
||||||
|
|||||||
@@ -1,6 +1,22 @@
|
|||||||
# Generated by pub
|
# Generated by pub
|
||||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||||
packages:
|
packages:
|
||||||
|
archive:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: archive
|
||||||
|
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.0.9"
|
||||||
|
args:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: args
|
||||||
|
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.7.0"
|
||||||
async:
|
async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -25,6 +41,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.1"
|
version: "1.4.1"
|
||||||
|
checked_yaml:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: checked_yaml
|
||||||
|
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.4"
|
||||||
|
cli_util:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: cli_util
|
||||||
|
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.4.2"
|
||||||
clock:
|
clock:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -102,6 +134,14 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
flutter_launcher_icons:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description:
|
||||||
|
name: flutter_launcher_icons
|
||||||
|
sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.13.1"
|
||||||
flutter_lints:
|
flutter_lints:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
@@ -168,6 +208,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.2"
|
version: "4.1.2"
|
||||||
|
image:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image
|
||||||
|
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.8.0"
|
||||||
|
json_annotation:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: json_annotation
|
||||||
|
sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.11.0"
|
||||||
leak_tracker:
|
leak_tracker:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -312,6 +368,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.0"
|
version: "2.3.0"
|
||||||
|
petitparser:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: petitparser
|
||||||
|
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "7.0.2"
|
||||||
platform:
|
platform:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -328,6 +392,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.8"
|
version: "2.1.8"
|
||||||
|
posix:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: posix
|
||||||
|
sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.5.0"
|
||||||
provider:
|
provider:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -525,6 +597,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.1"
|
version: "1.1.1"
|
||||||
|
web_socket:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: web_socket
|
||||||
|
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.1"
|
||||||
|
web_socket_channel:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: web_socket_channel
|
||||||
|
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.3"
|
||||||
xdg_directories:
|
xdg_directories:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -533,6 +621,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.0"
|
version: "1.1.0"
|
||||||
|
xml:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: xml
|
||||||
|
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.6.1"
|
||||||
yaml:
|
yaml:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
name: frontend
|
name: hightube
|
||||||
description: "A new Flutter project."
|
description: "Open Source Live Platform"
|
||||||
# The following line prevents the package from being accidentally published to
|
# The following line prevents the package from being accidentally published to
|
||||||
# pub.dev using `flutter pub publish`. This is preferred for private packages.
|
# pub.dev using `flutter pub publish`. This is preferred for private packages.
|
||||||
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||||
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 1.0.0+1
|
version: 1.0.0-beta4.1
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.11.1
|
sdk: ^3.11.1
|
||||||
@@ -39,10 +39,12 @@ dependencies:
|
|||||||
shared_preferences: ^2.5.4
|
shared_preferences: ^2.5.4
|
||||||
video_player: ^2.11.1
|
video_player: ^2.11.1
|
||||||
fvp: ^0.35.2
|
fvp: ^0.35.2
|
||||||
|
web_socket_channel: ^3.0.3
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
flutter_launcher_icons: ^0.13.1
|
||||||
|
|
||||||
# The "flutter_lints" package below contains a set of recommended lints to
|
# The "flutter_lints" package below contains a set of recommended lints to
|
||||||
# encourage good coding practices. The lint set provided by the package is
|
# encourage good coding practices. The lint set provided by the package is
|
||||||
@@ -51,6 +53,21 @@ dev_dependencies:
|
|||||||
# rules and activating additional ones.
|
# rules and activating additional ones.
|
||||||
flutter_lints: ^6.0.0
|
flutter_lints: ^6.0.0
|
||||||
|
|
||||||
|
flutter_launcher_icons:
|
||||||
|
android: "launcher_icon"
|
||||||
|
ios: false
|
||||||
|
image_path: "assets/icon/app_icon.png"
|
||||||
|
min_sdk_android: 21
|
||||||
|
web:
|
||||||
|
generate: true
|
||||||
|
image_path: "assets/icon/app_icon.png"
|
||||||
|
background_color: "#ffffff"
|
||||||
|
theme_color: "#2196F3"
|
||||||
|
windows:
|
||||||
|
generate: true
|
||||||
|
image_path: "assets/icon/app_icon.png"
|
||||||
|
icon_size: 256
|
||||||
|
|
||||||
# For information on the generic Dart part of this file, see the
|
# For information on the generic Dart part of this file, see the
|
||||||
# following page: https://dart.dev/tools/pub/pubspec
|
# following page: https://dart.dev/tools/pub/pubspec
|
||||||
|
|
||||||
|
|||||||
@@ -8,12 +8,12 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
import 'package:frontend/main.dart';
|
import 'package:hightube/main.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
||||||
// Build our app and trigger a frame.
|
// Build our app and trigger a frame.
|
||||||
await tester.pumpWidget(const MyApp());
|
await tester.pumpWidget(HightubeApp());
|
||||||
|
|
||||||
// Verify that our counter starts at 0.
|
// Verify that our counter starts at 0.
|
||||||
expect(find.text('0'), findsOneWidget);
|
expect(find.text('0'), findsOneWidget);
|
||||||
|
|||||||
10
frontend/web/flv.min.js
vendored
Normal file
99
frontend/web/flv_player.html
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta content="width=device-width, initial-scale=1.0" name="viewport">
|
||||||
|
<title>Hightube FLV Player</title>
|
||||||
|
<style>
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: #000;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-family: sans-serif;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
#player,
|
||||||
|
#message {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#player {
|
||||||
|
display: none;
|
||||||
|
object-fit: contain;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
#message {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script src="flv.min.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<video id="player" controls autoplay muted playsinline></video>
|
||||||
|
<div id="message">Loading live stream...</div>
|
||||||
|
<script>
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const streamUrl = params.get('src');
|
||||||
|
const video = document.getElementById('player');
|
||||||
|
const message = document.getElementById('message');
|
||||||
|
|
||||||
|
function showMessage(text) {
|
||||||
|
video.style.display = 'none';
|
||||||
|
message.style.display = 'flex';
|
||||||
|
message.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!streamUrl) {
|
||||||
|
showMessage('Missing stream URL.');
|
||||||
|
} else if (typeof flvjs === 'undefined') {
|
||||||
|
showMessage('flv.js failed to load. Check network access and reload.');
|
||||||
|
} else if (!flvjs.isSupported()) {
|
||||||
|
showMessage('This browser does not support FLV playback.');
|
||||||
|
} else {
|
||||||
|
const player = flvjs.createPlayer({
|
||||||
|
type: 'flv',
|
||||||
|
url: streamUrl,
|
||||||
|
isLive: true,
|
||||||
|
}, {
|
||||||
|
enableWorker: false,
|
||||||
|
stashInitialSize: 128,
|
||||||
|
});
|
||||||
|
|
||||||
|
player.attachMediaElement(video);
|
||||||
|
player.load();
|
||||||
|
player.play().catch(() => {});
|
||||||
|
|
||||||
|
player.on(flvjs.Events.ERROR, function(errorType, detail, info) {
|
||||||
|
const parts = ['Live stream failed to load.'];
|
||||||
|
if (errorType) parts.push('type=' + errorType);
|
||||||
|
if (detail) parts.push('detail=' + detail);
|
||||||
|
if (info && info.msg) parts.push('msg=' + info.msg);
|
||||||
|
showMessage(parts.join(' '));
|
||||||
|
});
|
||||||
|
|
||||||
|
video.style.display = 'block';
|
||||||
|
message.style.display = 'none';
|
||||||
|
|
||||||
|
window.addEventListener('beforeunload', function() {
|
||||||
|
player.destroy();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
# Project-level configuration.
|
# Project-level configuration.
|
||||||
cmake_minimum_required(VERSION 3.14)
|
cmake_minimum_required(VERSION 3.14)
|
||||||
project(frontend LANGUAGES CXX)
|
project(hightube LANGUAGES CXX)
|
||||||
|
|
||||||
# The name of the executable created for the application. Change this to change
|
# The name of the executable created for the application. Change this to change
|
||||||
# the on-disk name of your application.
|
# the on-disk name of your application.
|
||||||
set(BINARY_NAME "frontend")
|
set(BINARY_NAME "hightube")
|
||||||
|
|
||||||
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
|
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
|
||||||
# versions of CMake.
|
# versions of CMake.
|
||||||
|
|||||||
@@ -90,12 +90,12 @@ BEGIN
|
|||||||
BLOCK "040904e4"
|
BLOCK "040904e4"
|
||||||
BEGIN
|
BEGIN
|
||||||
VALUE "CompanyName", "com.example" "\0"
|
VALUE "CompanyName", "com.example" "\0"
|
||||||
VALUE "FileDescription", "frontend" "\0"
|
VALUE "FileDescription", "Hightube" "\0"
|
||||||
VALUE "FileVersion", VERSION_AS_STRING "\0"
|
VALUE "FileVersion", VERSION_AS_STRING "\0"
|
||||||
VALUE "InternalName", "frontend" "\0"
|
VALUE "InternalName", "Hightube" "\0"
|
||||||
VALUE "LegalCopyright", "Copyright (C) 2026 com.example. All rights reserved." "\0"
|
VALUE "LegalCopyright", "Copyright (C) 2026 com.example. All rights reserved." "\0"
|
||||||
VALUE "OriginalFilename", "frontend.exe" "\0"
|
VALUE "OriginalFilename", "Hightube.exe" "\0"
|
||||||
VALUE "ProductName", "frontend" "\0"
|
VALUE "ProductName", "Hightube" "\0"
|
||||||
VALUE "ProductVersion", VERSION_AS_STRING "\0"
|
VALUE "ProductVersion", VERSION_AS_STRING "\0"
|
||||||
END
|
END
|
||||||
END
|
END
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
|
|||||||
FlutterWindow window(project);
|
FlutterWindow window(project);
|
||||||
Win32Window::Point origin(10, 10);
|
Win32Window::Point origin(10, 10);
|
||||||
Win32Window::Size size(1280, 720);
|
Win32Window::Size size(1280, 720);
|
||||||
if (!window.Create(L"frontend", origin, size)) {
|
if (!window.Create(L"Hightube", origin, size)) {
|
||||||
return EXIT_FAILURE;
|
return EXIT_FAILURE;
|
||||||
}
|
}
|
||||||
window.SetQuitOnClose(true);
|
window.SetQuitOnClose(true);
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 1.5 KiB |