From 9c7261cbda8c32b5c32468b04a6d7cff4d82c443 Mon Sep 17 00:00:00 2001 From: CGH0S7 <776459475@qq.com> Date: Wed, 18 Mar 2026 15:30:03 +0800 Subject: [PATCH] Phase 4.0: WebSocket chat and danmaku system implemented --- backend/cmd/server/main.go | 6 +- backend/go.mod | 15 +- backend/go.sum | 13 ++ backend/internal/api/chat_handler.go | 51 ++++++ backend/internal/api/router.go | 3 + backend/internal/chat/hub.go | 160 ++++++++++++++++++ frontend/lib/pages/home_page.dart | 11 +- frontend/lib/pages/player_page.dart | 214 ++++++++++++++++++++---- frontend/lib/services/chat_service.dart | 61 +++++++ frontend/pubspec.lock | 16 ++ frontend/pubspec.yaml | 1 + 11 files changed, 507 insertions(+), 44 deletions(-) create mode 100644 backend/internal/api/chat_handler.go create mode 100644 backend/internal/chat/hub.go create mode 100644 frontend/lib/services/chat_service.dart diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 51f00f1..888d881 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -4,16 +4,20 @@ import ( "log" "hightube/internal/api" + "hightube/internal/chat" "hightube/internal/db" "hightube/internal/stream" ) func main() { - log.Println("Starting Hightube Server Version-1.0.2 ...") + log.Println("Starting Hightube Server (Phase 4)...") // Initialize Database and run auto-migrations db.InitDB() + // Initialize Chat WebSocket Hub + chat.InitChat() + // Start the API server in a goroutine so it doesn't block the RTMP server go func() { r := api.SetupRouter() diff --git a/backend/go.mod b/backend/go.mod index 9054442..adecba0 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -2,7 +2,14 @@ module hightube 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 ( github.com/bytedance/gopkg v0.1.3 // indirect @@ -11,13 +18,12 @@ require ( github.com/cloudwego/base64x v0.1.6 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // 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/universal-translator v0.18.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-yaml v1.19.2 // indirect - github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/gorilla/websocket v1.5.3 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect @@ -34,11 +40,8 @@ require ( github.com/ugorji/go/codec v1.3.1 // indirect go.mongodb.org/mongo-driver/v2 v2.5.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/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect google.golang.org/protobuf v1.36.10 // indirect - gorm.io/driver/sqlite v1.6.0 // indirect - gorm.io/gorm v1.30.0 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index 37eebae..6c8ef99 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -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/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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 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/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-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= 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/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 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/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/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/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/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= 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/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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 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/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.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.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/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/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/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/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= 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= 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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 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/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= diff --git a/backend/internal/api/chat_handler.go b/backend/internal/api/chat_handler.go new file mode 100644 index 0000000..c293e55 --- /dev/null +++ b/backend/internal/api/chat_handler.go @@ -0,0 +1,51 @@ +package api + +import ( + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" + + "hightube/internal/chat" +) + +var upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { + return true // Allow all connections + }, +} + +// WSHandler handles websocket requests from clients +func WSHandler(c *gin.Context) { + roomID := c.Param("room_id") + username := c.DefaultQuery("username", "Anonymous") + + conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + fmt.Printf("[WS ERROR] Failed to upgrade: %v\n", 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, + }) +} diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go index 97510c9..2bb1273 100644 --- a/backend/internal/api/router.go +++ b/backend/internal/api/router.go @@ -18,6 +18,9 @@ func SetupRouter() *gin.Engine { r.POST("/api/register", Register) r.POST("/api/login", Login) r.GET("/api/rooms/active", GetActiveRooms) + + // WebSocket endpoint for live chat + r.GET("/api/ws/room/:room_id", WSHandler) // Protected routes (require JWT) authGroup := r.Group("/api") diff --git a/backend/internal/chat/hub.go b/backend/internal/chat/hub.go new file mode 100644 index 0000000..3d4335b --- /dev/null +++ b/backend/internal/chat/hub.go @@ -0,0 +1,160 @@ +package chat + +import ( + "encoding/json" + "sync" + "time" + + "github.com/gorilla/websocket" +) + +const ( + writeWait = 10 * time.Second + pongWait = 60 * time.Second + pingPeriod = (pongWait * 9) / 10 + maxMessageSize = 512 +) + +type Message struct { + Type string `json:"type"` // "chat", "system", "danmaku" + Username string `json:"username"` + Content string `json:"content"` + RoomID string `json:"room_id"` +} + +type Client struct { + Hub *Hub + Conn *websocket.Conn + Send chan []byte + RoomID string + Username string +} + +type Hub struct { + rooms map[string]map[*Client]bool + broadcast chan Message + register chan *Client + unregister chan *Client + mutex sync.RWMutex +} + +func NewHub() *Hub { + return &Hub{ + broadcast: make(chan Message), + register: make(chan *Client), + unregister: make(chan *Client), + rooms: make(map[string]map[*Client]bool), + } +} + +func (h *Hub) Run() { + for { + select { + case client := <-h.register: + h.mutex.Lock() + if h.rooms[client.RoomID] == nil { + h.rooms[client.RoomID] = make(map[*Client]bool) + } + h.rooms[client.RoomID][client] = true + h.mutex.Unlock() + + case client := <-h.unregister: + h.mutex.Lock() + if rooms, ok := h.rooms[client.RoomID]; ok { + if _, ok := rooms[client]; ok { + delete(rooms, client) + close(client.Send) + if len(rooms) == 0 { + delete(h.rooms, client.RoomID) + } + } + } + h.mutex.Unlock() + + case message := <-h.broadcast: + h.mutex.RLock() + clients := h.rooms[message.RoomID] + if clients != nil { + msgBytes, _ := json.Marshal(message) + for client := range clients { + select { + case client.Send <- msgBytes: + default: + close(client.Send) + delete(clients, client) + } + } + } + h.mutex.RUnlock() + } + } +} + +func (h *Hub) RegisterClient(c *Client) { + h.register <- c +} + +// BroadcastToRoom sends a message to the broadcast channel +func (h *Hub) BroadcastToRoom(msg Message) { + h.broadcast <- msg +} + +func (c *Client) ReadPump() { + defer func() { + c.Hub.unregister <- c + c.Conn.Close() + }() + c.Conn.SetReadLimit(maxMessageSize) + c.Conn.SetReadDeadline(time.Now().Add(pongWait)) + c.Conn.SetPongHandler(func(string) error { c.Conn.SetReadDeadline(time.Now().Add(pongWait)); return nil }) + for { + _, message, err := c.Conn.ReadMessage() + if err != nil { + break + } + var msg Message + if err := json.Unmarshal(message, &msg); err == nil { + msg.RoomID = c.RoomID + msg.Username = c.Username + c.Hub.broadcast <- msg + } + } +} + +func (c *Client) WritePump() { + ticker := time.NewTicker(pingPeriod) + defer func() { + ticker.Stop() + c.Conn.Close() + }() + for { + select { + case message, ok := <-c.Send: + c.Conn.SetWriteDeadline(time.Now().Add(writeWait)) + if !ok { + c.Conn.WriteMessage(websocket.CloseMessage, []byte{}) + return + } + w, err := c.Conn.NextWriter(websocket.TextMessage) + if err != nil { + return + } + w.Write(message) + if err := w.Close(); err != nil { + return + } + case <-ticker.C: + c.Conn.SetWriteDeadline(time.Now().Add(writeWait)) + if err := c.Conn.WriteMessage(websocket.PingMessage, nil); err != nil { + return + } + } + } +} + +var MainHub *Hub + +func InitChat() { + MainHub = NewHub() + go MainHub.Run() +} diff --git a/frontend/lib/pages/home_page.dart b/frontend/lib/pages/home_page.dart index 96e2a97..39ae213 100644 --- a/frontend/lib/pages/home_page.dart +++ b/frontend/lib/pages/home_page.dart @@ -167,7 +167,16 @@ class _ExploreViewState extends State<_ExploreView> { child: InkWell( onTap: () { final rtmpUrl = "${settings.rtmpUrl}/${room['room_id']}"; - Navigator.push(context, MaterialPageRoute(builder: (_) => PlayerPage(title: room['title'], rtmpUrl: rtmpUrl))); + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => PlayerPage( + title: room['title'], + rtmpUrl: rtmpUrl, + roomId: room['room_id'].toString(), + ), + ), + ); }, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, diff --git a/frontend/lib/pages/player_page.dart b/frontend/lib/pages/player_page.dart index 0e0e710..7cffa5c 100644 --- a/frontend/lib/pages/player_page.dart +++ b/frontend/lib/pages/player_page.dart @@ -1,11 +1,21 @@ +import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; import 'package:video_player/video_player.dart'; +import '../providers/settings_provider.dart'; +import '../services/chat_service.dart'; class PlayerPage extends StatefulWidget { final String title; final String rtmpUrl; + final String roomId; - const PlayerPage({Key? key, required this.title, required this.rtmpUrl}) : super(key: key); + const PlayerPage({ + Key? key, + required this.title, + required this.rtmpUrl, + required this.roomId, + }) : super(key: key); @override _PlayerPageState createState() => _PlayerPageState(); @@ -13,6 +23,11 @@ class PlayerPage extends StatefulWidget { class _PlayerPageState extends State { late VideoPlayerController _controller; + final ChatService _chatService = ChatService(); + final TextEditingController _msgController = TextEditingController(); + final List _messages = []; + final List _danmakus = []; // 为简单起见,这里存储弹幕 Widget + bool _isError = false; String? _errorMessage; @@ -20,62 +35,189 @@ class _PlayerPageState extends State { void initState() { super.initState(); _initializePlayer(); + _initializeChat(); } void _initializePlayer() async { - print("[INFO] Playing stream: ${widget.rtmpUrl}"); _controller = VideoPlayerController.networkUrl(Uri.parse(widget.rtmpUrl)); - try { await _controller.initialize(); _controller.play(); - setState(() {}); // 更新状态以渲染画面 + if (mounted) setState(() {}); } catch (e) { - print("[ERROR] Player initialization failed: $e"); - setState(() { - _isError = true; - _errorMessage = e.toString(); - }); + if (mounted) setState(() { _isError = true; _errorMessage = e.toString(); }); + } + } + + void _initializeChat() { + final settings = context.read(); + _chatService.connect(settings.baseUrl, widget.roomId, "User_${widget.roomId}"); // 暂定用户名 + + _chatService.messages.listen((msg) { + if (mounted) { + setState(() { + _messages.insert(0, msg); + if (msg.type == "chat" || msg.type == "danmaku") { + _addDanmaku(msg.content); + } + }); + } + }); + } + + void _addDanmaku(String text) { + final id = DateTime.now().millisecondsSinceEpoch; + final top = 20.0 + (id % 5) * 30.0; // 简单的多轨道显示 + + final danmaku = _DanmakuItem( + key: ValueKey(id), + text: text, + top: top, + onFinished: () { + if (mounted) setState(() => _danmakus.removeWhere((w) => w.key == ValueKey(id))); + }, + ); + + setState(() => _danmakus.add(danmaku)); + } + + void _sendMsg() { + if (_msgController.text.isNotEmpty) { + _chatService.sendMessage(_msgController.text, "Me", widget.roomId); + _msgController.clear(); } } @override void dispose() { _controller.dispose(); + _chatService.dispose(); + _msgController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: Colors.black, - appBar: AppBar( - title: Text(widget.title), - backgroundColor: Colors.transparent, - foregroundColor: Colors.white, - ), - body: Center( - child: _isError - ? Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.error_outline, color: Colors.red, size: 60), - SizedBox(height: 16), - Text( - "Failed to load stream.", - style: TextStyle(color: Colors.white, fontSize: 18), + appBar: AppBar(title: Text(widget.title)), + body: Column( + children: [ + // 视频播放器 + 弹幕层 + Container( + color: Colors.black, + width: double.infinity, + height: 250, + child: Stack( + children: [ + Center( + child: _isError + ? Text("Error: $_errorMessage", style: TextStyle(color: Colors.white)) + : _controller.value.isInitialized + ? AspectRatio(aspectRatio: _controller.value.aspectRatio, child: VideoPlayer(_controller)) + : CircularProgressIndicator(), + ), + // 弹幕层 + ..._danmakus, + ], + ), + ), + // 评论区标题 + Container( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), + color: Theme.of(context).colorScheme.surfaceVariant, + child: Row( + children: [ + Icon(Icons.chat_bubble_outline, size: 16), + SizedBox(width: 8), + Text("Live Chat", style: TextStyle(fontWeight: FontWeight.bold)), + ], + ), + ), + // 消息列表 + Expanded( + child: ListView.builder( + reverse: true, + itemCount: _messages.length, + itemBuilder: (context, index) { + final m = _messages[index]; + return ListTile( + dense: true, + title: Text( + "${m.username}: ${m.content}", + style: TextStyle(color: m.type == "system" ? Colors.blue : null), ), - SizedBox(height: 8), - Text(_errorMessage ?? "Unknown error", style: TextStyle(color: Colors.grey)), - TextButton(onPressed: () => Navigator.pop(context), child: Text("Go Back")), - ], - ) - : _controller.value.isInitialized - ? AspectRatio( - aspectRatio: _controller.value.aspectRatio, - child: VideoPlayer(_controller), - ) - : CircularProgressIndicator(color: Colors.white), + ); + }, + ), + ), + // 输入框 + Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _msgController, + decoration: InputDecoration( + hintText: "Say something...", + border: OutlineInputBorder(borderRadius: BorderRadius.circular(20)), + contentPadding: EdgeInsets.symmetric(horizontal: 16), + ), + onSubmitted: (_) => _sendMsg(), + ), + ), + SizedBox(width: 8), + IconButton(icon: Icon(Icons.send, color: Colors.blue), onPressed: _sendMsg), + ], + ), + ), + ], + ), + ); + } +} + +class _DanmakuItem extends StatefulWidget { + final String text; + final double top; + final VoidCallback onFinished; + + const _DanmakuItem({Key? key, required this.text, required this.top, required this.onFinished}) : super(key: key); + + @override + __DanmakuItemState createState() => __DanmakuItemState(); +} + +class __DanmakuItemState extends State<_DanmakuItem> with SingleTickerProviderStateMixin { + late AnimationController _animationController; + late Animation _animation; + + @override + void initState() { + super.initState(); + _animationController = AnimationController(duration: const Duration(seconds: 8), vsync: this); + _animation = Tween(begin: Offset(1.5, 0), end: Offset(-1.5, 0)).animate(_animationController); + + _animationController.forward().then((_) => widget.onFinished()); + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Positioned( + top: widget.top, + width: 300, + child: SlideTransition( + position: _animation, + child: Text( + widget.text, + style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 16, shadows: [Shadow(blurRadius: 2, color: Colors.black)]), + ), ), ); } diff --git a/frontend/lib/services/chat_service.dart b/frontend/lib/services/chat_service.dart new file mode 100644 index 0000000..320a6e9 --- /dev/null +++ b/frontend/lib/services/chat_service.dart @@ -0,0 +1,61 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +class ChatMessage { + final String type; + final String username; + final String content; + final String roomId; + + ChatMessage({required this.type, required this.username, required this.content, required this.roomId}); + + factory ChatMessage.fromJson(Map json) { + return ChatMessage( + type: json['type'] ?? 'chat', + username: json['username'] ?? 'Anonymous', + content: json['content'] ?? '', + roomId: json['room_id'] ?? '', + ); + } + + Map toJson() => { + 'type': type, + 'username': username, + 'content': content, + 'room_id': roomId, + }; +} + +class ChatService { + WebSocketChannel? _channel; + final StreamController _messageController = StreamController.broadcast(); + + Stream get messages => _messageController.stream; + + void connect(String baseUrl, String roomId, String username) { + final wsUri = Uri.parse(baseUrl).replace(scheme: 'ws', path: '/api/ws/room/$roomId', queryParameters: {'username': username}); + _channel = WebSocketChannel.connect(wsUri); + + _channel!.stream.listen((data) { + final json = jsonDecode(data); + _messageController.add(ChatMessage.fromJson(json)); + }, onError: (err) { + print("[WS ERROR] $err"); + }, onDone: () { + print("[WS DONE] Connection closed"); + }); + } + + void sendMessage(String content, String username, String roomId, {String type = 'chat'}) { + if (_channel != null) { + final msg = ChatMessage(type: type, username: username, content: content, roomId: roomId); + _channel!.sink.add(jsonEncode(msg.toJson())); + } + } + + void dispose() { + _channel?.sink.close(); + _messageController.close(); + } +} diff --git a/frontend/pubspec.lock b/frontend/pubspec.lock index 18a31c5..ff4ed74 100644 --- a/frontend/pubspec.lock +++ b/frontend/pubspec.lock @@ -525,6 +525,22 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: diff --git a/frontend/pubspec.yaml b/frontend/pubspec.yaml index bcf8e4f..031c7b1 100644 --- a/frontend/pubspec.yaml +++ b/frontend/pubspec.yaml @@ -39,6 +39,7 @@ dependencies: shared_preferences: ^2.5.4 video_player: ^2.11.1 fvp: ^0.35.2 + web_socket_channel: ^3.0.3 dev_dependencies: flutter_test: