Compare commits

36 Commits

Author SHA1 Message Date
Z
804adf94a3 feat: add Linux disk space monitoring support- add disk_linux.go using syscall.Statfs to retrieve disk info- update disk_other.go build tag to exclude both Windows and Linux 2026-06-27 15:03:21 +08:00
b0e3d6069a README updated 2026-06-27 14:38:00 +08:00
80d4f692e0 fix: 完善直播结束通知与 WebSocket 连接 2026-06-25 10:50:35 +08:00
2281c98b1b fix: 兼容 Web 同源部署地址 2026-06-24 21:10:21 +08:00
7cb51e70a3 updated website icons and docker server 2026-06-24 16:18:20 +08:00
44318e0e4d updated website style 2026-06-24 10:44:38 +08:00
da577299a0 website updated 2026-06-24 10:25:09 +08:00
ddb2a01d93 添加产品介绍网页 2026-06-23 19:01:54 +08:00
ebded5057f chore: 发布 v1.0.1 并支持端口参数 2026-06-23 17:01:25 +08:00
ae8fe7f31b 修复flutter analyze提示,更新版本至v1.0.0-Beta4.8 2026-06-21 21:20:29 +08:00
2de7d5269e updated go.mod 2026-06-15 15:35:03 +08:00
8715c7bb3d perf: 提升后端高并发承载能力 2026-06-15 15:33:49 +08:00
63c954da55 gitignore updated 2026-06-15 15:08:58 +08:00
e0a6923984 perf: 优化后端性能与直播延迟,包含数据库WAL模式、流媒体写缓冲、转码 Preset 优化、聊天锁和指标采集优化,以及前端自动追帧功能 2026-06-15 15:08:07 +08:00
261b1ab169 feat(frontend): add multi-language support (en, zh-Hans, zh-Hant, ja) 2026-05-25 11:49:53 +08:00
1539e495e6 Make playback options endpoint public 2026-05-11 13:19:51 +08:00
c5b7451fc6 Add Android quick streaming and logout confirmation 2026-04-22 11:44:15 +08:00
b07f243c88 Add live room preview thumbnails 2026-04-22 11:01:32 +08:00
425ea363f8 Unify web player controls and add volume control 2026-04-22 10:45:46 +08:00
6eb0baf16e Add multi-resolution playback support 2026-04-15 11:42:13 +08:00
146f05388e Support Enter to submit login 2026-04-15 11:20:30 +08:00
98666ab1ea Rework admin console authentication and UI 2026-04-15 11:10:52 +08:00
Z
1cce5634b1 监控网页实现 2026-04-09 00:14:57 +08:00
6b1c7242c7 Fix player overlay and danmaku rendering 2026-04-08 11:13:52 +08:00
fa86c849ca gitignore updated 2026-04-01 18:06:14 +08:00
f97195d640 Improve settings and playback controls 2026-04-01 18:04:37 +08:00
2d0acad161 Bust web player iframe cache 2026-04-01 11:41:14 +08:00
48dc6c7b26 Add web HTTP-FLV playback path 2026-04-01 11:30:52 +08:00
01b25883e1 Added MIT license 2026-03-26 13:47:08 +08:00
6710aa0624 fix: resolve connectivity issues for Android and Web builds
- Android: Added INTERNET permission and enabled cleartext traffic in AndroidManifest.xml.
- Web: Implemented and registered CORSMiddleware in backend to allow cross-origin requests.
- Flutter: Updated SettingsProvider to use 10.0.2.2 as default for Android Emulator for easier local testing.
2026-03-26 13:37:34 +08:00
a0c5e7590d feat: implement chat history, theme customization, and password management
- Added chat history persistence for active rooms with auto-cleanup on stream end.
- Overhauled Settings page with user profile, theme color picker, and password change.
- Added backend API for user password updates.
- Integrated flutter_launcher_icons and updated app icon to 'H' logo.
- Fixed 'Duplicate keys' bug in danmaku by using UniqueKey and filtering historical messages.
- Updated version to 1.0.0-beta3.5 and author info.
2026-03-25 11:48:39 +08:00
b2a27f7801 Updated APP name 2026-03-23 16:44:23 +08:00
f21498717b Phase 4.5: Completed responsive player layout and real username integration 2026-03-18 15:36:08 +08:00
9c7261cbda Phase 4.0: WebSocket chat and danmaku system implemented 2026-03-18 15:30:03 +08:00
d05ec7ccdf Phase 3.5: Finalized UI polish, added Console, and fixed stale stream status bug 2026-03-18 11:37:16 +08:00
38bc9526b2 Phase 3.5: Implemented adaptive navigation (BottomBar/Rail) and My Stream console 2026-03-18 11:24:12 +08:00
78 changed files with 10963 additions and 291 deletions

5
.gitignore vendored
View File

@@ -3,9 +3,12 @@
.idea/ .idea/
.vscode/ .vscode/
docs/ docs/
.codex
# --- Backend (Go) --- # --- Backend (Go) ---
backend/hightube.db backend/hightube.db
backend/hightube.db-shm
backend/hightube.db-wal
backend/server backend/server
backend/*.exe backend/*.exe
backend/*.out backend/*.out
@@ -50,4 +53,4 @@ frontend/web/robots.txt
frontend/web/icons/Icon-192.png frontend/web/icons/Icon-192.png
frontend/web/icons/Icon-512.png frontend/web/icons/Icon-512.png
frontend/web/icons/Icon-maskable-192.png frontend/web/icons/Icon-maskable-192.png
frontend/web/icons/Icon-maskable-512.png frontend/web/icons/Icon-maskable-512.png

21
LICENSE Normal file
View File

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

View File

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

View File

@@ -1,32 +1,82 @@
package main package main
import ( import (
"log" "flag"
"fmt"
"net/http"
"os"
"time"
"hightube/internal/api" "hightube/internal/api"
"hightube/internal/chat"
"hightube/internal/db" "hightube/internal/db"
"hightube/internal/monitor"
"hightube/internal/stream" "hightube/internal/stream"
) )
type serverConfig struct {
apiPort int
rtmpPort int
}
func main() { func main() {
log.Println("Starting Hightube Server Version-1.0.2 ...") cfg := parseFlags()
monitor.Init(2000)
monitor.Infof("Starting Hightube Server v1.0.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(fmt.Sprintf("%d", cfg.rtmpPort))
// 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() apiAddr := fmt.Sprintf(":%d", cfg.apiPort)
log.Println("[INFO] API Server is listening on :8080...") r := api.SetupRouter(srv)
if err := r.Run(":8080"); err != nil { httpServer := &http.Server{
log.Fatalf("Failed to start API server: %v", err) Addr: apiAddr,
Handler: r,
ReadHeaderTimeout: 5 * time.Second,
IdleTimeout: 60 * time.Second,
MaxHeaderBytes: 1 << 20,
}
monitor.Infof("API server listening on %s", apiAddr)
monitor.Infof("Web console listening on %s/admin", apiAddr)
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
monitor.Errorf("Failed to start API server: %v", err)
} }
}() }()
// Setup and start the RTMP server // Setup and start the RTMP server
log.Println("[INFO] Ready to receive RTMP streams from OBS.") rtmpAddr := fmt.Sprintf(":%d", cfg.rtmpPort)
srv := stream.NewRTMPServer() monitor.Infof("Ready to receive RTMP streams from OBS")
if err := srv.Start(":1935"); err != nil { if err := srv.Start(rtmpAddr); err != nil {
log.Fatalf("Failed to start RTMP server: %v", err) monitor.Errorf("Failed to start RTMP server: %v", err)
} }
} }
func parseFlags() serverConfig {
cfg := serverConfig{}
flag.IntVar(&cfg.apiPort, "api-port", 8080, "API/Web console listen port")
flag.IntVar(&cfg.rtmpPort, "rtmp-port", 1935, "RTMP listen port")
flag.Parse()
if !validPort(cfg.apiPort) {
fmt.Fprintf(os.Stderr, "invalid --api-port %d: port must be between 1 and 65535\n", cfg.apiPort)
os.Exit(2)
}
if !validPort(cfg.rtmpPort) {
fmt.Fprintf(os.Stderr, "invalid --rtmp-port %d: port must be between 1 and 65535\n", cfg.rtmpPort)
os.Exit(2)
}
return cfg
}
func validPort(port int) bool {
return port >= 1 && port <= 65535
}

View File

@@ -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 // direct
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 // direct
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
) )

View File

@@ -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=

View File

@@ -0,0 +1,350 @@
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
}
user, err := db.LoadUserByUsername(strings.TrimSpace(req.Username))
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid username or password"})
return
}
if !utils.CheckPasswordHash(req.Password, user.Password) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid username or password"})
return
}
if !user.Enabled {
c.JSON(http.StatusForbidden, gin.H{"error": "Account is disabled"})
return
}
if user.Role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
return
}
token, err := utils.GenerateToken(user.ID, user.Username, user.Role)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create admin session"})
return
}
c.SetCookie(adminSessionCookieName, token, 86400, "/", "", false, true)
monitor.Auditf("admin=%s signed in", user.Username)
c.JSON(http.StatusOK, gin.H{
"username": user.Username,
"role": user.Role,
})
}
func AdminLogout(c *gin.Context) {
operator, _ := c.Get("username")
c.SetCookie(adminSessionCookieName, "", -1, "/", "", false, true)
monitor.Auditf("admin=%v signed out", operator)
c.JSON(http.StatusOK, gin.H{"message": "signed out"})
}
func GetAdminSession(c *gin.Context) {
username, _ := c.Get("username")
role, _ := c.Get("role")
c.JSON(http.StatusOK, gin.H{
"username": username,
"role": role,
})
}
func GetAdminOverview(c *gin.Context) {
stats := monitor.GetSnapshot()
chatStats := chat.StatsSnapshot{}
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.UpdateUserRole(userID, role); 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.UpdateUserEnabled(userID, req.Enabled); 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.UpdateUserPassword(userID, hash); 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.DeleteUserCascade(userID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete user"})
return
}
operator, _ := c.Get("username")
monitor.Auditf("admin=%v deleted user_id=%d", operator, userID)
c.JSON(http.StatusOK, gin.H{"message": "user deleted"})
}
func ListUsers(c *gin.Context) {
keyword := strings.TrimSpace(c.Query("keyword"))
page := parseIntWithDefault(c.Query("page"), 1)
pageSize := parseIntWithDefault(c.Query("page_size"), 20)
if pageSize > 200 {
pageSize = 200
}
offset := (page - 1) * pageSize
if offset < 0 {
offset = 0
}
query := db.DB.Model(&model.User{})
if keyword != "" {
query = query.Where("username LIKE ?", "%"+keyword+"%")
}
var total int64
if err := query.Count(&total).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count users"})
return
}
var users []model.User
if err := query.Order("id DESC").Offset(offset).Limit(pageSize).Find(&users).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query users"})
return
}
items := make([]gin.H, 0, len(users))
for _, u := range users {
items = append(items, gin.H{
"id": u.ID,
"username": u.Username,
"role": u.Role,
"enabled": u.Enabled,
"created_at": u.CreatedAt,
"updated_at": u.UpdatedAt,
})
}
c.JSON(http.StatusOK, gin.H{
"items": items,
"total": total,
"page": page,
"page_size": pageSize,
})
}
func parseIntWithDefault(v string, def int) int {
i, err := strconv.Atoi(v)
if err != nil || i <= 0 {
return def
}
return i
}
func parsePathUint(v string) (uint, bool) {
u64, err := strconv.ParseUint(v, 10, 32)
if err != nil {
return 0, false
}
return uint(u64), true
}

View File

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

View File

@@ -1,9 +1,13 @@
package api package api
import ( import (
"errors"
"net/http" "net/http"
"os"
"strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm"
"hightube/internal/db" "hightube/internal/db"
"hightube/internal/model" "hightube/internal/model"
@@ -20,6 +24,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,11 +36,19 @@ 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 if _, err := db.LoadUserByUsername(req.Username); err == nil {
if err := db.DB.Where("username = ?", req.Username).First(&existingUser).Error; err == nil {
c.JSON(http.StatusConflict, gin.H{"error": "Username already exists"}) c.JSON(http.StatusConflict, gin.H{"error": "Username already exists"})
return return
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to validate username"})
return
} }
// Hash password // Hash password
@@ -45,21 +62,16 @@ 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 {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
return
}
// Create a default live room for the new user
room := model.Room{ room := model.Room{
UserID: user.ID,
Title: user.Username + "'s Live Room", Title: user.Username + "'s Live Room",
StreamKey: utils.GenerateStreamKey(), StreamKey: utils.GenerateStreamKey(),
IsActive: false, IsActive: false,
} }
if err := db.DB.Create(&room).Error; err != nil { if err := db.CreateUserAndRoom(&user, &room); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create room for user"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
return return
} }
@@ -72,9 +84,10 @@ func Login(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
req.Username = strings.TrimSpace(req.Username)
var user model.User user, err := db.LoadUserByUsername(req.Username)
if err := db.DB.Where("username = ?", req.Username).First(&user).Error; err != nil { if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid username or password"}) c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid username or password"})
return return
} }
@@ -84,13 +97,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
}
user, err := db.LoadUserByID(userID.(uint))
if 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.UpdateUserPassword(user.ID, hashedPassword); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update password"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Password updated successfully"})
}
func bootstrapAdminUsername() string {
adminUsername := strings.TrimSpace(os.Getenv("HIGHTUBE_ADMIN_USER"))
if adminUsername == "" {
return "admin"
}
return adminUsername
}

View File

@@ -0,0 +1,56 @@
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{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
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)
}

View File

@@ -1,42 +1,136 @@
package api package api
import ( import (
"errors"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"hightube/internal/db"
"hightube/internal/model"
"hightube/internal/monitor"
"hightube/internal/utils" "hightube/internal/utils"
) )
const adminSessionCookieName = "hightube_admin_session"
// AuthMiddleware intercepts requests, validates JWT, and injects user_id into context // AuthMiddleware intercepts requests, validates JWT, and injects user_id into context
func AuthMiddleware() gin.HandlerFunc { func AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization") user, err := authenticateRequest(c)
if authHeader == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header is required"})
c.Abort()
return
}
parts := strings.Split(authHeader, " ")
if len(parts) != 2 || parts[0] != "Bearer" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header format must be Bearer {token}"})
c.Abort()
return
}
tokenStr := parts[1]
userIDStr, err := utils.ParseToken(tokenStr)
if err != nil { if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token"}) switch {
case errors.Is(err, errMissingToken):
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization is required"})
case errors.Is(err, errInvalidToken):
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token"})
case errors.Is(err, errUserNotFound):
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not found"})
case errors.Is(err, errDisabledAccount):
c.JSON(http.StatusForbidden, gin.H{"error": "Account is disabled"})
default:
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication failed"})
}
c.Abort() c.Abort()
return return
} }
userID, _ := strconv.ParseUint(userIDStr, 10, 32) c.Set("user_id", user.ID)
c.Set("user_id", uint(userID)) c.Set("username", user.Username)
c.Set("role", user.Role)
c.Next()
}
}
func AdminMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
role, ok := c.Get("role")
if !ok || role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "admin access required"})
c.Abort()
return
}
c.Next()
}
}
func RequestMetricsMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
monitor.IncrementRequestCount()
c.Next()
if c.Writer.Status() >= http.StatusBadRequest {
monitor.IncrementErrorCount()
}
}
}
var (
errMissingToken = errors.New("missing token")
errInvalidToken = errors.New("invalid token")
errUserNotFound = errors.New("user not found")
errDisabledAccount = errors.New("disabled account")
)
func authenticateRequest(c *gin.Context) (*model.User, error) {
tokenStr := extractToken(c)
if tokenStr == "" {
return nil, errMissingToken
}
claims, err := utils.ParseToken(tokenStr)
if err != nil {
return nil, errInvalidToken
}
userID, err := strconv.ParseUint(claims.Subject, 10, 32)
if err != nil {
return nil, errInvalidToken
}
user, err := db.LoadUserByID(uint(userID))
if err != nil {
return nil, errUserNotFound
}
if !user.Enabled {
return nil, errDisabledAccount
}
return &user, nil
}
func extractToken(c *gin.Context) string {
authHeader := strings.TrimSpace(c.GetHeader("Authorization"))
if authHeader != "" {
parts := strings.Split(authHeader, " ")
if len(parts) == 2 && parts[0] == "Bearer" {
return strings.TrimSpace(parts[1])
}
}
cookieToken, err := c.Cookie(adminSessionCookieName)
if err == nil {
return strings.TrimSpace(cookieToken)
}
return ""
}
// CORSMiddleware handles cross-origin requests from web clients
func CORSMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With")
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next() c.Next()
} }
} }

View File

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

View File

@@ -2,14 +2,21 @@ 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.New()
r.Use(gin.Recovery())
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 +24,38 @@ 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("/api/rooms/:room_id/playback-options", GetRoomPlaybackOptions)
r.GET("/api/rooms/:room_id/thumbnail", streamServer.HandleThumbnail)
r.GET("/live/:room_id", streamServer.HandleHTTPFLV)
// WebSocket endpoint for live chat
r.GET("/api/ws/room/:room_id", WSHandler)
r.GET("/admin", AdminPage)
r.GET("/api/admin/logs/stream", 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.POST("/user/change-password", ChangePassword)
adminGroup := authGroup.Group("/admin")
adminGroup.Use(AdminMiddleware())
{
adminGroup.GET("/session", GetAdminSession)
adminGroup.GET("/overview", GetAdminOverview)
adminGroup.GET("/health", GetAdminHealth)
adminGroup.GET("/logs", ListAdminLogs)
adminGroup.POST("/logout", AdminLogout)
adminGroup.GET("/users", ListUsers)
adminGroup.PATCH("/users/:id/role", UpdateUserRole)
adminGroup.PATCH("/users/:id/enabled", UpdateUserEnabled)
adminGroup.POST("/users/:id/reset-password", ResetUserPassword)
adminGroup.DELETE("/users/:id", DeleteUser)
}
} }
return r return r

View File

@@ -0,0 +1,677 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Hightube Admin Console</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=IBM+Plex+Mono:wght@400;600&display=swap" rel="stylesheet">
<style>
:root {
--bg: #eef4fb;
--bg-accent: #dbe9f7;
--surface: #ffffff;
--surface-soft: #f6f9fc;
--ink: #19324d;
--muted: #5a718a;
--line: #d7e1eb;
--primary: #2f6fed;
--primary-strong: #1d4ed8;
--primary-soft: #dce8ff;
--danger: #dc2626;
--danger-soft: #fee2e2;
--success: #15803d;
--success-soft: #dcfce7;
--warning: #b45309;
--shadow: 0 18px 44px rgba(29, 78, 216, 0.10);
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
font-family: "Space Grotesk", "Segoe UI", sans-serif;
color: var(--ink);
background:
radial-gradient(900px 460px at -10% -20%, rgba(47,111,237,0.16) 0%, transparent 60%),
radial-gradient(800px 420px at 110% 0%, rgba(61,187,167,0.12) 0%, transparent 55%),
linear-gradient(180deg, var(--bg) 0%, #f8fbff 100%);
padding: 24px;
}
.shell {
max-width: 1280px;
margin: 0 auto;
}
.hero {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
margin-bottom: 18px;
}
.brand h1 {
margin: 0;
font-size: clamp(28px, 5vw, 42px);
letter-spacing: 0.03em;
}
.brand p {
margin: 8px 0 0;
color: var(--muted);
max-width: 620px;
}
.badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-radius: 999px;
background: rgba(255,255,255,0.78);
border: 1px solid rgba(47,111,237,0.12);
box-shadow: var(--shadow);
color: var(--primary-strong);
font-weight: 700;
}
.panel-grid {
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 16px;
}
.card {
background: rgba(255,255,255,0.88);
border: 1px solid var(--line);
border-radius: 20px;
padding: 18px;
box-shadow: var(--shadow);
backdrop-filter: blur(12px);
}
.login-card {
grid-column: 4 / span 6;
padding: 22px;
}
.full { grid-column: 1 / -1; }
.col4 { grid-column: span 4; }
.col5 { grid-column: span 5; }
.col7 { grid-column: span 7; }
.col8 { grid-column: span 8; }
h2 {
margin: 0 0 12px;
font-size: 19px;
}
.muted {
color: var(--muted);
}
.hidden {
display: none !important;
}
.stack {
display: grid;
gap: 12px;
}
.row {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.row.between {
justify-content: space-between;
}
input, select, button {
border-radius: 14px;
border: 1px solid var(--line);
padding: 10px 12px;
font-size: 14px;
font-family: inherit;
}
input, select {
width: 100%;
background: var(--surface);
color: var(--ink);
}
button {
cursor: pointer;
border: none;
color: white;
background: linear-gradient(120deg, var(--primary), #5994ff);
font-weight: 700;
transition: transform 140ms ease, opacity 140ms ease;
}
button:hover {
transform: translateY(-1px);
}
button.secondary {
background: linear-gradient(120deg, #7ea7ff, #4b7df2);
}
button.subtle {
color: var(--ink);
background: var(--surface-soft);
border: 1px solid var(--line);
}
button.danger {
background: linear-gradient(120deg, #ef4444, #dc2626);
}
.stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
}
.metric {
background: var(--surface-soft);
border: 1px solid var(--line);
border-radius: 16px;
padding: 12px;
}
.metric .k {
font-size: 12px;
color: var(--muted);
}
.metric .v {
margin-top: 8px;
font-size: 22px;
font-weight: 700;
}
.health-strip {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.health-chip {
min-width: 120px;
padding: 10px 12px;
border-radius: 14px;
background: var(--surface-soft);
border: 1px solid var(--line);
}
.health-chip .k {
font-size: 12px;
color: var(--muted);
margin-bottom: 8px;
}
.mono {
font-family: "IBM Plex Mono", monospace;
font-size: 12px;
}
#logs {
height: 300px;
overflow: auto;
background: #f5f9ff;
color: #17304d;
border-radius: 16px;
padding: 12px;
white-space: pre-wrap;
border: 1px solid var(--line);
font-family: "IBM Plex Mono", monospace;
font-size: 12px;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
th, td {
text-align: left;
border-bottom: 1px solid var(--line);
padding: 10px 8px;
vertical-align: top;
}
.actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.pill {
display: inline-block;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
}
.pill.ok {
background: var(--success-soft);
color: var(--success);
}
.pill.off {
background: var(--danger-soft);
color: var(--danger);
}
.pill.admin {
background: var(--primary-soft);
color: var(--primary-strong);
}
.pill.user {
background: #edf2f7;
color: #475569;
}
.notice {
padding: 12px 14px;
border-radius: 14px;
background: #fff7ed;
border: 1px solid #fed7aa;
color: var(--warning);
font-size: 13px;
}
.session-info {
color: var(--muted);
font-size: 13px;
}
@media (max-width: 960px) {
.login-card, .col4, .col5, .col7, .col8 {
grid-column: 1 / -1;
}
.stats {
grid-template-columns: repeat(2, 1fr);
}
.health-strip {
display: grid;
grid-template-columns: repeat(2, 1fr);
}
.hero {
flex-direction: column;
align-items: flex-start;
}
}
</style>
</head>
<body>
<div class="shell">
<div class="hero">
<div class="brand">
<div class="badge">Admin Panel</div>
<h1>Hightube Control Console</h1>
<p>Lightweight operations dashboard for stream status, runtime health, audit logs, and account management.</p>
</div>
<div class="session-info" id="sessionInfo">Not signed in</div>
</div>
<div class="panel-grid" id="loginView">
<div class="card login-card">
<h2>Admin Sign In</h2>
<p class="muted">Use the administrator account to access monitoring, logs, and user controls. Default bootstrap credentials are <b>admin / admin</b> unless changed by environment variables.</p>
<div class="stack">
<input id="loginUsername" placeholder="Admin username" value="admin" />
<input id="loginPassword" type="password" placeholder="Password" value="admin" />
<div class="row">
<button onclick="login()">Sign In</button>
</div>
<div class="notice">
Change the default admin password immediately after first login.
</div>
</div>
</div>
</div>
<div class="panel-grid hidden" id="appView">
<div class="card full">
<div class="row between">
<div>
<h2 style="margin-bottom:6px;">Session</h2>
<div class="muted">Authenticated through a server-managed admin session cookie.</div>
</div>
<div class="row">
<button class="subtle" onclick="refreshAll()">Refresh Now</button>
<button class="danger" onclick="logout()">Sign Out</button>
</div>
</div>
</div>
<div class="card col7">
<h2>System Overview</h2>
<div class="stats" id="stats"></div>
</div>
<div class="card col5">
<h2>Admin Password</h2>
<div class="stack">
<input id="oldPassword" type="password" placeholder="Current password" />
<input id="newPassword" type="password" placeholder="New password" />
<div class="row">
<button onclick="changePassword()">Update Password</button>
</div>
</div>
</div>
<div class="card col4">
<h2>Live Status</h2>
<div id="online"></div>
<div class="health-strip" id="health" style="margin-top:14px;"></div>
</div>
<div class="card col8">
<div class="row between" style="margin-bottom:10px;">
<h2 style="margin:0;">Audit and Runtime Logs</h2>
<div class="row">
<select id="logLevel">
<option value="">All levels</option>
<option value="info">info</option>
<option value="warn">warn</option>
<option value="error">error</option>
<option value="audit">audit</option>
</select>
<input id="logKeyword" placeholder="Filter keyword" style="width:220px;" />
<button class="secondary" onclick="loadHistory()">Load History</button>
</div>
</div>
<div id="logs"></div>
</div>
<div class="card full">
<div class="row between" style="margin-bottom:10px;">
<h2 style="margin:0;">User Management</h2>
<div class="row">
<input id="userKeyword" placeholder="Search by username" style="width:220px;" />
<button class="secondary" onclick="loadUsers()">Search</button>
</div>
</div>
<table>
<thead>
<tr>
<th>ID</th>
<th>Username</th>
<th>Role</th>
<th>Status</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="usersBody"></tbody>
</table>
</div>
</div>
</div>
<script>
let evt = null;
let overviewTimer = null;
let healthTimer = null;
let currentAdmin = null;
function setSessionText(text) {
document.getElementById('sessionInfo').textContent = text;
}
function addLogLine(text) {
const box = document.getElementById('logs');
box.textContent += text + '\n';
box.scrollTop = box.scrollHeight;
}
async function api(path, options = {}) {
const response = await fetch(path, {
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
...(options.headers || {}),
},
...options,
});
let data = null;
const text = await response.text();
if (text) {
try {
data = JSON.parse(text);
} catch (_) {
data = { raw: text };
}
}
if (!response.ok) {
const message = (data && (data.error || data.message)) || `Request failed (${response.status})`;
throw new Error(message);
}
return data;
}
function clearPolling() {
if (overviewTimer) clearInterval(overviewTimer);
if (healthTimer) clearInterval(healthTimer);
overviewTimer = null;
healthTimer = null;
}
function disconnectLogs() {
if (evt) {
evt.close();
evt = null;
}
}
function showLogin() {
clearPolling();
disconnectLogs();
currentAdmin = null;
setSessionText('Not signed in');
document.getElementById('loginView').classList.remove('hidden');
document.getElementById('appView').classList.add('hidden');
}
function showApp(session) {
currentAdmin = session;
setSessionText(`Signed in as ${session.username}`);
document.getElementById('loginView').classList.add('hidden');
document.getElementById('appView').classList.remove('hidden');
}
async function ensureSession() {
try {
const session = await api('/api/admin/session');
showApp(session);
await refreshAll();
connectLiveLogs();
clearPolling();
overviewTimer = setInterval(loadOverview, 1000);
healthTimer = setInterval(loadHealth, 1000);
} catch (_) {
showLogin();
}
}
async function login() {
const username = document.getElementById('loginUsername').value.trim();
const password = document.getElementById('loginPassword').value;
try {
await api('/api/admin/login', {
method: 'POST',
body: JSON.stringify({ username, password }),
});
document.getElementById('loginPassword').value = '';
await ensureSession();
} catch (error) {
alert(error.message);
}
}
async function logout() {
try {
await api('/api/admin/logout', { method: 'POST' });
} catch (_) {
} finally {
showLogin();
}
}
async function refreshAll() {
await Promise.all([loadOverview(), loadHealth(), loadUsers(), loadHistory()]);
}
async function loadOverview() {
const data = await api('/api/admin/overview');
const sys = data.system || {};
const stream = data.stream || {};
const chat = data.chat || {};
document.getElementById('stats').innerHTML = `
<div class="metric"><div class="k">Uptime (s)</div><div class="v">${sys.uptime_seconds ?? '-'}</div></div>
<div class="metric"><div class="k">Requests</div><div class="v">${sys.requests_total ?? '-'}</div></div>
<div class="metric"><div class="k">Errors</div><div class="v">${sys.errors_total ?? '-'}</div></div>
<div class="metric"><div class="k">Goroutines</div><div class="v">${sys.goroutines ?? '-'}</div></div>
<div class="metric"><div class="k">Alloc (MB)</div><div class="v">${(sys.memory_alloc_mb || 0).toFixed(1)}</div></div>
<div class="metric"><div class="k">System Mem (MB)</div><div class="v">${(sys.memory_sys_mb || 0).toFixed(1)}</div></div>
<div class="metric"><div class="k">CPU Cores</div><div class="v">${sys.cpu_cores ?? '-'}</div></div>
<div class="metric"><div class="k">Disk Free / Total (GB)</div><div class="v">${(sys.disk_free_gb || 0).toFixed(1)} / ${(sys.disk_total_gb || 0).toFixed(1)}</div></div>
`;
document.getElementById('online').innerHTML = `
<p>Active streams: <b>${stream.active_stream_count ?? 0}</b></p>
<p>Active chat rooms: <b>${chat.room_count ?? 0}</b></p>
<p>Connected chat clients: <b>${chat.total_connected_client ?? 0}</b></p>
<div class="mono">Stream paths: ${(stream.active_stream_paths || []).join(', ') || 'none'}</div>
`;
}
async function loadHealth() {
const h = await api('/api/admin/health');
const dbOk = h.db && h.db.ok;
document.getElementById('health').innerHTML =
`<div class="health-chip"><div class="k">API</div><span class="pill ${h.api ? 'ok' : 'off'}">${h.api ? 'UP' : 'DOWN'}</span></div>` +
`<div class="health-chip"><div class="k">RTMP</div><span class="pill ${h.rtmp ? 'ok' : 'off'}">${h.rtmp ? 'UP' : 'DOWN'}</span></div>` +
`<div class="health-chip"><div class="k">Database</div><span class="pill ${dbOk ? 'ok' : 'off'}">${dbOk ? 'UP' : 'DOWN'}</span></div>`;
}
async function loadHistory() {
const level = encodeURIComponent(document.getElementById('logLevel').value || '');
const keyword = encodeURIComponent(document.getElementById('logKeyword').value || '');
const data = await api(`/api/admin/logs?page=1&page_size=100&level=${level}&keyword=${keyword}`);
const items = data.items || [];
const box = document.getElementById('logs');
box.textContent = '';
items.forEach(it => addLogLine(`[${it.time}] [${it.level}] ${it.message}`));
}
function connectLiveLogs() {
disconnectLogs();
evt = new EventSource('/api/admin/logs/stream', { withCredentials: true });
evt.onmessage = () => {};
evt.addEventListener('log', (e) => {
const item = JSON.parse(e.data);
addLogLine(`[${item.time}] [${item.level}] ${item.message}`);
});
evt.onerror = () => {
addLogLine('[warn] Live log stream disconnected.');
};
}
async function loadUsers() {
const keyword = encodeURIComponent(document.getElementById('userKeyword').value || '');
const data = await api(`/api/admin/users?page=1&page_size=50&keyword=${keyword}`);
const body = document.getElementById('usersBody');
body.innerHTML = '';
(data.items || []).forEach((u) => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${u.id}</td>
<td>${u.username}</td>
<td><span class="pill ${u.role === 'admin' ? 'admin' : 'user'}">${u.role}</span></td>
<td><span class="pill ${u.enabled ? 'ok' : 'off'}">${u.enabled ? 'enabled' : 'disabled'}</span></td>
<td>${new Date(u.created_at).toLocaleString()}</td>
<td class="actions">
<button class="subtle" onclick="toggleRole(${u.id}, '${u.role}')">Toggle Role</button>
<button class="subtle" onclick="toggleEnabled(${u.id}, ${u.enabled})">Enable / Disable</button>
<button class="secondary" onclick="resetPwd(${u.id})">Reset Password</button>
<button class="danger" onclick="deleteUser(${u.id})">Delete</button>
</td>
`;
body.appendChild(tr);
});
}
async function toggleRole(id, role) {
const next = role === 'admin' ? 'user' : 'admin';
await api(`/api/admin/users/${id}/role`, {
method: 'PATCH',
body: JSON.stringify({ role: next }),
});
await loadUsers();
}
async function toggleEnabled(id, enabled) {
await api(`/api/admin/users/${id}/enabled`, {
method: 'PATCH',
body: JSON.stringify({ enabled: !enabled }),
});
await loadUsers();
}
async function resetPwd(id) {
const newPwd = prompt('Enter a new password for this user');
if (!newPwd) return;
await api(`/api/admin/users/${id}/reset-password`, {
method: 'POST',
body: JSON.stringify({ new_password: newPwd }),
});
addLogLine(`[audit] password reset requested for user ${id}`);
}
async function deleteUser(id) {
if (!confirm('Delete this user account?')) return;
await api(`/api/admin/users/${id}`, { method: 'DELETE' });
await loadUsers();
}
async function changePassword() {
const oldPassword = document.getElementById('oldPassword').value;
const newPassword = document.getElementById('newPassword').value;
await api('/api/user/change-password', {
method: 'POST',
body: JSON.stringify({
old_password: oldPassword,
new_password: newPassword,
}),
});
document.getElementById('oldPassword').value = '';
document.getElementById('newPassword').value = '';
alert('Password updated successfully.');
}
window.addEventListener('load', ensureSession);
</script>
</body>
</html>

View File

@@ -0,0 +1,332 @@
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
roomQueueBufferSize = 2048
historyLimit = 100
)
type Message struct {
Type string `json:"type"` // "chat", "system", "danmaku", "stream_end"
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 {
mutex sync.RWMutex
rooms map[string]*roomHub
}
type roomHub struct {
roomID string
manager *Hub
register chan *Client
unregister chan *Client
broadcast chan Message
clearHistory chan struct{}
stop chan struct{}
stopOnce sync.Once
mutex sync.RWMutex
clients map[*Client]struct{}
history []Message
}
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{
rooms: make(map[string]*roomHub),
}
}
func (h *Hub) getRoom(roomID string) *roomHub {
h.mutex.RLock()
room := h.rooms[roomID]
h.mutex.RUnlock()
return room
}
func (h *Hub) getOrCreateRoom(roomID string) *roomHub {
if room := h.getRoom(roomID); room != nil {
return room
}
h.mutex.Lock()
defer h.mutex.Unlock()
if room := h.rooms[roomID]; room != nil {
return room
}
room := &roomHub{
roomID: roomID,
manager: h,
register: make(chan *Client, roomQueueBufferSize),
unregister: make(chan *Client, roomQueueBufferSize),
broadcast: make(chan Message, roomQueueBufferSize),
clearHistory: make(chan struct{}, 1),
stop: make(chan struct{}),
clients: make(map[*Client]struct{}),
}
h.rooms[roomID] = room
go room.run()
return room
}
func (h *Hub) deleteRoomIfIdle(room *roomHub) {
room.mutex.RLock()
idle := len(room.clients) == 0 && len(room.history) == 0
room.mutex.RUnlock()
if !idle {
return
}
h.mutex.Lock()
if current := h.rooms[room.roomID]; current == room {
delete(h.rooms, room.roomID)
room.stopOnce.Do(func() {
close(room.stop)
})
}
h.mutex.Unlock()
}
func (r *roomHub) run() {
for {
select {
case client := <-r.register:
r.handleRegister(client)
case client := <-r.unregister:
r.handleUnregister(client)
case message := <-r.broadcast:
r.handleBroadcast(message)
case <-r.clearHistory:
r.handleClearHistory()
case <-r.stop:
return
}
}
}
func (r *roomHub) handleRegister(client *Client) {
r.mutex.RLock()
historyCopy := make([]Message, len(r.history))
copy(historyCopy, r.history)
r.mutex.RUnlock()
for _, msg := range historyCopy {
msg.IsHistory = true
msgBytes, err := json.Marshal(msg)
if err != nil {
continue
}
select {
case client.Send <- msgBytes:
default:
}
}
r.mutex.Lock()
r.clients[client] = struct{}{}
r.mutex.Unlock()
}
func (r *roomHub) handleUnregister(client *Client) {
r.mutex.Lock()
if _, ok := r.clients[client]; ok {
delete(r.clients, client)
close(client.Send)
}
r.mutex.Unlock()
r.manager.deleteRoomIfIdle(r)
}
func (r *roomHub) handleBroadcast(message Message) {
msgBytes, err := json.Marshal(message)
if err != nil {
return
}
r.mutex.Lock()
if message.Type == "chat" || message.Type == "danmaku" {
r.history = append(r.history, message)
if len(r.history) > historyLimit {
r.history = r.history[1:]
}
}
for client := range r.clients {
select {
case client.Send <- msgBytes:
default:
close(client.Send)
delete(r.clients, client)
}
}
shouldDeleteIfIdle := message.Type == "stream_end"
if shouldDeleteIfIdle {
r.history = nil
}
r.mutex.Unlock()
if shouldDeleteIfIdle {
r.manager.deleteRoomIfIdle(r)
}
}
func (r *roomHub) handleClearHistory() {
r.mutex.Lock()
r.history = nil
r.mutex.Unlock()
r.manager.deleteRoomIfIdle(r)
}
func (h *Hub) ClearRoomHistory(roomID string) {
if room := h.getRoom(roomID); room != nil {
select {
case room.clearHistory <- struct{}{}:
default:
}
}
}
func (h *Hub) RegisterClient(c *Client) {
h.getOrCreateRoom(c.RoomID).register <- c
}
func (h *Hub) UnregisterClient(c *Client) {
if room := h.getRoom(c.RoomID); room != nil {
room.unregister <- c
}
}
func (h *Hub) BroadcastToRoom(msg Message) {
h.getOrCreateRoom(msg.RoomID).broadcast <- msg
}
func (h *Hub) NotifyStreamEnded(roomID string) {
h.BroadcastToRoom(Message{
Type: "stream_end",
Username: "System",
Content: "The host has ended the live stream.",
RoomID: roomID,
})
}
func (c *Client) ReadPump() {
defer func() {
c.Hub.UnregisterClient(c)
c.Conn.Close()
}()
c.Conn.SetReadLimit(maxMessageSize)
c.Conn.SetReadDeadline(time.Now().Add(pongWait))
c.Conn.SetPongHandler(func(string) error {
c.Conn.SetReadDeadline(time.Now().Add(pongWait))
return nil
})
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.BroadcastToRoom(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
}
if _, err := w.Write(message); err != nil {
_ = w.Close()
return
}
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()
}
func (h *Hub) GetStatsSnapshot() StatsSnapshot {
h.mutex.RLock()
rooms := make([]*roomHub, 0, len(h.rooms))
roomClients := make(map[string]int, len(h.rooms))
for roomID, room := range h.rooms {
rooms = append(rooms, room)
roomClients[roomID] = 0
}
h.mutex.RUnlock()
totalClients := 0
for _, room := range rooms {
room.mutex.RLock()
count := len(room.clients)
room.mutex.RUnlock()
roomClients[room.roomID] = count
totalClients += count
}
return StatsSnapshot{
RoomCount: len(rooms),
TotalConnectedClient: totalClients,
RoomClients: roomClients,
}
}

View File

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

View File

@@ -3,6 +3,7 @@ package db
import ( import (
"log" "log"
"os" "os"
"runtime"
"time" "time"
"gorm.io/driver/sqlite" "gorm.io/driver/sqlite"
@@ -10,6 +11,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,27 +22,124 @@ 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
}, },
) )
var err error var err error
// Use SQLite database stored in a local file named "hightube.db" // Use SQLite database stored in a local file named "hightube.db" with WAL mode and busy timeout enabled
DB, err = gorm.Open(sqlite.Open("hightube.db"), &gorm.Config{ DB, err = gorm.Open(sqlite.Open("hightube.db?_journal_mode=WAL&_busy_timeout=5000"), &gorm.Config{
Logger: newLogger, Logger: newLogger,
}) })
if err != nil { if err != nil {
log.Fatalf("Failed to connect database: %v", err) log.Fatalf("Failed to connect database: %v", err)
} }
sqlDB, err := DB.DB()
if err != nil {
log.Fatalf("Failed to get database instance: %v", err)
}
maxOpen := runtime.NumCPU()*2 + 1
if maxOpen < 4 {
maxOpen = 4
}
if maxOpen > 32 {
maxOpen = 32
}
sqlDB.SetMaxOpenConns(maxOpen)
sqlDB.SetMaxIdleConns(maxOpen)
sqlDB.SetConnMaxIdleTime(10 * time.Minute)
sqlDB.SetConnMaxLifetime(time.Hour)
for _, pragma := range []string{
"PRAGMA synchronous=NORMAL",
"PRAGMA temp_store=MEMORY",
"PRAGMA foreign_keys=ON",
} {
if execErr := DB.Exec(pragma).Error; execErr != nil {
log.Fatalf("Failed to apply %s: %v", pragma, execErr)
}
}
// Auto-migrate the schema // Auto-migrate the schema
err = DB.AutoMigrate(&model.User{}, &model.Room{}) err = DB.AutoMigrate(&model.User{}, &model.Room{})
if err != nil { if err != nil {
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 with WAL mode and tuned SQLite pragmas")
}
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"
user.Role = "admin"
}
if !user.Enabled {
updates["enabled"] = true
user.Enabled = true
}
if len(updates) > 0 {
DB.Model(&user).Updates(updates)
monitor.Warnf("Admin account normalized for username=%s", adminUsername)
}
cacheUser(user)
return
}
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)
}
cacheUser(newAdmin)
if room.ID != 0 {
cacheRoom(room)
}
monitor.Warnf("Default admin created for username=%s; change the password after first login", adminUsername)
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,18 +1,31 @@
package stream package stream
import ( import (
"bufio"
"context"
"crypto/rand"
"encoding/hex"
"fmt" "fmt"
"io" "io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings" "strings"
"sync" "sync"
"time"
"github.com/gin-gonic/gin"
"github.com/nareix/joy4/av"
"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/monitor"
) )
func init() { func init() {
@@ -22,67 +35,148 @@ func init() {
// RTMPServer manages all active live streams // RTMPServer manages all active live streams
type RTMPServer struct { type RTMPServer struct {
server *rtmp.Server server *rtmp.Server
channels map[string]*pubsub.Queue channels map[string]*pubsub.Queue
mutex sync.RWMutex transcoders map[string][]*variantTranscoder
thumbnailJobs map[string]context.CancelFunc
internalPublishKey string
rtmpPort string
thumbnailDir string
mutex sync.RWMutex
} }
// NewRTMPServer creates and initializes a new media server type variantTranscoder struct {
func NewRTMPServer() *RTMPServer { quality string
cancel context.CancelFunc
cmd *exec.Cmd
}
type qualityProfile struct {
scale string
videoBitrate string
audioBitrate string
}
var qualityOrder = []string{"source", "720p", "480p"}
const thumbnailCaptureInterval = 12 * time.Second
var supportedQualities = map[string]qualityProfile{
"720p": {
scale: "1280:-2",
videoBitrate: "2500k",
audioBitrate: "128k",
},
"480p": {
scale: "854:-2",
videoBitrate: "1200k",
audioBitrate: "96k",
},
}
type writeFlusher struct {
httpFlusher http.Flusher
io.Writer
}
func (w writeFlusher) Flush() error {
w.httpFlusher.Flush()
return nil
}
type bufferedWriteFlusher struct {
bufw *bufio.Writer
httpFlusher http.Flusher
}
func (w *bufferedWriteFlusher) Write(p []byte) (n int, err error) {
return w.bufw.Write(p)
}
func (w *bufferedWriteFlusher) Flush() error {
if err := w.bufw.Flush(); err != nil {
return err
}
w.httpFlusher.Flush()
return nil
}
// NewRTMPServer creates and initializes a new media server.
func NewRTMPServer(rtmpPort string) *RTMPServer {
s := &RTMPServer{ s := &RTMPServer{
channels: make(map[string]*pubsub.Queue), channels: make(map[string]*pubsub.Queue),
server: &rtmp.Server{}, transcoders: make(map[string][]*variantTranscoder),
thumbnailJobs: make(map[string]context.CancelFunc),
internalPublishKey: generateInternalPublishKey(),
rtmpPort: rtmpPort,
thumbnailDir: filepath.Join(os.TempDir(), "hightube-thumbnails"),
server: &rtmp.Server{},
} }
// Triggered when a broadcaster (e.g., OBS) starts publishing // Triggered when a broadcaster (e.g., OBS) starts publishing
s.server.HandlePublish = func(conn *rtmp.Conn) { s.server.HandlePublish = func(conn *rtmp.Conn) {
streamPath := conn.URL.Path // Expected format: /live/{stream_key} streamPath := conn.URL.Path // Expected format: /live/{stream_key} or /variant/{room_id}/{quality}/{token}
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 {
if err := db.SetRoomActive(roomIDUint, true); err != nil {
monitor.Warnf("Failed to mark room active room_id=%s: %v", roomID, err)
}
}
s.startVariantTranscoders(roomID)
s.startThumbnailCapture(roomID)
}
// 3. Cleanup on end // 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)
s.stopThumbnailCapture(roomID)
roomIDUint := parseRoomID(roomID)
if roomIDUint != 0 {
if err := db.SetRoomActive(roomIDUint, false); err != nil {
monitor.Warnf("Failed to mark room inactive room_id=%s: %v", roomID, err)
}
}
chat.MainHub.NotifyStreamEnded(roomID)
monitor.Infof("Publishing ended for room_id=%s", roomID)
} else {
monitor.Infof("Variant publishing ended for room_id=%s path=%s", roomID, channelPath)
}
}() }()
// 4. Continuously copy data packets to our broadcast queue // 4. Continuously copy data packets to our broadcast queue
@@ -92,7 +186,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 +194,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 +204,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 +212,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 +225,345 @@ 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()
// Coalesce the 3 internal write calls of WriteTag using a 4KB bufio.Writer
bufWriter := bufio.NewWriterSize(c.Writer, 4096)
bwf := &bufferedWriteFlusher{
bufw: bufWriter,
httpFlusher: flusher,
}
muxer := flv.NewMuxerWriteFlusher(bwf)
cursor := q.Latest()
// Write header first
streams, err := cursor.Streams()
if err != nil {
monitor.Errorf("HTTP-FLV failed to get cursor streams: %v", err)
return
}
if err = muxer.WriteHeader(streams); err != nil {
monitor.Errorf("HTTP-FLV failed to write header: %v", err)
return
}
if err = bwf.Flush(); err != nil {
return
}
// Read and write packet loop with per-packet flushing for low latency
for {
var pkt av.Packet
pkt, err = cursor.ReadPacket()
if err != nil {
break
}
if err = muxer.WritePacket(pkt); err != nil {
break
}
// Flush immediately so the frame is sent to the client (grouped write syscall)
if err = bwf.Flush(); err != nil {
break
}
}
if err != nil && err != io.EOF {
errStr := err.Error()
if strings.Contains(errStr, "broken pipe") || strings.Contains(errStr, "connection reset by peer") {
monitor.Infof("HTTP-FLV viewer disconnected: %s", streamPath)
return
}
monitor.Errorf("HTTP-FLV playback error on %s: %v", streamPath, err)
}
}
func (s *RTMPServer) HandleThumbnail(c *gin.Context) {
thumbnailPath := s.thumbnailPath(c.Param("room_id"))
if _, err := os.Stat(thumbnailPath); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Thumbnail not found"})
return
}
c.Header("Cache-Control", "no-store, no-cache, must-revalidate")
c.File(thumbnailPath)
}
func (s *RTMPServer) resolvePublishPath(parts []string) (roomID string, channelPath string, isSource bool, ok bool) {
if parts[1] == "live" && len(parts) == 3 {
room, err := db.LoadRoomByStreamKey(parts[2])
if err != nil {
return "", "", false, false
}
roomID = fmt.Sprintf("%d", room.ID)
channelPath = fmt.Sprintf("/live/%s", roomID)
return roomID, channelPath, true, true
}
if parts[1] == "variant" && len(parts) == 5 {
roomID = parts[2]
quality := normalizeQuality(parts[3])
token := parts[4]
if quality == "" || token != s.internalPublishKey {
return "", "", false, false
}
return roomID, fmt.Sprintf("/live/%s/%s", roomID, quality), false, true
}
return "", "", false, false
}
func (s *RTMPServer) startVariantTranscoders(roomID string) {
s.stopVariantTranscoders(roomID)
launch := make([]*variantTranscoder, 0, len(supportedQualities))
for quality, profile := range supportedQualities {
ctx, cancel := context.WithCancel(context.Background())
inputURL := fmt.Sprintf("rtmp://127.0.0.1:%s/live/%s", s.rtmpPort, roomID)
outputURL := fmt.Sprintf("rtmp://127.0.0.1:%s/variant/%s/%s/%s", s.rtmpPort, roomID, quality, s.internalPublishKey)
cmd := exec.CommandContext(
ctx,
"ffmpeg",
"-nostdin",
"-loglevel", "error",
"-fflags", "nobuffer",
"-i", inputURL,
"-vf", "scale="+profile.scale+":force_original_aspect_ratio=decrease",
"-c:v", "libx264",
"-preset", "ultrafast",
"-tune", "zerolatency",
"-g", "48",
"-keyint_min", "48",
"-sc_threshold", "0",
"-b:v", profile.videoBitrate,
"-maxrate", profile.videoBitrate,
"-bufsize", profile.videoBitrate,
"-c:a", "aac",
"-b:a", profile.audioBitrate,
"-ar", "44100",
"-ac", "2",
"-f", "flv",
outputURL,
)
transcoder := &variantTranscoder{
quality: quality,
cancel: cancel,
cmd: cmd,
}
launch = append(launch, transcoder)
go func(roomID string, tr *variantTranscoder) {
time.Sleep(2 * time.Second)
monitor.Infof("Starting transcoder room_id=%s quality=%s", roomID, tr.quality)
if err := tr.cmd.Start(); err != nil {
monitor.Errorf("Failed to start transcoder room_id=%s quality=%s: %v", roomID, tr.quality, err)
return
}
if err := tr.cmd.Wait(); err != nil && ctx.Err() == nil {
monitor.Warnf("Transcoder exited room_id=%s quality=%s: %v", roomID, tr.quality, err)
}
}(roomID, transcoder)
}
s.mutex.Lock()
s.transcoders[roomID] = launch
s.mutex.Unlock()
}
func (s *RTMPServer) stopVariantTranscoders(roomID string) {
s.mutex.Lock()
transcoders := s.transcoders[roomID]
delete(s.transcoders, roomID)
s.mutex.Unlock()
for _, transcoder := range transcoders {
transcoder.cancel()
}
}
func (s *RTMPServer) startThumbnailCapture(roomID string) {
s.stopThumbnailCapture(roomID)
if err := os.MkdirAll(s.thumbnailDir, 0o755); err != nil {
monitor.Errorf("Failed to create thumbnail directory: %v", err)
return
}
ctx, cancel := context.WithCancel(context.Background())
s.mutex.Lock()
s.thumbnailJobs[roomID] = cancel
s.mutex.Unlock()
go func() {
select {
case <-ctx.Done():
return
case <-time.After(3 * time.Second):
}
s.captureThumbnail(roomID)
ticker := time.NewTicker(thumbnailCaptureInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.captureThumbnail(roomID)
}
}
}()
}
func (s *RTMPServer) stopThumbnailCapture(roomID string) {
s.mutex.Lock()
cancel := s.thumbnailJobs[roomID]
delete(s.thumbnailJobs, roomID)
s.mutex.Unlock()
if cancel != nil {
cancel()
}
_ = os.Remove(s.thumbnailPath(roomID))
}
func (s *RTMPServer) captureThumbnail(roomID string) {
outputPath := s.thumbnailPath(roomID)
tempPath := outputPath + ".tmp.jpg"
defer os.Remove(tempPath)
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
cmd := exec.CommandContext(
ctx,
"ffmpeg",
"-y",
"-loglevel", "error",
"-rtmp_live", "live",
"-i", fmt.Sprintf("rtmp://127.0.0.1:%s/live/%s", s.rtmpPort, roomID),
"-frames:v", "1",
"-q:v", "4",
tempPath,
)
if err := cmd.Run(); err != nil {
if ctx.Err() == context.DeadlineExceeded {
monitor.Warnf("Thumbnail capture timed out for room_id=%s", roomID)
return
}
monitor.Warnf("Thumbnail capture failed for room_id=%s: %v", roomID, err)
return
}
_ = os.Remove(outputPath)
if err := os.Rename(tempPath, outputPath); err != nil {
monitor.Warnf("Failed to store thumbnail for room_id=%s: %v", roomID, err)
}
}
func (s *RTMPServer) thumbnailPath(roomID string) string {
return filepath.Join(s.thumbnailDir, fmt.Sprintf("%s.jpg", roomID))
}
func normalizeQuality(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
if _, ok := supportedQualities[value]; ok {
return value
}
return ""
}
func parseRoomID(value string) uint {
var roomID uint
_, _ = fmt.Sscanf(value, "%d", &roomID)
return roomID
}
func generateInternalPublishKey() string {
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
return "internal_publish_fallback_key"
}
return hex.EncodeToString(buf)
}
func (s *RTMPServer) ActiveStreamCount() int {
s.mutex.RLock()
defer s.mutex.RUnlock()
count := 0
for path := range s.channels {
if strings.Count(path, "/") == 2 {
count++
}
}
return count
}
func (s *RTMPServer) AvailablePlaybackQualities(roomID string) []string {
s.mutex.RLock()
defer s.mutex.RUnlock()
basePath := fmt.Sprintf("/live/%s", roomID)
available := make([]string, 0, len(qualityOrder))
for _, quality := range qualityOrder {
streamPath := basePath
if quality != "source" {
streamPath = fmt.Sprintf("%s/%s", basePath, quality)
}
if _, ok := s.channels[streamPath]; ok {
available = append(available, quality)
}
}
return available
}
func (s *RTMPServer) ActiveStreamPaths() []string {
s.mutex.RLock()
defer s.mutex.RUnlock()
paths := make([]string, 0, len(s.channels))
for path := range s.channels {
if strings.Count(path, "/") == 2 {
paths = append(paths, path)
}
}
return paths
}

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 948 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 681 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 882 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

3
frontend/l10n.yaml Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,15 +1,16 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:fvp/fvp.dart' as fvp; // 使用 as fvp 别名 import 'package:fvp/fvp.dart' as fvp;
import 'providers/auth_provider.dart'; import 'providers/auth_provider.dart';
import 'providers/settings_provider.dart'; import 'providers/settings_provider.dart';
import 'pages/home_page.dart'; import 'pages/home_page.dart';
import 'pages/login_page.dart'; import 'pages/login_page.dart';
import 'l10n/app_localizations.dart';
void main() { void main() {
// 初始化播放器引擎
fvp.registerWith(); fvp.registerWith();
runApp( runApp(
MultiProvider( MultiProvider(
providers: [ providers: [
@@ -22,16 +23,38 @@ 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',
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
locale: settings.locale,
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,
); );

View File

@@ -2,27 +2,106 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../providers/auth_provider.dart'; import '../providers/auth_provider.dart';
import '../providers/settings_provider.dart'; import '../providers/settings_provider.dart';
import '../services/api_service.dart'; import '../services/api_service.dart';
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 {
const HomePage({super.key});
@override @override
_HomePageState createState() => _HomePageState(); State<HomePage> createState() => _HomePageState();
} }
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 l10n = AppLocalizations.of(context)!;
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: [
NavigationRailDestination(
icon: const Icon(Icons.explore),
label: Text(l10n.explore),
),
NavigationRailDestination(
icon: const Icon(Icons.videocam),
label: Text(l10n.console),
),
NavigationRailDestination(
icon: const Icon(Icons.settings),
label: Text(l10n.settings),
),
],
),
Expanded(child: pages[_selectedIndex]),
],
),
bottomNavigationBar: !isWide
? NavigationBar(
selectedIndex: _selectedIndex,
onDestinationSelected: (int index) =>
setState(() => _selectedIndex = index),
destinations: [
NavigationDestination(
icon: const Icon(Icons.explore),
label: l10n.explore,
),
NavigationDestination(
icon: const Icon(Icons.videocam),
label: l10n.console,
),
NavigationDestination(
icon: const Icon(Icons.settings),
label: l10n.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;
String _thumbnailCacheBuster = DateTime.now().millisecondsSinceEpoch
.toString();
@override @override
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 +114,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);
@@ -46,74 +124,259 @@ class _HomePageState extends State<HomePage> {
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'] ?? [];
_thumbnailCacheBuster = DateTime.now().millisecondsSinceEpoch
.toString();
});
} }
} }
} catch (e) { } catch (e) {
if (!isAuto && mounted) { if (!isAuto && mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Failed to load rooms"))); final l10n = AppLocalizations.of(context);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n?.failedToLoadRooms ?? "Failed to load rooms")));
} }
} finally { } finally {
if (!isAuto && mounted) setState(() => _isLoading = false); if (!isAuto && mounted) setState(() => _isLoading = false);
} }
} }
Future<void> _confirmLogout() async {
final l10n = AppLocalizations.of(context)!;
final confirmed = await showDialog<bool>(
context: context,
builder: (context) {
return AlertDialog(
title: Text(l10n.confirmLogout),
content: Text(l10n.confirmLogoutDesc),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(l10n.cancel),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: Text(l10n.logout),
),
],
);
},
);
if (confirmed == true && mounted) {
await context.read<AuthProvider>().logout();
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final settings = context.watch<SettingsProvider>(); final settings = context.watch<SettingsProvider>();
final l10n = AppLocalizations.of(context)!;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text("Hightube Live"), title: Text(l10n.explore, style: const TextStyle(fontWeight: FontWeight.bold)),
actions: [ actions: [
IconButton( IconButton(
icon: Icon(Icons.refresh), icon: const Icon(Icons.refresh),
tooltip: "Manual Refresh",
onPressed: () => _refreshRooms(), onPressed: () => _refreshRooms(),
), ),
IconButton( IconButton(icon: const Icon(Icons.logout), onPressed: _confirmLogout),
icon: Icon(Icons.settings),
tooltip: "Settings",
onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (_) => SettingsPage())),
),
IconButton(
icon: Icon(Icons.logout),
tooltip: "Logout",
onPressed: () => context.read<AuthProvider>().logout(),
),
], ],
), ),
floatingActionButton: FloatingActionButton.extended(
onPressed: widget.onGoLive,
label: Text(l10n.goLive),
icon: const Icon(Icons.videocam),
),
body: RefreshIndicator( body: RefreshIndicator(
onRefresh: _refreshRooms, onRefresh: _refreshRooms,
child: _isLoading && _activeRooms.isEmpty child: _isLoading && _activeRooms.isEmpty
? Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: _activeRooms.isEmpty : _activeRooms.isEmpty
? ListView(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: const 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']), const 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']}"; const SizedBox(height: 16),
Navigator.push( Text(
context, l10n.noActiveRooms,
MaterialPageRoute( style: const TextStyle(color: Colors.grey, fontSize: 16),
builder: (_) => PlayerPage( ),
title: room['title'], ],
rtmpUrl: rtmpUrl, ),
),
),
);
},
);
},
), ),
],
)
: GridView.builder(
padding: const EdgeInsets.all(12),
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 400,
childAspectRatio: 1.2,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
itemCount: _activeRooms.length,
itemBuilder: (context, index) {
final room = _activeRooms[index];
return _buildRoomCard(room, settings, l10n);
},
),
),
);
}
Widget _buildRoomCard(dynamic room, SettingsProvider settings, AppLocalizations l10n) {
final roomId = room['room_id'].toString();
return Card(
elevation: 4,
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: InkWell(
onTap: () {
final playbackUrl = settings.playbackUrl(roomId);
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => PlayerPage(
title: room['title'],
playbackUrl: playbackUrl,
roomId: roomId,
),
),
);
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
flex: 3,
child: Stack(
fit: StackFit.expand,
children: [
settings.livePreviewThumbnailsEnabled
? Image.network(
settings.thumbnailUrl(
roomId,
cacheBuster: _thumbnailCacheBuster,
),
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) =>
_buildRoomPreviewFallback(),
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) {
return child;
}
return Stack(
fit: StackFit.expand,
children: [
_buildRoomPreviewFallback(),
const Center(
child: SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
strokeWidth: 2,
),
),
),
],
);
},
)
: _buildRoomPreviewFallback(),
Positioned(
top: 8,
left: 8,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(4),
),
child: const 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)),
),
const SizedBox(width: 12),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
room['title'],
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
Text(
"${l10n.hostId}: ${room['user_id']}",
style: const TextStyle(fontSize: 12, color: Colors.grey),
),
],
),
),
],
),
),
),
],
),
),
);
}
Widget _buildRoomPreviewFallback() {
return Container(
color: Theme.of(context).colorScheme.primaryContainer,
child: Center(
child: Icon(
Icons.live_tv,
size: 50,
color: Theme.of(context).colorScheme.primary,
),
), ),
); );
} }

View File

@@ -1,22 +1,43 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../providers/auth_provider.dart'; import '../providers/auth_provider.dart';
import '../providers/settings_provider.dart'; import '../providers/settings_provider.dart';
import '../services/api_service.dart'; import '../services/api_service.dart';
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(); State<LoginPage> createState() => _LoginPageState();
} }
class _LoginPageState extends State<LoginPage> { class _LoginPageState extends State<LoginPage> {
final _usernameController = TextEditingController(); final _usernameController = TextEditingController();
final _passwordController = TextEditingController(); final _passwordController = TextEditingController();
final _passwordFocusNode = FocusNode();
bool _isLoading = false; bool _isLoading = false;
@override
void dispose() {
_usernameController.dispose();
_passwordController.dispose();
_passwordFocusNode.dispose();
super.dispose();
}
void _handleLogin() async { void _handleLogin() async {
final l10n = AppLocalizations.of(context)!;
if (_usernameController.text.isEmpty || _passwordController.text.isEmpty) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.fillAllFields)));
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 +48,142 @@ 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 {
final error = jsonDecode(response.body)['error'] ?? "Login Failed"; if (!mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(error))); return;
}
final error = jsonDecode(response.body)['error'] ?? l10n.loginFailed;
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(l10n.networkError)));
} finally { } finally {
setState(() => _isLoading = false); if (mounted) setState(() => _isLoading = false);
} }
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(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: const 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: const BoxConstraints(maxWidth: 400),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Logo & Name
Icon(
Icons.flutter_dash,
size: 80,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 16),
Text(
"HIGHTUBE",
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
letterSpacing: 4,
color: Theme.of(context).colorScheme.primary,
),
),
const Text(
"Open Source Live Platform",
style: TextStyle(color: Colors.grey),
),
const SizedBox(height: 48),
// Fields
TextField(
controller: _usernameController,
textInputAction: TextInputAction.next,
onSubmitted: (_) => _passwordFocusNode.requestFocus(),
decoration: InputDecoration(
labelText: l10n.username,
prefixIcon: const Icon(Icons.person),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
const SizedBox(height: 16),
TextField(
controller: _passwordController,
focusNode: _passwordFocusNode,
obscureText: true,
textInputAction: TextInputAction.done,
onSubmitted: (_) {
if (!_isLoading) {
_handleLogin();
}
},
decoration: InputDecoration(
labelText: l10n.password,
prefixIcon: const Icon(Icons.lock),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
const 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
? const CircularProgressIndicator()
: Text(
l10n.login,
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
),
const SizedBox(height: 16),
// Register Link
TextButton(
onPressed: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const RegisterPage()),
),
child: Text(l10n.dontHaveAccount),
),
],
),
),
), ),
), ),
); );

View File

@@ -0,0 +1,158 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:flutter/services.dart';
import '../l10n/app_localizations.dart';
import '../providers/auth_provider.dart';
import '../providers/settings_provider.dart';
import '../services/api_service.dart';
import '../widgets/android_quick_stream_panel.dart';
class MyStreamPage extends StatefulWidget {
const MyStreamPage({super.key});
@override
State<MyStreamPage> 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 (!mounted) {
return;
}
if (response.statusCode == 200) {
setState(() => _roomInfo = jsonDecode(response.body));
}
} catch (e) {
if (!mounted) {
return;
}
final l10n = AppLocalizations.of(context);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n?.failedToFetchRoomInfo ?? "Failed to fetch room info")));
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
@override
Widget build(BuildContext context) {
final settings = context.watch<SettingsProvider>();
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(title: Text(l10n.myStreamConsole)),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _roomInfo == null
? Center(child: Text(l10n.noRoomInfo))
: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildInfoCard(
title: l10n.roomTitle,
value: _roomInfo!['title'],
icon: Icons.edit,
onTap: () {
// TODO: Implement title update API later
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("Title editing coming soon!")),
);
},
),
const SizedBox(height: 20),
_buildInfoCard(
title: l10n.rtmpServerUrl,
value: settings.rtmpUrl,
icon: Icons.copy,
onTap: () {
Clipboard.setData(ClipboardData(text: settings.rtmpUrl));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.copiedToClipboard),
),
);
},
),
const SizedBox(height: 20),
_buildInfoCard(
title: l10n.streamKey,
value: _roomInfo!['stream_key'],
icon: Icons.copy,
isSecret: true,
onTap: () {
Clipboard.setData(
ClipboardData(text: _roomInfo!['stream_key']),
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.copiedToClipboard),
),
);
},
),
const SizedBox(height: 24),
AndroidQuickStreamPanel(
rtmpBaseUrl: settings.rtmpUrl,
streamKey: _roomInfo!['stream_key'],
),
const SizedBox(height: 30),
const Center(
child: Column(
children: [
Icon(Icons.info_outline, color: Colors.grey),
SizedBox(height: 8),
Text(
"Use OBS or other tools to stream to this address.",
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey, fontSize: 12),
),
],
),
),
],
),
),
);
}
Widget _buildInfoCard({
required String title,
required String value,
required IconData icon,
bool isSecret = false,
VoidCallback? onTap,
}) {
return Card(
child: ListTile(
title: Text(title, style: const TextStyle(fontSize: 12, color: Colors.grey)),
subtitle: Text(
isSecret ? "••••••••••••••••" : value,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
trailing: IconButton(icon: Icon(icon), onPressed: onTap),
),
);
}
}

View File

@@ -1,82 +1,892 @@
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 '../l10n/app_localizations.dart';
import '../providers/auth_provider.dart';
import '../providers/settings_provider.dart';
import '../services/api_service.dart';
import '../services/chat_service.dart';
import '../widgets/web_stream_player.dart';
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;
bool _streamEnded = false;
double _volume = kIsWeb ? 0.0 : 1.0;
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(); await _controller!.setVolume(_volume);
setState(() {}); // 更新状态以渲染画面 _controller!.play();
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) {
if (msg.type == "stream_end") {
_handleStreamEnded(msg.content);
return;
}
setState(() {
_messages.insert(0, msg);
if (!msg.isHistory && (msg.type == "chat" || msg.type == "danmaku")) {
if (_showDanmaku) {
_addDanmaku(msg.content);
}
}
});
}
});
}
void _handleStreamEnded(String message) {
if (_streamEnded) {
return;
}
final l10n = AppLocalizations.of(context)!;
final streamEndedMessage = l10n.liveStreamEnded;
setState(() {
_streamEnded = true;
_isRefreshing = false;
_danmakus.clear();
_messages.insert(
0,
ChatMessage(
type: "system",
username: "System",
content: streamEndedMessage,
roomId: widget.roomId,
),
);
if (!kIsWeb) {
_isError = true;
_errorMessage = streamEndedMessage;
}
});
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(streamEndedMessage)));
Future<void>.delayed(const Duration(seconds: 2), () {
if (mounted && Navigator.canPop(context)) {
Navigator.pop(context);
}
});
}
void _addDanmaku(String text) {
final key = UniqueKey();
final lane = DateTime.now().millisecondsSinceEpoch % 8;
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 (!_streamEnded && _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;
}
if (_streamEnded) {
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();
}
Future<void> _setVolume(double volume) async {
final nextVolume = volume.clamp(0.0, 1.0);
if (!mounted) {
return;
}
setState(() => _volume = nextVolume);
if (!kIsWeb && _controller != null) {
await _controller!.setVolume(nextVolume);
}
}
Future<void> _openVolumeSheet() async {
_showControls();
final l10n = AppLocalizations.of(context)!;
await showModalBottomSheet<void>(
context: context,
builder: (context) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 28),
child: StatefulBuilder(
builder: (context, setSheetState) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.volume,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
Row(
children: [
Icon(
_volume == 0
? Icons.volume_off
: _volume < 0.5
? Icons.volume_down
: Icons.volume_up,
),
Expanded(
child: Slider(
value: _volume,
min: 0,
max: 1,
divisions: 20,
label: '${(_volume * 100).round()}%',
onChanged: (value) {
setSheetState(() => _volume = value);
_setVolume(value);
},
),
),
SizedBox(
width: 48,
child: Text('${(_volume * 100).round()}%'),
),
],
),
],
);
},
),
),
);
},
);
}
void _showControls() {
_controlsHideTimer?.cancel();
if (mounted) {
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 l10n = AppLocalizations.of(context)!;
final nextResolution = await showModalBottomSheet<String>(
context: context,
builder: (context) {
const options = ['Source', '720p', '480p'];
final available = _availableResolutions.toSet();
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
title: Text(l10n.playbackResolution),
subtitle: Text(
available.length > 1
? l10n.playbackOptionsDesc
: l10n.sourceOnlyDesc,
),
),
...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
? Text(l10n.availableNow)
: Text(l10n.waitingForTranscoding),
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(),
volume: _volume,
)
: _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() {
final l10n = AppLocalizations.of(context)!;
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: l10n.refresh,
onPressed: _streamEnded ? null : _refreshPlayer,
),
_buildControlButton(
icon: _volume == 0
? Icons.volume_off
: _volume < 0.5
? Icons.volume_down
: Icons.volume_up,
label: l10n.volume,
onPressed: _openVolumeSheet,
),
_buildControlButton(
icon: _showDanmaku ? Icons.subtitles : Icons.subtitles_off,
label: _showDanmaku ? l10n.danmakuOn : l10n.danmakuOff,
onPressed: _toggleDanmaku,
),
_buildControlButton(
icon: _isFullscreen
? Icons.fullscreen_exit
: Icons.fullscreen,
label: _isFullscreen
? l10n.exitFullscreen
: l10n.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: onPressed == null
? null
: () 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() {
final l10n = AppLocalizations.of(context)!;
return Column(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: Row(
children: [
const Icon(Icons.chat_bubble_outline, size: 16),
const SizedBox(width: 8),
Text(
l10n.liveChat,
style: const TextStyle(fontWeight: FontWeight.bold),
),
],
),
),
Expanded(
child: ListView.builder(
reverse: true,
padding: const EdgeInsets.all(8),
itemCount: _messages.length,
itemBuilder: (context, index) {
final m = _messages[index];
return _buildMessageItem(m);
},
),
),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
children: [
Expanded(
child: TextField(
controller: _msgController,
enabled: !_streamEnded,
decoration: InputDecoration(
hintText: _streamEnded
? l10n.liveStreamEndedShort
: l10n.sendMessage,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
),
onSubmitted: (_) => _sendMsg(),
),
),
IconButton(
icon: Icon(
Icons.send,
color: Theme.of(context).colorScheme.primary,
),
onPressed: _streamEnded ? null : _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),
),
],
),
),
);
},
);
}
} }

View File

@@ -1,12 +1,15 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../providers/settings_provider.dart'; import '../providers/settings_provider.dart';
import '../services/api_service.dart'; import '../services/api_service.dart';
class RegisterPage extends StatefulWidget { class RegisterPage extends StatefulWidget {
const RegisterPage({super.key});
@override @override
_RegisterPageState createState() => _RegisterPageState(); State<RegisterPage> createState() => _RegisterPageState();
} }
class _RegisterPageState extends State<RegisterPage> { class _RegisterPageState extends State<RegisterPage> {
@@ -15,6 +18,14 @@ class _RegisterPageState extends State<RegisterPage> {
bool _isLoading = false; bool _isLoading = false;
void _handleRegister() async { void _handleRegister() async {
final l10n = AppLocalizations.of(context)!;
if (_usernameController.text.isEmpty || _passwordController.text.isEmpty) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.fillAllFields)));
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);
@@ -24,34 +35,108 @@ class _RegisterPageState extends State<RegisterPage> {
_usernameController.text, _usernameController.text,
_passwordController.text, _passwordController.text,
); );
if (!mounted) {
return;
}
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(l10n.accountCreated)));
Navigator.pop(context); Navigator.pop(context);
} else { } else {
final error = jsonDecode(response.body)['error'] ?? "Registration Failed"; final error =
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(error))); jsonDecode(response.body)['error'] ?? "Registration Failed";
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(l10n.networkError)));
} finally { } finally {
setState(() => _isLoading = false); if (mounted) setState(() => _isLoading = false);
} }
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold( return Scaffold(
appBar: AppBar(title: Text("Register")), appBar: AppBar(title: Text(l10n.createAccount)),
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: const 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,
),
const SizedBox(height: 24),
Text(
l10n.joinHightube,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 48),
TextField(
controller: _usernameController,
decoration: InputDecoration(
labelText: l10n.desiredUsername,
prefixIcon: const Icon(Icons.person),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
const SizedBox(height: 16),
TextField(
controller: _passwordController,
obscureText: true,
decoration: InputDecoration(
labelText: l10n.password,
prefixIcon: const Icon(Icons.lock),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
height: 50,
child: ElevatedButton(
onPressed: _isLoading ? null : _handleRegister,
style: ElevatedButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: _isLoading
? const CircularProgressIndicator()
: Text(
l10n.register,
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
),
const SizedBox(height: 16),
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(l10n.alreadyHaveAccount),
),
],
),
),
), ),
), ),
); );

View File

@@ -1,48 +1,476 @@
import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../providers/auth_provider.dart';
import '../providers/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(); State<SettingsPage> 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")));
}
}
Future<void> _confirmLogout(AuthProvider auth, AppLocalizations l10n) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) {
return AlertDialog(
title: Text(l10n.confirmLogout),
content: Text(l10n.confirmLogoutDesc),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(l10n.cancel),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: Text(l10n.logout),
),
],
);
},
);
if (confirmed == true && mounted) {
await auth.logout();
}
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final auth = context.watch<AuthProvider>();
final settings = context.watch<SettingsProvider>();
final isAuthenticated = auth.isAuthenticated;
final l10n = AppLocalizations.of(context)!;
return Scaffold( return Scaffold(
appBar: AppBar(title: Text("Server Settings")), appBar: AppBar(
body: Padding( title: Text(
padding: const EdgeInsets.all(16.0), l10n.settings,
style: TextStyle(fontWeight: FontWeight.bold),
),
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(l10n.language),
const SizedBox(height: 16),
DropdownButtonFormField<Locale?>(
initialValue: settings.locale == null
? null
: AppLocalizations.supportedLocales
.cast<Locale?>()
.firstWhere(
(l) =>
l?.languageCode ==
settings.locale?.languageCode &&
l?.scriptCode == settings.locale?.scriptCode,
orElse: () => null,
),
decoration: InputDecoration(
labelText: l10n.selectLanguage,
prefixIcon: const Icon(Icons.language),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
items: [
DropdownMenuItem(value: null, child: Text(l10n.system)),
DropdownMenuItem(
value: const Locale('en'),
child: Text(l10n.english),
),
DropdownMenuItem(
value: const Locale('zh'),
child: Text(l10n.simplifiedChinese),
),
DropdownMenuItem(
value: const Locale.fromSubtags(
languageCode: 'zh',
scriptCode: 'Hant',
),
child: Text(l10n.traditionalChinese),
),
DropdownMenuItem(
value: const Locale('ja'),
child: Text(l10n.japanese),
),
],
onChanged: (Locale? newLocale) {
settings.setLocale(newLocale);
},
),
const SizedBox(height: 32),
_buildSectionTitle(l10n.networkConfiguration),
const SizedBox(height: 16),
TextField( TextField(
controller: _urlController, controller: _urlController,
decoration: InputDecoration( decoration: InputDecoration(
labelText: "Backend URL (e.g., http://127.0.0.1:8080)", labelText: l10n.backendServerUrl,
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(l10n.serverUrlUpdated),
behavior: SnackBarBehavior.floating,
),
);
},
icon: Icon(Icons.save),
label: Text(l10n.saveNetworkSettings),
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(l10n.themeCustomization),
const SizedBox(height: 16),
Text(
l10n.appearanceMode,
style: Theme.of(context).textTheme.labelLarge,
),
const SizedBox(height: 12),
SegmentedButton<ThemeMode>(
segments: [
ButtonSegment<ThemeMode>(
value: ThemeMode.system,
label: Text(l10n.system),
icon: Icon(Icons.brightness_auto),
),
ButtonSegment<ThemeMode>(
value: ThemeMode.light,
label: Text(l10n.light),
icon: Icon(Icons.light_mode),
),
ButtonSegment<ThemeMode>(
value: ThemeMode.dark,
label: Text(l10n.dark),
icon: Icon(Icons.dark_mode),
),
],
selected: {settings.themeMode},
onSelectionChanged: (selection) {
settings.setThemeMode(selection.first);
},
),
const SizedBox(height: 20),
Text(
l10n.accentColor,
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(),
),
const SizedBox(height: 32),
_buildSectionTitle(l10n.explore),
const SizedBox(height: 8),
SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
title: Text(l10n.livePreviewThumbnails),
subtitle: Text(l10n.livePreviewThumbnailsDesc),
value: settings.livePreviewThumbnailsEnabled,
onChanged: settings.setLivePreviewThumbnailsEnabled,
),
if (isAuthenticated) ...[
const SizedBox(height: 32),
_buildSectionTitle(l10n.security),
const SizedBox(height: 16),
TextField(
controller: _oldPasswordController,
obscureText: true,
decoration: InputDecoration(
labelText: l10n.oldPassword,
prefixIcon: const Icon(Icons.lock_outline),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
const SizedBox(height: 12),
TextField(
controller: _newPasswordController,
obscureText: true,
decoration: InputDecoration(
labelText: l10n.newPassword,
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: Text(l10n.changePassword),
style: OutlinedButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: FilledButton.tonalIcon(
onPressed: () => _confirmLogout(auth, l10n),
icon: const Icon(Icons.logout),
label: Text(l10n.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.1", style: TextStyle(color: Colors.grey)),
Text(
"Author: Highground-Soft",
style: TextStyle(color: Colors.grey),
),
SizedBox(height: 20),
Text(
"© 2026 Hightube Project",
style: TextStyle(fontSize: 12, color: Colors.grey),
),
],
),
),
const SizedBox(height: 40),
], ],
), ),
), ),
); );
} }
Widget _buildSectionTitle(String title) {
return Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
);
}
Widget _buildProfileSection(AuthProvider auth) {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
),
child: Row(
children: [
CircleAvatar(
radius: 35,
backgroundColor: Theme.of(context).colorScheme.primary,
child: Text(
(auth.username ?? "U")[0].toUpperCase(),
style: TextStyle(
fontSize: 32,
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 20),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
auth.username ?? "Unknown User",
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
),
Text(
"Self-hosted Streamer",
style: TextStyle(
color: Theme.of(context).colorScheme.outline,
),
),
],
),
),
],
),
);
}
} }

View File

@@ -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();
} }
} }

View File

@@ -1,12 +1,30 @@
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. // On web: use empty string so API calls use same origin (works behind any proxy)
// Using 10.0.2.2 for Android emulator or localhost for Desktop. // On Android emulator: 10.0.2.2 maps to host localhost
String _baseUrl = "http://localhost:8080"; // On other platforms: localhost
static String get _defaultUrl {
if (kIsWeb) return "";
if (defaultTargetPlatform == TargetPlatform.android) {
return "http://10.0.2.2:8080";
}
return "http://localhost:8080";
}
String _baseUrl = _defaultUrl;
Color _themeColor = Colors.blue;
ThemeMode _themeMode = ThemeMode.system;
bool _livePreviewThumbnailsEnabled = false;
Locale? _locale;
String get baseUrl => _baseUrl; String get baseUrl => _baseUrl;
Color get themeColor => _themeColor;
ThemeMode get themeMode => _themeMode;
bool get livePreviewThumbnailsEnabled => _livePreviewThumbnailsEnabled;
Locale? get locale => _locale;
SettingsProvider() { SettingsProvider() {
_loadSettings(); _loadSettings();
@@ -15,6 +33,51 @@ 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);
}
_livePreviewThumbnailsEnabled =
prefs.getBool('livePreviewThumbnailsEnabled') ?? false;
final languageCode = prefs.getString('languageCode');
final scriptCode = prefs.getString('scriptCode');
final countryCode = prefs.getString('countryCode');
if (languageCode != null) {
_locale = Locale.fromSubtags(
languageCode: languageCode,
scriptCode: scriptCode,
countryCode: countryCode,
);
}
notifyListeners();
}
void setLocale(Locale? newLocale) async {
_locale = newLocale;
final prefs = await SharedPreferences.getInstance();
if (newLocale == null) {
await prefs.remove('languageCode');
await prefs.remove('scriptCode');
await prefs.remove('countryCode');
} else {
await prefs.setString('languageCode', newLocale.languageCode);
if (newLocale.scriptCode != null) {
await prefs.setString('scriptCode', newLocale.scriptCode!);
} else {
await prefs.remove('scriptCode');
}
if (newLocale.countryCode != null) {
await prefs.setString('countryCode', newLocale.countryCode!);
} else {
await prefs.remove('countryCode');
}
}
notifyListeners(); notifyListeners();
} }
@@ -24,10 +87,84 @@ class SettingsProvider with ChangeNotifier {
await prefs.setString('baseUrl', url); await prefs.setString('baseUrl', url);
notifyListeners(); notifyListeners();
} }
void setThemeColor(Color color) async {
_themeColor = color;
final prefs = await SharedPreferences.getInstance();
await prefs.setInt('themeColor', color.toARGB32());
notifyListeners();
}
void setThemeMode(ThemeMode mode) async {
_themeMode = mode;
final prefs = await SharedPreferences.getInstance();
await prefs.setString('themeMode', mode.name);
notifyListeners();
}
void setLivePreviewThumbnailsEnabled(bool enabled) async {
_livePreviewThumbnailsEnabled = enabled;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('livePreviewThumbnailsEnabled', enabled);
notifyListeners();
}
// Also provide the RTMP URL based on the same hostname // Also provide the RTMP URL based on the same hostname
String get rtmpUrl { String get rtmpUrl {
final host = _baseUrl.isEmpty ? _effectiveHost : Uri.parse(_baseUrl).host;
return "rtmp://$host:1935/live";
}
// Fallback hostname when baseUrl is empty (web same-origin mode)
String get _effectiveHost {
if (kIsWeb) {
final host = Uri.base.host;
if (host.isNotEmpty) return host;
return 'localhost';
}
return 'localhost';
}
String playbackUrl(String roomId, {String? quality}) {
final uri = Uri.parse(_baseUrl); final uri = Uri.parse(_baseUrl);
return "rtmp://${uri.host}:1935/live"; final normalizedQuality = quality?.trim().toLowerCase();
if (kIsWeb) {
return uri
.replace(
path: '/live/$roomId',
queryParameters:
normalizedQuality == null || normalizedQuality.isEmpty
? null
: {'quality': normalizedQuality},
)
.toString();
}
if (normalizedQuality == null || normalizedQuality.isEmpty) {
return "$rtmpUrl/$roomId";
}
return "$rtmpUrl/$roomId/$normalizedQuality";
}
String thumbnailUrl(String roomId, {String? cacheBuster}) {
final uri = Uri.parse(_baseUrl);
return uri
.replace(
path: '/api/rooms/$roomId/thumbnail',
queryParameters: cacheBuster == null ? null : {'t': cacheBuster},
)
.toString();
}
ThemeMode _themeModeFromString(String value) {
switch (value) {
case 'light':
return ThemeMode.light;
case 'dark':
return ThemeMode.dark;
default:
return ThemeMode.system;
}
} }
} }

View File

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

View File

@@ -0,0 +1,104 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
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 = _webSocketUri(baseUrl).replace(
path: '/api/ws/room/$roomId',
queryParameters: {'username': username},
);
_channel = WebSocketChannel.connect(wsUri);
_channel!.stream.listen(
(data) {
final json = jsonDecode(data);
_messageController.add(ChatMessage.fromJson(json));
},
onError: (err) {
debugPrint("[WS ERROR] $err");
},
onDone: () {
debugPrint("[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()));
}
}
Uri _webSocketUri(String baseUrl) {
if (baseUrl.isEmpty) {
if (kIsWeb) {
return Uri.base.replace(
scheme: Uri.base.scheme == 'https' ? 'wss' : 'ws',
);
}
return Uri.parse('http://localhost:8080');
}
final uri = Uri.parse(baseUrl);
final scheme = uri.scheme == 'https' ? 'wss' : 'ws';
return uri.replace(scheme: scheme);
}
void dispose() {
_channel?.sink.close();
_messageController.close();
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,65 @@
// ignore_for_file: avoid_web_libraries_in_flutter, deprecated_member_use
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 double volume;
final int? refreshToken;
const WebStreamPlayer({
super.key,
required this.streamUrl,
required this.volume,
this.refreshToken,
});
@override
State<WebStreamPlayer> createState() => _WebStreamPlayerState();
}
class _WebStreamPlayerState extends State<WebStreamPlayer> {
late final String _viewType;
html.IFrameElement? _iframe;
@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)}'
'&volume=${widget.volume}'
..style.border = '0'
..style.width = '100%'
..style.height = '100%'
..style.pointerEvents = 'none'
..allow = 'autoplay; fullscreen';
_iframe = iframe;
return iframe;
});
}
@override
void didUpdateWidget(covariant WebStreamPlayer oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.volume != widget.volume) {
_iframe?.contentWindow?.postMessage({
'type': 'setVolume',
'value': widget.volume,
}, '*');
}
}
@override
Widget build(BuildContext context) {
return HtmlElementView(viewType: _viewType);
}
}

View File

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

View File

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

View File

@@ -1,6 +1,22 @@
# Generated by pub # Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile # See https://dart.dev/tools/pub/glossary#lockfile
packages: packages:
archive:
dependency: transitive
description:
name: archive
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
url: "https://pub.dev"
source: hosted
version: "4.0.9"
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async: async:
dependency: transitive dependency: transitive
description: description:
@@ -25,6 +41,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.1" version: "1.4.1"
checked_yaml:
dependency: transitive
description:
name: checked_yaml
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
url: "https://pub.dev"
source: hosted
version: "2.0.4"
cli_util:
dependency: transitive
description:
name: cli_util
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
url: "https://pub.dev"
source: hosted
version: "0.4.2"
clock: clock:
dependency: transitive dependency: transitive
description: description:
@@ -102,6 +134,14 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_launcher_icons:
dependency: "direct dev"
description:
name: flutter_launcher_icons
sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea"
url: "https://pub.dev"
source: hosted
version: "0.13.1"
flutter_lints: flutter_lints:
dependency: "direct dev" dependency: "direct dev"
description: description:
@@ -110,6 +150,11 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.0.0" version: "6.0.0"
flutter_localizations:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_test: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
@@ -168,6 +213,30 @@ 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"
intl:
dependency: "direct main"
description:
name: intl
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
url: "https://pub.dev"
source: hosted
version: "0.20.2"
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 +381,62 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.0" version: "2.3.0"
permission_handler:
dependency: "direct main"
description:
name: permission_handler
sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1
url: "https://pub.dev"
source: hosted
version: "12.0.1"
permission_handler_android:
dependency: transitive
description:
name: permission_handler_android
sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6"
url: "https://pub.dev"
source: hosted
version: "13.0.1"
permission_handler_apple:
dependency: transitive
description:
name: permission_handler_apple
sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023
url: "https://pub.dev"
source: hosted
version: "9.4.7"
permission_handler_html:
dependency: transitive
description:
name: permission_handler_html
sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24"
url: "https://pub.dev"
source: hosted
version: "0.1.3+5"
permission_handler_platform_interface:
dependency: transitive
description:
name: permission_handler_platform_interface
sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878
url: "https://pub.dev"
source: hosted
version: "4.3.0"
permission_handler_windows:
dependency: transitive
description:
name: permission_handler_windows
sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e"
url: "https://pub.dev"
source: hosted
version: "0.2.1"
petitparser:
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 +453,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:
@@ -344,6 +477,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.0" version: "2.2.0"
rtmp_streaming:
dependency: "direct main"
description:
name: rtmp_streaming
sha256: f54c0c0443df65086d2936b0f3432fbb351fc35bffba69aa2b004ee7ecf45d40
url: "https://pub.dev"
source: hosted
version: "1.0.5"
shared_preferences: shared_preferences:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -525,6 +666,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 +690,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.0" version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
url: "https://pub.dev"
source: hosted
version: "6.6.1"
yaml: yaml:
dependency: transitive dependency: transitive
description: description:

View File

@@ -1,5 +1,5 @@
name: frontend name: hightube
description: "A new Flutter project." description: "Open Source Live Platform"
# The following line prevents the package from being accidentally published to # The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages. # pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev publish_to: 'none' # Remove this line if you wish to publish to pub.dev
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+1 version: 1.0.1
environment: environment:
sdk: ^3.11.1 sdk: ^3.11.1
@@ -39,10 +39,17 @@ 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
permission_handler: ^12.0.1
rtmp_streaming: ^1.0.5
intl: ^0.20.2
flutter_localizations:
sdk: flutter
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
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,11 +58,27 @@ 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
# The following section is specific to Flutter packages. # The following section is specific to Flutter packages.
flutter: flutter:
generate: true
# The following line ensures that the Material Icons font is # The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in # included with your application, so that you can use the icons in

View File

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

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

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,134 @@
<!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" autoplay muted playsinline></video>
<div id="message">Loading live stream...</div>
<script>
const params = new URLSearchParams(window.location.search);
const streamUrl = params.get('src');
const initialVolume = Number.parseFloat(params.get('volume') || '1');
const video = document.getElementById('player');
const message = document.getElementById('message');
function applyVolume(value) {
const normalized = Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : 1;
video.volume = normalized;
video.muted = normalized === 0;
}
function showMessage(text) {
video.style.display = 'none';
message.style.display = 'flex';
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 {
applyVolume(initialVolume);
const player = flvjs.createPlayer({
type: 'flv',
url: streamUrl,
isLive: true,
}, {
enableWorker: false,
enableStashBuffer: false,
stashInitialSize: 128,
});
player.attachMediaElement(video);
player.load();
player.play().catch(() => {});
// Live latency auto-catchup logic
video.addEventListener('timeupdate', function() {
if (video.buffered.length > 0) {
const end = video.buffered.end(video.buffered.length - 1);
const diff = end - video.currentTime;
if (diff > 5) {
// If way behind, jump directly close to the live edge
video.currentTime = end - 1.0;
} else if (diff > 1.5) {
// Speed up slightly to catch up
video.playbackRate = 1.15;
} else {
// Reset to normal speed
video.playbackRate = 1.0;
}
}
});
player.on(flvjs.Events.ERROR, function(errorType, detail, info) {
const parts = ['Live stream failed to load.'];
if (errorType) parts.push('type=' + errorType);
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('message', function(event) {
const data = event.data || {};
if (data.type === 'setVolume') {
applyVolume(Number(data.value));
}
});
window.addEventListener('beforeunload', function() {
player.destroy();
});
}
</script>
</body>
</html>

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

395
website/hightube/index.html Normal file

File diff suppressed because one or more lines are too long

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

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

344
website/index.html Normal file
View File

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

774
website/styles.css Normal file
View File

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