Merge remote-tracking branch 'origin/master' into feat/async-ffmpeg-init

# Conflicts:
#	src/servers/sse.go
#	src/webapp/src/utils/sse.ts
This commit is contained in:
kira1928 committed 2026-07-08 14:01:15 +09:00
commit 6096171663
22 files changed
+1153 -351

No files matched your search

+1
View File
@@ -191,6 +191,7 @@ test/launcher-config-local.json
# E2E 测试输出(录制文件等)
/test-output/
test-output*appdata/
# npm 锁文件(项目统一使用 yarn)
package-lock.json
+110 -61
View File
@@ -79,7 +79,7 @@ func (f *Feature) GetEffectiveDownloaderType() DownloaderType {
type DanmakuConfig struct {
FontSize int `yaml:"font_size" json:"font_size"` // 字体大小 (12~120)
FontName string `yaml:"font_name" json:"font_name"` // 字体名称
ScrollArea string `yaml:"scroll_area" json:"scroll_area"` // 滚动区域: full(全屏), top(顶部), bottom(底部)
ScrollArea string `yaml:"scroll_area" json:"scroll_area"` // 滚动区域: full(全屏), top(顶部半屏), bottom(底部半屏), quarter(1/4屏), three-quarter(3/4屏)
ScrollTime int `yaml:"scroll_time" json:"scroll_time"` // 弹幕滚过屏幕的秒数 (5~20)
Resolution string `yaml:"resolution" json:"resolution"` // 播放分辨率
Outline *int `yaml:"outline,omitempty" json:"outline,omitempty"` // 描边粗细 (0~4),nil 表示使用默认值
@@ -115,9 +115,11 @@ var defaultDanmakuConfig = DanmakuConfig{
// validScrollAreas 支持的滚动区域
var validScrollAreas = map[string]bool{
"full": true, // 全屏滚动
"top": true, // 仅在屏幕上半部分滚动
"bottom": true, // 仅在屏幕下半部分滚动
"full": true, // 全屏滚动
"top": true, // 仅在屏幕上半部分滚动
"bottom": true, // 仅在屏幕下半部分滚动
"quarter": true, // 仅在屏幕上1/4部分滚动
"three-quarter": true, // 仅在屏幕上3/4部分滚动
}
// validResolutions 支持的分辨率列表
@@ -221,7 +223,7 @@ func (d *DanmakuConfig) ValidateWithPlatform(platformKey string) error {
return fmt.Errorf("字体名称不能为空")
}
if !validScrollAreas[d.ScrollArea] {
return fmt.Errorf("不支持的滚动区域: %s,可选值: full, top, bottom", d.ScrollArea)
return fmt.Errorf("不支持的滚动区域: %s,可选值: full, top, bottom, quarter, three-quarter", d.ScrollArea)
}
if d.ScrollTime < 5 || d.ScrollTime > 20 {
return fmt.Errorf("滚动时间必须在 5~20 秒之间,当前值: %d", d.ScrollTime)
@@ -649,38 +651,51 @@ func UpdateTransient(mutator func(c *Config) error) (*Config, error) {
}
func updateImpl(mutator func(c *Config) error, persist bool) (*Config, error) {
updateMu.Lock()
defer updateMu.Unlock()
old := GetCurrentConfig()
// 若当前尚未设置配置,则以默认配置为基础
var base *Config
if old == nil {
base = NewConfig()
} else {
base = CloneConfigShallow(old)
}
if err := mutator(base); err != nil {
return nil, err
}
// 维护派生字段
base.RefreshLiveRoomIndexCache()
// 版本号自增
if old == nil {
base.Version = 1
} else {
base.Version = old.Version + 1
}
newCfg := base
var newCfg *Config
var updateErr error
if persist && newCfg.File != "" {
if err := newCfg.Marshal(); err != nil {
// 如果持久化失败,我们选择记录错误但不阻止内存更新
// 或者返回错误?这里选择返回错误,因为用户期望保存成功。
return nil, fmt.Errorf("failed to save config: %w", err)
func() {
updateMu.Lock()
defer updateMu.Unlock()
old := GetCurrentConfig()
// 若当前尚未设置配置,则以默认配置为基础
var base *Config
if old == nil {
base = NewConfig()
} else {
base = CloneConfigShallow(old)
}
if err := mutator(base); err != nil {
updateErr = err
return
}
// 维护派生字段
base.RefreshLiveRoomIndexCache()
// 版本号自增
if old == nil {
base.Version = 1
} else {
base.Version = old.Version + 1
}
newCfg = base
// 持久化在锁内执行,保证内存与磁盘一致性
if persist && newCfg.File != "" {
if err := newCfg.Marshal(); err != nil {
updateErr = fmt.Errorf("failed to save config: %w", err)
return
}
}
SetCurrentConfig(newCfg)
}()
if updateErr != nil {
return nil, updateErr
}
if newCfg == nil {
return nil, errors.New("config update failed")
}
SetCurrentConfig(newCfg)
return newCfg, nil
}
@@ -691,38 +706,52 @@ func UpdateCAS(expectedVersion int64, mutator func(c *Config) error) (*Config, e
}
func updateCASImpl(expectedVersion int64, mutator func(c *Config) error, persist bool) (*Config, error) {
updateMu.Lock()
defer updateMu.Unlock()
cur := GetCurrentConfig()
// 校验版本
var curVersion int64
if cur != nil {
curVersion = cur.Version
}
if curVersion != expectedVersion {
return nil, ErrConfigVersionConflict
}
// 克隆并修改
var base *Config
if cur == nil {
base = NewConfig()
} else {
base = CloneConfigShallow(cur)
}
if err := mutator(base); err != nil {
return nil, err
}
base.RefreshLiveRoomIndexCache()
base.Version = expectedVersion + 1
var newCfg *Config
var updateErr error
if persist && base.File != "" {
if err := base.Marshal(); err != nil {
return nil, fmt.Errorf("failed to save config: %w", err)
func() {
updateMu.Lock()
defer updateMu.Unlock()
cur := GetCurrentConfig()
// 校验版本
var curVersion int64
if cur != nil {
curVersion = cur.Version
}
if curVersion != expectedVersion {
updateErr = ErrConfigVersionConflict
return
}
// 克隆并修改
var base *Config
if cur == nil {
base = NewConfig()
} else {
base = CloneConfigShallow(cur)
}
if err := mutator(base); err != nil {
updateErr = err
return
}
base.RefreshLiveRoomIndexCache()
base.Version = expectedVersion + 1
newCfg = base
// 持久化在锁内执行,保证内存与磁盘一致性
if persist && newCfg.File != "" {
if err := newCfg.Marshal(); err != nil {
updateErr = fmt.Errorf("failed to save config: %w", err)
return
}
}
SetCurrentConfig(newCfg)
}()
if updateErr != nil {
return nil, updateErr
}
SetCurrentConfig(base)
return base, nil
return newCfg, nil
}
// UpdateWithRetry 在读取-修改-提交之间做乐观锁重试,避免调用方自行实现重试逻辑
@@ -811,6 +840,14 @@ func AppendLiveRoom(room LiveRoom) (*Config, error) {
}, 3, 10*time.Millisecond)
}
// AppendLiveRoomTransient 追加一个 LiveRoom(仅更新内存,不持久化)。
func AppendLiveRoomTransient(room LiveRoom) (*Config, error) {
return UpdateWithRetryTransient(func(c *Config) error {
c.LiveRooms = append(c.LiveRooms, room)
return nil
}, 3, 10*time.Millisecond)
}
// RemoveLiveRoomByUrl 从配置中移除指定 URL 的房间
func RemoveLiveRoomByUrl(url string) (*Config, error) {
return UpdateWithRetry(func(c *Config) error {
@@ -849,6 +886,18 @@ func SetLiveRoomId(url string, id types.LiveID) (*Config, error) {
}, 3, 10*time.Millisecond)
}
// Persist 将当前内存中的配置持久化到磁盘。
// 用于批量 Transient 更新后统一刷盘的场景。
func Persist() error {
updateMu.Lock()
defer updateMu.Unlock()
cfg := GetCurrentConfig()
if cfg == nil {
return errors.New("config not initialized")
}
return cfg.Marshal()
}
type LiveRoom struct {
Url string `yaml:"url" json:"url"`
IsListening bool `yaml:"is_listening" json:"is_listening"`
+41 -15
View File
@@ -28,7 +28,7 @@ type AssWriter struct {
laneEnd int // last usable lane index (exclusive)
laneNum int // total lanes in the usable range
nextLane int
laneLast []int64 // last end time (centiseconds) per lane
laneLast []int64 // last tail-clear time (centiseconds) per lane
}
func parseResolution(res string) (int, int) {
@@ -72,6 +72,10 @@ func NewAssWriter(filePath string, startAt time.Time, cfg configs.DanmakuConfig,
laneEnd = totalLanes / 2
case "bottom":
laneStart = totalLanes / 2
case "quarter":
laneEnd = totalLanes / 4
case "three-quarter":
laneEnd = totalLanes * 3 / 4
}
laneNum := laneEnd - laneStart
if laneNum < 1 {
@@ -84,14 +88,14 @@ func NewAssWriter(filePath string, startAt time.Time, cfg configs.DanmakuConfig,
cfg: cfg,
title: title,
resX: resX,
resY: resY,
resY: resY,
scrollTimeMs: scrollTimeMs,
bannerSpeed: bannerSpeed,
laneStart: laneStart,
laneEnd: laneEnd,
laneNum: laneNum,
nextLane: 0,
laneLast: make([]int64, laneNum),
bannerSpeed: bannerSpeed,
laneStart: laneStart,
laneEnd: laneEnd,
laneNum: laneNum,
nextLane: 0,
laneLast: make([]int64, laneNum),
}
if err := w.writeHeader(); err != nil {
@@ -251,7 +255,12 @@ func (w *AssWriter) AddDanmaku(recvAt time.Time, username, text string, color in
}
endCS := startCS + durationCS
lane := w.assignLane(startCS, endCS)
lane, adjustedStartCS := w.assignLane(startCS, textWidth)
// 基于文字宽度的防重叠:使用调整后的起始时间
if adjustedStartCS != startCS {
startCS = adjustedStartCS
endCS = startCS + durationCS
}
laneHeight := w.cfg.FontSize + 4
marginV := (lane + w.laneStart) * laneHeight
@@ -298,7 +307,12 @@ func (w *AssWriter) AddGift(recvAt time.Time, username, giftName string, num int
}
endCS := startCS + durationCS
lane := w.assignLane(startCS, endCS)
lane, adjustedStartCS := w.assignLane(startCS, textWidth)
// 基于文字宽度的防重叠:使用调整后的起始时间
if adjustedStartCS != startCS {
startCS = adjustedStartCS
endCS = startCS + durationCS
}
laneHeight := w.cfg.FontSize + 4
marginV := (lane + w.laneStart) * laneHeight
@@ -373,24 +387,36 @@ func (w *AssWriter) AddSuperChat(recvAt time.Time, username, text string, price
}
}
func (w *AssWriter) assignLane(startCS, endCS int64) int {
// assignLane 分配一个空闲 lane,基于文字宽度的防重叠。
// 参数:startCS 弹幕开始时间,textWidth 文字像素宽度。
// 返回值:lane 索引、调整后的 startCS(可能延迟)。
func (w *AssWriter) assignLane(startCS int64, textWidth int) (int, int64) {
// 安全间距:防止因字体渲染差异导致的重叠
safeTextWidth := textWidth + w.cfg.FontSize
// 优先找空闲 lane(前一条弹幕的尾部已离开屏幕右侧)
for i := 0; i < w.laneNum; i++ {
idx := (w.nextLane + i) % w.laneNum
if w.laneLast[idx] <= startCS {
w.laneLast[idx] = endCS
// 存储该弹幕尾部离开右侧的时间点(用于下一条弹幕判断)
tailClearCS := startCS + int64(w.scrollTimeMs)*int64(safeTextWidth)/int64(w.resX)/10
w.laneLast[idx] = tailClearCS
w.nextLane = (idx + 1) % w.laneNum
return idx
return idx, startCS
}
}
// 所有 lane 都被占用,延迟到最早可用的时间点
earliest := 0
for i := 1; i < w.laneNum; i++ {
if w.laneLast[i] < w.laneLast[earliest] {
earliest = i
}
}
w.laneLast[earliest] = endCS
newStartCS := w.laneLast[earliest]
// 存储新弹幕尾部离开右侧的时间点
tailClearCS := newStartCS + int64(w.scrollTimeMs)*int64(safeTextWidth)/int64(w.resX)/10
w.laneLast[earliest] = tailClearCS
w.nextLane = (earliest + 1) % w.laneNum
return earliest
return earliest, newStartCS
}
func (w *AssWriter) OutputPath() string {
+163 -22
View File
@@ -19,6 +19,7 @@ import (
"text/template"
"time"
"github.com/google/uuid"
"github.com/gorilla/mux"
"github.com/hr3lxphr6j/requests"
"github.com/tidwall/gjson"
@@ -678,8 +679,9 @@ func addLives(writer http.ResponseWriter, r *http.Request) {
inst := instance.GetInstance(r.Context())
b, err := io.ReadAll(r.Body)
if err != nil {
writeJSON(writer, map[string]any{
"error": err.Error(),
writeJsonWithStatusCode(writer, http.StatusBadRequest, commonResp{
ErrNo: http.StatusBadRequest,
ErrMsg: err.Error(),
})
return
}
@@ -687,8 +689,9 @@ func addLives(writer http.ResponseWriter, r *http.Request) {
errorMessages := make([]string, 0, 4)
gjson.ParseBytes(b).ForEach(func(key, value gjson.Result) bool {
isListen := value.Get("listen").Bool()
notifyOnly := value.Get("notify_only").Bool()
urlStr := strings.Trim(value.Get("url").String(), " ")
if retInfo, err := addLiveImpl(inst.Ctx, urlStr, isListen); err != nil {
if retInfo, err := addLiveImpl(inst.Ctx, urlStr, isListen, notifyOnly, true); err != nil {
msg := urlStr + ": " + err.Error()
applog.GetLogger().Error(msg)
errorMessages = append(errorMessages, msg)
@@ -699,11 +702,10 @@ func addLives(writer http.ResponseWriter, r *http.Request) {
return true
})
sort.Sort(info)
// TODO return error messages too
writeJSON(writer, info)
}
func addLiveImpl(ctx context.Context, urlStr string, isListen bool) (info *live.Info, err error) {
func addLiveImpl(ctx context.Context, urlStr string, isListen bool, notifyOnly bool, persist bool) (info *live.Info, err error) {
if !strings.HasPrefix(urlStr, "http://") && !strings.HasPrefix(urlStr, "https://") {
urlStr = "https://" + urlStr
}
@@ -718,6 +720,7 @@ func addLiveImpl(ctx context.Context, urlStr string, isListen bool) (info *live.
liveRoom = &configs.LiveRoom{
Url: u.String(),
IsListening: isListen,
NotifyOnly: notifyOnly,
}
needAppend = true
}
@@ -725,33 +728,171 @@ func addLiveImpl(ctx context.Context, urlStr string, isListen bool) (info *live.
if err != nil {
return nil, err
}
if !inst.Lives.SetIfAbsent(newLive.GetLiveId(), newLive) {
return nil, errors.New("直播间已存在")
}
// 记录 LiveId 到全局配置(并发安全)
configs.SetLiveRoomId(u.String(), newLive.GetLiveId())
if inst.Lives.SetIfAbsent(newLive.GetLiveId(), newLive) {
if isListen {
inst.ListenerManager.(listeners.Manager).AddListener(ctx, newLive)
// 先将房间写入配置,再启动 Listener,确保 sendLiveNotification 能读到 NotifyOnly 等字段
if needAppend {
if liveRoom == nil {
return nil, errors.New("liveRoom is nil, cannot append to LiveRooms")
}
info = parseInfo(ctx, newLive)
if needAppend {
if liveRoom == nil {
return nil, errors.New("liveRoom is nil, cannot append to LiveRooms")
}
// 使用统一的 Update 接口做 COW 并原子替换
if persist {
if _, err := configs.AppendLiveRoom(*liveRoom); err != nil {
return nil, err
}
} else {
if _, err := configs.AppendLiveRoomTransient(*liveRoom); err != nil {
return nil, err
}
}
// 广播直播间列表变更事件
GetSSEHub().BroadcastListChange(newLive.GetLiveId(), "room_added", map[string]interface{}{
"live_id": string(newLive.GetLiveId()),
"url": urlStr,
"listening": isListen,
})
}
if isListen {
inst.ListenerManager.(listeners.Manager).AddListener(ctx, newLive)
}
info = parseInfo(ctx, newLive)
// 广播直播间列表变更事件
GetSSEHub().BroadcastListChange(newLive.GetLiveId(), "room_added", map[string]interface{}{
"live_id": string(newLive.GetLiveId()),
"url": urlStr,
"listening": isListen,
})
return info, nil
}
func batchAddLives(writer http.ResponseWriter, r *http.Request) {
inst := instance.GetInstance(r.Context())
b, err := io.ReadAll(r.Body)
if err != nil {
writeJsonWithStatusCode(writer, http.StatusBadRequest, commonResp{
ErrNo: http.StatusBadRequest,
ErrMsg: "failed to read request body",
})
return
}
var req batchAddRequest
if err := json.Unmarshal(b, &req); err != nil {
writeJsonWithStatusCode(writer, http.StatusBadRequest, commonResp{
ErrNo: http.StatusBadRequest,
ErrMsg: "invalid request body",
})
return
}
// 过滤空 URL,只保留有效条目
validURLs := make([]string, 0, len(req.URLs))
for _, u := range req.URLs {
if s := strings.TrimSpace(u); s != "" {
validURLs = append(validURLs, s)
}
}
if len(validURLs) == 0 {
writeJsonWithStatusCode(writer, http.StatusBadRequest, commonResp{
ErrNo: http.StatusBadRequest,
ErrMsg: "urls is empty",
})
return
}
const maxBatchSize = 50
if len(validURLs) > maxBatchSize {
writeJsonWithStatusCode(writer, http.StatusBadRequest, commonResp{
ErrNo: http.StatusBadRequest,
ErrMsg: fmt.Sprintf("too many urls: %d (max %d)", len(validURLs), maxBatchSize),
})
return
}
// 使用客户端提供的 batch_id,或生成新的
batchID := req.BatchID
if batchID == "" {
batchID = "batch_" + uuid.New().String()
}
// 立即返回 batch_id 和有效总数
writeJSON(writer, batchAddResponse{
BatchID: batchID,
Total: len(validURLs),
})
// 异步处理批量添加
go func() {
defer func() {
if r := recover(); r != nil {
applog.GetLogger().Errorf("batch add: panic recovered: %v", r)
GetSSEHub().BroadcastBatchComplete(batchID, batchCompleteEvent{
Total: len(validURLs),
SuccessCount: 0,
FailCount: len(validURLs),
Timestamp: time.Now(),
})
}
}()
hub := GetSSEHub()
successCount := 0
failCount := 0
for i, urlStr := range validURLs {
// 检查重复房间,避免调用 addLiveImpl 做无用的网络请求
checkURL := urlStr
if !strings.HasPrefix(checkURL, "http://") && !strings.HasPrefix(checkURL, "https://") {
checkURL = "https://" + checkURL
}
if u, err := url.Parse(checkURL); err == nil {
if _, err := configs.GetCurrentConfig().GetLiveRoomByUrl(u.String()); err == nil {
event := batchProgressEvent{
Index: i,
Total: len(validURLs),
URL: urlStr,
Success: false,
Error: "直播间已存在",
}
failCount++
hub.BroadcastBatchProgress(batchID, event)
continue
}
}
retInfo, err := addLiveImpl(inst.Ctx, urlStr, req.Listen, req.NotifyOnly, false)
event := batchProgressEvent{
Index: i,
Total: len(validURLs),
URL: urlStr,
Success: err == nil,
}
if err != nil {
event.Error = err.Error()
failCount++
} else {
event.Info = retInfo
successCount++
}
hub.BroadcastBatchProgress(batchID, event)
}
// 批量完成后统一持久化一次配置
var persistErr string
if successCount > 0 {
if err := configs.Persist(); err != nil {
persistErr = err.Error()
applog.GetLogger().Errorf("batch add: failed to persist config: %v", err)
}
}
hub.BroadcastBatchComplete(batchID, batchCompleteEvent{
Total: len(validURLs),
SuccessCount: successCount,
FailCount: failCount,
PersistError: persistErr,
Timestamp: time.Now(),
})
}()
}
func removeLive(writer http.ResponseWriter, r *http.Request) {
inst := instance.GetInstance(r.Context())
vars := mux.Vars(r)
@@ -888,7 +1029,7 @@ func applyLiveRoomsByConfig(ctx context.Context, oldConfig *configs.Config, newC
newUrlMap[newRoom.Url] = newRoom
if room, err := oldConfig.GetLiveRoomByUrl(newRoom.Url); err != nil {
// add live
if _, err := addLiveImpl(ctx, newRoom.Url, newRoom.IsListening); err != nil {
if _, err := addLiveImpl(ctx, newRoom.Url, newRoom.IsListening, newRoom.NotifyOnly, true); err != nil {
return err
}
} else {
+35
View File
@@ -1,6 +1,8 @@
package servers
import (
"time"
"github.com/bililive-go/bililive-go/src/live"
)
@@ -21,3 +23,36 @@ func (c liveSlice) Swap(i, j int) {
func (c liveSlice) Less(i, j int) bool {
return c[i].Live.GetLiveId() < c[j].Live.GetLiveId()
}
// batchAddRequest 批量添加直播间请求
type batchAddRequest struct {
URLs []string `json:"urls"`
Listen bool `json:"listen"`
NotifyOnly bool `json:"notify_only,omitempty"`
BatchID string `json:"batch_id,omitempty"`
}
// batchAddResponse 批量添加直播间响应(立即返回)
type batchAddResponse struct {
BatchID string `json:"batch_id"`
Total int `json:"total"`
}
// batchProgressEvent SSE 进度事件数据
type batchProgressEvent struct {
Index int `json:"index"`
Total int `json:"total"`
URL string `json:"url"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
Info *live.Info `json:"live_info,omitempty"`
}
// batchCompleteEvent SSE 完成事件数据
type batchCompleteEvent struct {
Total int `json:"total"`
SuccessCount int `json:"success_count"`
FailCount int `json:"fail_count"`
PersistError string `json:"persist_error,omitempty"`
Timestamp time.Time `json:"timestamp"`
}
+1 -1
View File
@@ -260,7 +260,7 @@ func osrpAddTask(w http.ResponseWriter, r *http.Request) {
}
// 添加直播间
info, err := addLiveImpl(ctx, req.URL, req.AutoStart)
info, err := addLiveImpl(ctx, req.URL, req.AutoStart, false, true)
if err != nil {
osrpWriteError(w, http.StatusBadRequest, "ADD_FAILED", err.Error())
return
+1
View File
@@ -84,6 +84,7 @@ func initMux(ctx context.Context) *mux.Router {
apiRoute.HandleFunc("/raw-config", putRawConfig).Methods("PUT")
apiRoute.HandleFunc("/lives", getAllLives).Methods("GET")
apiRoute.HandleFunc("/lives", addLives).Methods("POST")
apiRoute.HandleFunc("/lives/batch", batchAddLives).Methods("POST")
apiRoute.HandleFunc("/lives/{id}", getLive).Methods("GET")
apiRoute.HandleFunc("/lives/{id}", removeLive).Methods("DELETE")
apiRoute.HandleFunc("/lives/{id}/logs", getLiveLogs).Methods("GET")
+44
View File
@@ -46,6 +46,10 @@ const (
SSEEventDanmaku SSEEventType = "danmaku"
// SSEEventFFmpegStatus FFmpeg 就绪状态变更
SSEEventFFmpegStatus SSEEventType = "ffmpeg_status"
// SSEEventBatchProgress 批量添加进度
SSEEventBatchProgress SSEEventType = "batch_progress"
// SSEEventBatchComplete 批量添加完成
SSEEventBatchComplete SSEEventType = "batch_complete"
)
// SSEMessage SSE 消息结构
@@ -109,6 +113,28 @@ func (h *SSEHub) Broadcast(msg SSEMessage) {
}
}
// BroadcastCritical 广播关键消息,channel 满时丢弃最旧消息腾出位置,保证送达。
func (h *SSEHub) BroadcastCritical(msg SSEMessage) {
h.mu.RLock()
defer h.mu.RUnlock()
for ch := range h.clients {
select {
case ch <- msg:
default:
// channel 满,丢弃最旧的消息为关键事件腾出位置
select {
case <-ch:
default:
}
select {
case ch <- msg:
default:
// 仍然满(极端情况),放弃
}
}
}
}
// BroadcastLiveUpdate 广播直播间状态更新
func (h *SSEHub) BroadcastLiveUpdate(roomID types.LiveID, data interface{}) {
h.Broadcast(SSEMessage{
@@ -239,6 +265,24 @@ func (h *SSEHub) BroadcastDanmaku(roomID types.LiveID, data interface{}) {
})
}
// BroadcastBatchProgress 广播批量添加进度
func (h *SSEHub) BroadcastBatchProgress(batchID string, data interface{}) {
h.Broadcast(SSEMessage{
Type: SSEEventBatchProgress,
RoomID: batchID,
Data: data,
})
}
// BroadcastBatchComplete 广播批量添加完成(关键事件,保证送达)
func (h *SSEHub) BroadcastBatchComplete(batchID string, data interface{}) {
h.BroadcastCritical(SSEMessage{
Type: SSEEventBatchComplete,
RoomID: batchID,
Data: data,
})
}
// ClientCount 获取当前连接的客户端数量
func (h *SSEHub) ClientCount() int {
h.mu.RLock()
@@ -1,86 +0,0 @@
import { Modal, Input } from 'antd';
import React from 'react';
import API from '../../utils/api';
const api = new API();
interface Props {
refresh?: any;
children?: React.ReactNode;
}
class AddRoomDialog extends React.Component<Props> {
state = {
ModalText: '请输入直播间的URL地址',
visible: false,
confirmLoading: false,
textView: ''
};
showModal = () => {
this.setState({
ModalText: '请输入直播间的URL地址',
visible: true,
confirmLoading: false,
});
};
handleOk = () => {
this.setState({
ModalText: '正在添加直播间......',
confirmLoading: true,
});
api.addNewRoom(this.state.textView)
.then((rsp) => {
// 保存设置
api.saveSettingsInBackground();
this.setState({
visible: false,
confirmLoading: false,
textView: ''
});
this.props.refresh();
})
.catch(err => {
alert(`添加直播间失败:\n${err}`);
this.setState({
visible: false,
confirmLoading: false,
textView: ''
});
})
};
handleCancel = () => {
this.setState({
visible: false,
textView: ''
});
};
textChange = (e: any) => {
this.setState({
textView: e.target.value
})
}
render() {
const { visible, confirmLoading, ModalText, textView } = this.state;
return (
<div>
<Modal
title="添加直播间"
open={visible}
onOk={this.handleOk}
confirmLoading={confirmLoading}
onCancel={this.handleCancel}>
<p>{ModalText}</p>
<Input size="large" value={textView} placeholder="https://" onChange={this.textChange} />
</Modal>
</div>
);
}
}
export default AddRoomDialog;
@@ -0,0 +1,449 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import {
Modal,
Input,
Button,
Upload,
Progress,
Space,
Typography,
Radio,
Alert,
Switch,
message as antdMessage,
} from 'antd';
import {
UploadOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
MinusCircleOutlined,
} from '@ant-design/icons';
import API from '../../utils/api';
import { sseManager } from '../../utils/sse';
const { TextArea } = Input;
const { Text } = Typography;
const api = new API();
interface BatchResultItem {
url: string;
status: 'pending' | 'success' | 'error';
error?: string;
}
interface Props {
visible: boolean;
onClose: () => void;
onSuccess: () => void;
}
type InputMode = 'single' | 'batch';
type ImportMode = 'text' | 'file';
function generateBatchId(): string {
return 'batch_' + Date.now() + '_' + Math.random().toString(36).substring(2, 10);
}
const MAX_BATCH_URLS = 50;
const AddRoomDialog: React.FC<Props> = ({ visible, onClose, onSuccess }) => {
const [inputMode, setInputMode] = useState<InputMode>('single');
const [importMode, setImportMode] = useState<ImportMode>('text');
const [singleUrl, setSingleUrl] = useState('');
const [textInput, setTextInput] = useState('');
const [listen, setListen] = useState(true);
const [notifyOnly, setNotifyOnly] = useState(false);
const [processing, setProcessing] = useState(false);
const [current, setCurrent] = useState(0);
const [total, setTotal] = useState(0);
const [results, setResults] = useState<BatchResultItem[]>([]);
const [completed, setCompleted] = useState(false);
const [successCount, setSuccessCount] = useState(0);
const [failCount, setFailCount] = useState(0);
const sseSubRef = useRef<string[]>([]);
const sseTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const parseUrls = useCallback((text: string): string[] => {
return text
.split(/[\r\n]+/)
.map(line => line.trim())
.filter(line => line.length > 0 && !line.startsWith('#') && !line.startsWith('#'));
}, []);
const handleFileImport = useCallback((file: File) => {
const reader = new FileReader();
reader.onload = (e) => {
const text = e.target?.result as string;
setTextInput(text);
setImportMode('text');
};
reader.onerror = () => {
antdMessage.error('文件读取失败');
};
reader.readAsText(file);
return false;
}, []);
const cleanupSSE = useCallback(() => {
sseSubRef.current.forEach(id => sseManager.unsubscribe(id));
sseSubRef.current = [];
if (sseTimeoutRef.current) {
clearTimeout(sseTimeoutRef.current);
sseTimeoutRef.current = null;
}
}, []);
const subscribeSSE = useCallback((batchId: string) => {
const progressSubId = sseManager.subscribe(
batchId,
'batch_progress',
(message) => {
const data = message.data;
setCurrent(data.index);
setResults(prev => prev.map((item, i) =>
i === data.index
? { ...item, status: data.success ? 'success' : 'error', error: data.error }
: item
));
}
);
const completeSubId = sseManager.subscribe(
batchId,
'batch_complete',
(message) => {
const data = message.data;
setProcessing(false);
setCompleted(true);
setSuccessCount(data.success_count);
setFailCount(data.fail_count);
cleanupSSE();
if (data.fail_count === 0) {
if (data.persist_error) {
antdMessage.warning('添加成功,但配置保存失败,重启后可能丢失');
} else {
antdMessage.success(`添加成功`);
}
} else if (data.success_count === 0) {
antdMessage.error(`添加失败`);
} else {
antdMessage.warning(`添加完成,成功 ${data.success_count} 个,失败 ${data.fail_count} 个`);
}
onSuccess();
}
);
sseSubRef.current = [progressSubId, completeSubId];
// 5 分钟超时保护:后端异常时避免 UI 永久卡住
sseTimeoutRef.current = setTimeout(() => {
antdMessage.error('批量添加超时,请刷新页面查看结果');
setProcessing(false);
setCompleted(true);
cleanupSSE();
}, 5 * 60 * 1000);
}, [cleanupSSE, onSuccess]);
useEffect(() => {
return cleanupSSE;
}, [cleanupSSE]);
const handleStart = async () => {
if (processing) return;
// 根据模式获取 URL 列表
let urls: string[];
if (inputMode === 'single') {
const trimmed = singleUrl.trim();
if (!trimmed) {
antdMessage.warning('请输入直播间地址');
return;
}
urls = [trimmed];
} else {
urls = parseUrls(textInput);
if (urls.length === 0) {
antdMessage.warning('请输入至少一个直播间地址');
return;
}
if (urls.length > MAX_BATCH_URLS) {
antdMessage.warning(`单次最多添加 ${MAX_BATCH_URLS} 个直播间,当前 ${urls.length} 个`);
return;
}
}
const batchId = generateBatchId();
setProcessing(true);
setCompleted(false);
setCurrent(-1);
setTotal(urls.length);
setResults(urls.map(url => ({ url, status: 'pending' as const })));
setSuccessCount(0);
setFailCount(0);
subscribeSSE(batchId);
try {
await api.batchAddRooms(urls, listen, notifyOnly, batchId);
} catch (err: any) {
antdMessage.error('请求失败: ' + (err?.message || err));
cleanupSSE();
setProcessing(false);
}
};
const handleClose = () => {
if (processing) {
Modal.confirm({
title: '确认关闭',
content: '关闭后前端不再显示进度,后端会继续完成剩余添加。确认关闭?',
onOk: () => {
cleanupSSE();
resetState();
onClose();
},
});
return;
}
resetState();
onClose();
};
const resetState = () => {
cleanupSSE();
setInputMode('single');
setImportMode('text');
setSingleUrl('');
setTextInput('');
setListen(true);
setNotifyOnly(false);
setProcessing(false);
setCurrent(0);
setTotal(0);
setResults([]);
setCompleted(false);
setSuccessCount(0);
setFailCount(0);
};
const progressPercent = total > 0 && current >= 0 ? Math.round(((current + 1) / total) * 100) : 0;
const validUrlCount = inputMode === 'single'
? (singleUrl.trim() ? 1 : 0)
: parseUrls(textInput).length;
// 单个模式下,成功/失败后显示简要结果而非进度列表
const isSingleMode = inputMode === 'single' || total === 1;
return (
<Modal
title="添加直播间"
open={visible}
onCancel={handleClose}
width={inputMode === 'batch' ? 600 : 460}
footer={
processing ? null : (
<Space>
<Button onClick={handleClose}>
{completed ? '关闭' : '取消'}
</Button>
{!completed && (
<Button
type="primary"
onClick={handleStart}
disabled={validUrlCount === 0}
>
{inputMode === 'single' ? '添加' : '开始添加'}
</Button>
)}
</Space>
)
}
>
{/* 输入区域(仅未处理/未完成时显示) */}
{!processing && !completed && (
<>
{/* 模式切换 */}
<div style={{ marginBottom: 12, display: 'flex', alignItems: 'center', gap: 12 }}>
<Radio.Group
value={inputMode}
onChange={e => {
setInputMode(e.target.value);
setImportMode('text');
}}
size="small"
>
<Radio.Button value="single">单个添加</Radio.Button>
<Radio.Button value="batch">批量添加</Radio.Button>
</Radio.Group>
{inputMode === 'batch' && (
<Radio.Group
value={importMode}
onChange={e => setImportMode(e.target.value)}
size="small"
>
<Radio.Button value="text">手动输入</Radio.Button>
<Radio.Button value="file">导入TXT</Radio.Button>
</Radio.Group>
)}
</div>
{/* 文件导入按钮 */}
{inputMode === 'batch' && importMode === 'file' && (
<Upload
accept=".txt"
showUploadList={false}
beforeUpload={handleFileImport}
style={{ marginBottom: 12 }}
>
<Button icon={<UploadOutlined />} size="small">选择 TXT 文件</Button>
</Upload>
)}
{/* URL 输入 */}
{inputMode === 'single' ? (
<Input
size="large"
value={singleUrl}
onChange={e => setSingleUrl(e.target.value)}
placeholder="输入直播间地址,如 https://live.bilibili.com/12345"
onPressEnter={handleStart}
/>
) : (
<>
<Alert
type="info"
showIcon
message={`每行输入一个直播间地址,支持 # 开头的注释行,单次最多 ${MAX_BATCH_URLS} 个`}
style={{ marginBottom: 8 }}
/>
<TextArea
rows={8}
value={textInput}
onChange={e => setTextInput(e.target.value)}
placeholder={
'https://live.bilibili.com/12345\nhttps://www.douyu.com/67890\n# 这是注释行,会被忽略'
}
style={{ fontFamily: 'monospace', fontSize: 13 }}
/>
</>
)}
{/* 立即监听开关 */}
<div style={{ marginTop: 8 }}>
<Switch
checked={listen}
onChange={(checked) => {
setListen(checked);
if (!checked) setNotifyOnly(false);
}}
size="small"
/>
<Text style={{ marginLeft: 8 }}>添加后立即监听</Text>
</div>
{/* 仅开播提醒开关 */}
<div style={{ marginTop: 8 }}>
<Switch
checked={notifyOnly}
onChange={(checked) => {
setNotifyOnly(checked);
if (checked) setListen(true);
}}
size="small"
/>
<Text style={{ marginLeft: 8 }}>仅开播提醒(不自动录制)</Text>
</div>
{/* 批量模式下的解析统计 */}
{inputMode === 'batch' && validUrlCount > 0 && (
<Text type="secondary" style={{ fontSize: 12, marginTop: 4, display: 'block' }}>
已解析 {validUrlCount} 个链接
</Text>
)}
</>
)}
{/* 进度/结果区域 */}
{(processing || completed) && (
<div>
{/* 进度条 */}
<div style={{ marginBottom: isSingleMode && completed ? 0 : 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<Text>
{completed
? isSingleMode
? (failCount > 0 ? '添加失败' : '添加成功')
: `已完成 - 成功 ${successCount},失败 ${failCount}`
: current >= 0
? `已处理 ${current + 1}/${total}`
: `准备中...`}
</Text>
{!isSingleMode && <Text type="secondary">{progressPercent}%</Text>}
</div>
<Progress
percent={progressPercent}
status={completed ? (failCount > 0 ? 'exception' : 'success') : 'active'}
showInfo={false}
/>
</div>
{/* 结果列表(批量模式或单个失败时显示) */}
{(!isSingleMode || (isSingleMode && completed && failCount > 0)) && (
<div style={{
maxHeight: 300,
overflow: 'auto',
border: '1px solid #f0f0f0',
borderRadius: 6,
padding: 8,
}}>
{results.map((item, index) => (
<div key={index} style={{ marginBottom: index < results.length - 1 ? 8 : 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{item.status === 'pending' && (
<MinusCircleOutlined style={{ color: '#d9d9d9', fontSize: 14 }} />
)}
{item.status === 'success' && (
<CheckCircleOutlined style={{ color: '#52c41a', fontSize: 14 }} />
)}
{item.status === 'error' && (
<CloseCircleOutlined style={{ color: '#ff4d4f', fontSize: 14 }} />
)}
<Text
style={{
fontSize: 12,
color: item.status === 'error' ? '#ff4d4f' : undefined,
wordBreak: 'break-all',
}}
>
{item.url}
</Text>
</div>
{item.status === 'error' && item.error && (
<div style={{
marginLeft: 22,
marginTop: 2,
padding: '2px 8px',
background: '#fff2f0',
border: '1px solid #ffccc7',
borderRadius: 4,
fontSize: 12,
color: '#cf1322',
}}>
{item.error}
</div>
)}
</div>
))}
</div>
)}
</div>
)}
</Modal>
);
};
export default AddRoomDialog;
@@ -31,6 +31,7 @@ const { TextArea } = Input;
// 功能开关:代理配置(开发中,设为 false 隐藏 UI)
// 与后端 configs.EnableProxyConfig 对应
const ENABLE_PROXY_CONFIG = false;
const ENABLE_CLOUD_UPLOAD_SETTINGS = false;
const { Panel } = Collapse;
// 配置项类型定义
@@ -681,8 +682,8 @@ const GlobalSettings: React.FC<{
</ConfigField>
</Card>
{/* 云盘上传设置 */}
<CloudUploadSettings config={config} />
{/* 云盘上传设置(开发中) */}
{ENABLE_CLOUD_UPLOAD_SETTINGS && <CloudUploadSettings config={config} />}
{/* 高级设置 */}
<Card title="高级设置" size="small" style={{ marginBottom: 16 }}>
@@ -173,6 +173,8 @@ const DanmakuParamForm: React.FC<{
{ label: '全屏', value: 'full' },
{ label: '顶部半屏', value: 'top' },
{ label: '底部半屏', value: 'bottom' },
{ label: '1/4屏', value: 'quarter' },
{ label: '3/4屏', value: 'three-quarter' },
]} />
</Form.Item>
<Form.Item
@@ -5,7 +5,7 @@ import {
} from '@ant-design/icons';
import './danmaku-panel.css';
const MAX_MESSAGES = 500;
const MAX_MESSAGES = 5000;
const ITEM_HEIGHT = 32;
const OVERSCAN = 5;
@@ -320,9 +320,6 @@ const DanmakuPanel: React.FC<DanmakuPanelProps> = ({ messages }) => {
<span>
{isAllFiltered ? `共 ${displayMessages.length} 条` : `已筛选 ${filteredMessages.length}/${displayMessages.length} 条`}
</span>
{displayMessages.length >= MAX_MESSAGES && (
<span className="dm-limit-hint">(已达上限)</span>
)}
{autoScroll && (
<span className="dm-autoscroll-hint">自动滚动中</span>
)}
@@ -162,4 +162,18 @@
white-space: nowrap;
font-weight: bold;
will-change: left;
}
/* 播放器头部的复选框样式 */
.player-header .ant-checkbox-wrapper {
color: #ffffffb3 !important;
}
.player-header .ant-checkbox-wrapper:hover .ant-checkbox-inner {
border-color: #1890ff;
}
.player-header .ant-checkbox-checked .ant-checkbox-inner {
background-color: #1890ff;
border-color: #1890ff;
}
+232 -113
View File
@@ -1,6 +1,6 @@
import React, { useState, useEffect, useCallback, useRef } from "react";
import API from "../../utils/api";
import { Breadcrumb, Table, Button, Modal, Input, Popconfirm, message, Space, Tooltip, Switch } from "antd";
import { Breadcrumb, Table, Button, Modal, Input, Popconfirm, message, Space, Tooltip, Switch, Popover, Slider, Select, Checkbox } from "antd";
import {
// @ts-ignore
FolderOutlined,
@@ -41,23 +41,28 @@ function parseAssColor(c: string): string {
return `rgba(${r},${g},${b},${a.toFixed(2)})`;
}
type DanmakuEntry = { start: number; end: number; color: string; text: string; style: string; align: number; bgColor: string };
type DanmakuEntry = { start: number; end: number; color: string; text: string; style: string; align: number; bgColor: string; marginV: number };
/** 解析 ASS 文件,提取所有弹幕条目 */
function parseAss(content: string): { items: DanmakuEntry[]; scrollTime: number } {
function parseAss(content: string): { items: DanmakuEntry[]; scrollTime: number; resY: number } {
const lines = content.split('\n');
const items: DanmakuEntry[] = [];
let inStyles = false;
let inEvents = false;
let resX = 1920;
let resY = 1080;
let bannerSpeed = 80; // ms per pixel
// 样式名 → PrimaryColour / BackColour CSS 颜色
const styleColors: Record<string, string> = {};
const styleBackColors: Record<string, string> = {};
// 样式名 → MarginV
const styleMarginV: Record<string, number> = {};
for (const line of lines) {
const mr = line.match(/^PlayResX:\s*(\d+)/);
if (mr) resX = parseInt(mr[1]);
const my = line.match(/^PlayResY:\s*(\d+)/);
if (my) resY = parseInt(my[1]);
// 解析样式定义中的颜色(忽略大小写)
const trimmed = line.trim().toLowerCase();
@@ -66,12 +71,13 @@ function parseAss(content: string): { items: DanmakuEntry[]; scrollTime: number
if (line.startsWith('[')) { inStyles = false; inEvents = false; continue; }
if (inStyles && line.startsWith('Style:')) {
// Style: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, ...
// Style: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
const sp = line.substring('Style:'.length).split(',');
if (sp.length >= 7) {
if (sp.length >= 23) {
const name = sp[0].trim();
styleColors[name] = parseAssColor(sp[3].trim());
styleBackColors[name] = parseAssColor(sp[6].trim());
styleMarginV[name] = parseInt(sp[21].trim()) || 0;
}
continue;
}
@@ -79,6 +85,7 @@ function parseAss(content: string): { items: DanmakuEntry[]; scrollTime: number
if (!inEvents) continue;
if (!line.startsWith('Dialogue:')) continue;
// Dialogue: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
const parts = line.substring('Dialogue:'.length).split(',');
if (parts.length < 10) continue;
@@ -93,6 +100,9 @@ function parseAss(content: string): { items: DanmakuEntry[]; scrollTime: number
const start = parseAssTime(parts[1]);
const end = parseAssTime(parts[2]);
// MarginV: Dialogue 行的第 8 个字段(0-indexed: 7)
const mv = parseInt(parts[7].trim(), 10);
const marginV = Number.isNaN(mv) ? (styleMarginV[style] ?? 0) : mv;
const raw = parts.slice(9).join(',');
// 优先使用 Dialogue 行中的 {\c} 覆盖色,否则回退到样式定义的 PrimaryColour
let color = styleColors[style] || 'rgba(255,255,255,1)';
@@ -104,22 +114,38 @@ function parseAss(content: string): { items: DanmakuEntry[]; scrollTime: number
if (am) align = parseInt(am[1]);
const text = raw.replace(/\{[^}]*\}/g, '');
const bgColor = styleBackColors[style] || '';
if (end > start && text) items.push({ start, end, color, text, style, align, bgColor });
if (end > start && text) items.push({ start, end, color, text, style, align, bgColor, marginV });
}
return { items, scrollTime: (bannerSpeed * resX) / 1000 };
return { items, scrollTime: (bannerSpeed * resX) / 1000, resY };
}
// ==================== 弹幕设置 ====================
export type DanmakuArea = 'full' | 'top' | 'bottom' | 'quarter' | 'three-quarter';
export interface DanmakuSettings {
area: DanmakuArea; // 渲染区域
fontSize: number; // 字体大小 (12-48)
opacity: number; // 透明度 (0.2-1.0)
scrollTime: number; // 滚动时间 (3-20秒)
}
const DEFAULT_DANMAKU_SETTINGS: DanmakuSettings = {
area: 'full',
fontSize: 22,
opacity: 1.0,
scrollTime: 10,
};
// ==================== 弹幕渲染器 ====================
const DANMAKU_FONT_SIZE = 22;
const DANMAKU_GAP = 50; // 同轨道弹幕间隔(px)
/**
* 弹幕渲染器 — 核心:同轨道可同时显示多条弹幕
* 使用 requestAnimationFrame 手动控制 left 位置,而非 CSS animation,
* 这样可以精确跟踪每条弹幕的右边缘,实现真正的多弹幕同轨。
* 同时支持固定位置的消息(Guard/SC/Toast)。
* 弹幕渲染器 — 直接使用 ASS 文件中的时间轴和位置信息
* ASS 已包含防重叠逻辑,Web 端无需重复计算
* 使用 requestAnimationFrame 手动控制 left 位置实现滚动动画
*/
export interface DanmakuFilter {
danmaku: boolean;
@@ -132,25 +158,40 @@ class DanmakuRenderer {
private overlay: HTMLDivElement;
private video: HTMLVideoElement;
private items: DanmakuEntry[];
private scrollTime: number;
private filter: DanmakuFilter;
private settings: DanmakuSettings;
private rafId = 0;
private running = false;
private nextIdx = 0;
// 滚动弹幕轨道
private lanes: Array<{ el: HTMLDivElement; spawnTime: number; rightEdgeOutTime: number; tailEnterTime: number }[]> = [];
private laneCount = 0;
// 滚动弹幕(按结束时间排序,用于清理)
private scrollEls: { el: HTMLDivElement; spawnTime: number; endTime: number }[] = [];
// 固定位置消息(按位置分组堆叠)
private fixedEls: { el: HTMLDivElement; endTime: number; fadeOutTime: number; posKey: string; fadingOut: boolean }[] = [];
// 滚动样式白名单
private static SCROLL_STYLES = new Set(['Danmaku', 'Gift']);
// ASS 原始分辨率(用于 marginV 缩放)
private resY = 1080;
constructor(overlay: HTMLDivElement, video: HTMLVideoElement, items: DanmakuEntry[], scrollTime: number, filter: DanmakuFilter) {
constructor(overlay: HTMLDivElement, video: HTMLVideoElement, items: DanmakuEntry[], filter: DanmakuFilter, resY: number, settings: DanmakuSettings) {
this.overlay = overlay;
this.video = video;
this.items = items.slice().sort((a, b) => a.start - b.start);
this.scrollTime = scrollTime;
this.filter = filter;
this.resY = resY > 0 ? resY : 1080;
this.settings = settings;
this.overlay.style.opacity = String(settings.opacity);
}
/** 判断弹幕是否在指定区域内 */
private isInArea(marginV: number): boolean {
const ratio = marginV / this.resY;
switch (this.settings.area) {
case 'top': return ratio <= 0.5;
case 'bottom': return ratio > 0.5;
case 'quarter': return ratio <= 0.25;
case 'three-quarter': return ratio <= 0.75;
default: return true; // full
}
}
private matchesFilter(style: string): boolean {
@@ -169,8 +210,7 @@ class DanmakuRenderer {
if (this.running) return;
this.running = true;
this.nextIdx = 0;
this.laneCount = Math.max(1, Math.floor(this.overlay.clientHeight / (DANMAKU_FONT_SIZE + 4)));
this.lanes = Array.from({ length: this.laneCount }, () => []);
this.scrollEls = [];
this.rafId = requestAnimationFrame(this.tick);
}
@@ -178,14 +218,12 @@ class DanmakuRenderer {
this.running = false;
if (this.rafId) cancelAnimationFrame(this.rafId);
this.rafId = 0;
// 逐个移除固定消息(触发猴子补丁的定时器清理)
// 移除所有滚动弹幕
for (const d of this.scrollEls) d.el.remove();
this.scrollEls = [];
// 移除固定消息
for (const f of this.fixedEls) f.el.remove();
this.fixedEls = [];
// 逐个移除滚动弹幕
for (const lane of this.lanes) {
for (const d of lane) d.el.remove();
}
this.lanes = [];
// 移除 overlay 元素本身
if (this.overlay.parentNode) {
this.overlay.parentNode.removeChild(this.overlay);
@@ -194,11 +232,9 @@ class DanmakuRenderer {
/** 跳转到指定时间:清理已有弹幕,找到正确起始索引 */
seek(time: number) {
// 清理 DOM 和轨道
for (const lane of this.lanes) {
for (const d of lane) d.el.remove();
}
this.lanes = Array.from({ length: this.laneCount }, () => []);
// 清理 DOM
for (const d of this.scrollEls) d.el.remove();
this.scrollEls = [];
for (const f of this.fixedEls) f.el.remove();
this.fixedEls = [];
@@ -209,13 +245,32 @@ class DanmakuRenderer {
if (this.items[mid].start <= time) lo = mid + 1;
else hi = mid;
}
// 向前扫描:仅包含仍在显示期的固定消息(SC/上舰)
// 滚动弹幕不重新发射(它们无法从中间位置开始)
let idx = lo;
while (idx > 0 && this.items[idx - 1].end > time && !DanmakuRenderer.SCROLL_STYLES.has(this.items[idx - 1].style)) {
idx--;
this.nextIdx = lo;
// 向前寻找仍在显示期的固定消息并直接渲染
for (let i = lo - 1; i >= 0; i--) {
const item = this.items[i];
if (time - item.start > 10) break; // 超过 10 秒的消息肯定已结束
if (item.end > time && !DanmakuRenderer.SCROLL_STYLES.has(item.style)) {
if (this.matchesFilter(item.style)) {
this.spawnFixed(item, time);
}
}
}
this.nextIdx = idx;
}
/** 动态更新设置,无需销毁重建 */
update(filter: DanmakuFilter, settings: DanmakuSettings) {
this.filter = filter;
// 检测是否需要重绘:区域、字体大小、滚动时间变化会影响已上屏弹幕
const needReset = settings.area !== this.settings.area
|| settings.fontSize !== this.settings.fontSize
|| settings.scrollTime !== this.settings.scrollTime;
this.settings = settings;
// 透明度直接应用到 overlay
this.overlay.style.opacity = String(settings.opacity);
// 属性变化时重绘,避免新旧弹幕视觉不一致
if (needReset) this.seek(this.video.currentTime);
}
private tick = () => {
@@ -225,14 +280,15 @@ class DanmakuRenderer {
const t = this.video.currentTime;
const cw = this.overlay.clientWidth || 1;
const ch = this.overlay.clientHeight || 1;
// 1. 清理已完全滚出的弹幕
for (const lane of this.lanes) {
while (lane.length > 0 && lane[0].rightEdgeOutTime <= t) {
lane[0].el.remove();
lane.shift();
}
// 1. 清理已结束的滚动弹幕(endTime 大致递增,从队头移除)
let scrollRemoved = 0;
while (scrollRemoved < this.scrollEls.length && this.scrollEls[scrollRemoved].endTime <= t) {
this.scrollEls[scrollRemoved].el.remove();
scrollRemoved++;
}
if (scrollRemoved > 0) this.scrollEls.splice(0, scrollRemoved);
// 2. 清理过期的固定位置消息 + 淡出动画
let fixedChanged = false;
@@ -258,87 +314,74 @@ class DanmakuRenderer {
continue;
}
if (DanmakuRenderer.SCROLL_STYLES.has(item.style)) {
this.spawnScroll(item, t, cw);
this.spawnScroll(item, t, cw, ch);
} else {
this.spawnFixed(item, t);
}
this.nextIdx++;
}
// 4. 更新所有滚动弹幕位置
for (const lane of this.lanes) {
for (const d of lane) {
const elapsed = t - d.spawnTime;
const progress = elapsed / this.scrollTime;
const el = d.el;
const twPct = parseFloat(el.dataset.tw || '20');
const leftPct = 100 - progress * (100 + twPct);
el.style.left = leftPct + '%';
}
// 4. 更新所有滚动弹幕位置(使用 settings 中的 scrollTime)
const scrollTime = this.settings.scrollTime;
for (const d of this.scrollEls) {
const elapsed = t - d.spawnTime;
const progress = elapsed / scrollTime;
const el = d.el;
const twPct = parseFloat(el.dataset.tw || '20');
const leftPct = 100 - progress * (100 + twPct);
el.style.left = leftPct + '%';
}
};
/** 估算文本宽度(px),处理 surrogate pair 和粗体 */
private estimateTextWidth(text: string): number {
const fontSize = this.settings.fontSize;
let px = 0;
for (const ch of text) {
const code = ch.codePointAt(0)!;
if (code > 0xFFFF) {
// emoji / 扩展 CJK,算 2 个字符宽
px += DANMAKU_FONT_SIZE * 2;
px += fontSize * 2;
} else if (code > 0x7F) {
// CJK / 全角
px += DANMAKU_FONT_SIZE;
px += fontSize;
} else {
// ASCII(粗体下约 0.65x)
px += DANMAKU_FONT_SIZE * 0.65;
px += fontSize * 0.65;
}
}
return px;
}
private spawnScroll(item: DanmakuEntry, t: number, cw: number) {
private spawnScroll(item: DanmakuEntry, t: number, cw: number, ch: number) {
// 区域过滤:跳过不在指定区域的弹幕
if (!this.isInArea(item.marginV)) return;
const fontSize = this.settings.fontSize;
const scrollTime = this.settings.scrollTime;
const textPx = this.estimateTextWidth(item.text);
const twPct = (textPx / cw) * 100;
// 尾部进入屏幕的时间
const textEnterTime = t + (textPx / cw) * this.scrollTime;
// 完全滚出时间 = 滚动时间 + 文字滚入时间
const rightEdgeOutTime = t + this.scrollTime + (textPx / cw) * this.scrollTime;
// 安全间隔时间
const safeGapTime = (DANMAKU_GAP / cw) * this.scrollTime;
// 寻找可容纳的轨道
let laneIdx = -1;
for (let i = 0; i < this.laneCount; i++) {
const lane = this.lanes[i];
if (lane.length === 0) { laneIdx = i; break; }
const last = lane[lane.length - 1];
// 条件:最后一条弹幕的尾部已进入屏幕 + 安全间隔
if (t >= last.tailEnterTime + safeGapTime) { laneIdx = i; break; }
}
if (laneIdx < 0) {
let minLen = Infinity;
for (let i = 0; i < this.laneCount; i++) {
if (this.lanes[i].length < minLen) { minLen = this.lanes[i].length; laneIdx = i; }
}
}
if (laneIdx < 0) return;
// 计算结束时间:基于实际 spawn 时间 t,确保弹幕完整滚出屏幕
const endTime = t + scrollTime + (textPx / cw) * scrollTime;
// 创建 DOM
const el = document.createElement('div');
el.className = 'danmaku-item';
el.textContent = item.text;
el.style.color = item.color;
el.style.fontSize = DANMAKU_FONT_SIZE + 'px';
el.style.lineHeight = (DANMAKU_FONT_SIZE + 4) + 'px';
el.style.fontSize = fontSize + 'px';
el.style.lineHeight = (fontSize + 4) + 'px';
el.style.textShadow = '1px 1px 2px rgba(0,0,0,0.8),-1px -1px 2px rgba(0,0,0,0.8),1px -1px 2px rgba(0,0,0,0.8),-1px 1px 2px rgba(0,0,0,0.8)';
el.style.top = (laneIdx * (DANMAKU_FONT_SIZE + 4)) + 'px';
// 直接使用 ASS 的 marginV,按比例缩放到 overlay 高度
const scaledTop = (item.marginV / this.resY) * ch;
el.style.top = scaledTop + 'px';
el.style.left = '100%';
el.dataset.tw = twPct.toString();
this.overlay.appendChild(el);
this.lanes[laneIdx].push({ el, spawnTime: t, rightEdgeOutTime, tailEnterTime: textEnterTime });
this.scrollEls.push({ el, spawnTime: t, endTime });
}
/** 重新布局固定位置消息,消除过期后留下的空隙 */
@@ -458,7 +501,8 @@ const FileList: React.FC = () => {
const [filterGift, setFilterGift] = useState(true);
const [filterGuard, setFilterGuard] = useState(true);
const [filterSC, setFilterSC] = useState(true);
const parsedItemsRef = useRef<{ items: DanmakuEntry[]; scrollTime: number } | null>(null);
const [danmakuSettings, setDanmakuSettings] = useState<DanmakuSettings>(DEFAULT_DANMAKU_SETTINGS);
const parsedItemsRef = useRef<{ items: DanmakuEntry[]; scrollTime: number; resY: number } | null>(null);
const currentPlayingRef = useRef<{ record: CurrentFolderFile; fullPath: string } | null>(null);
// 重命名相关状态
@@ -516,6 +560,7 @@ const FileList: React.FC = () => {
danmakuRef.current.stop();
danmakuRef.current = null;
}
parsedItemsRef.current = null;
if (artRef.current) {
artRef.current.destroy(true);
artRef.current = null;
@@ -601,9 +646,9 @@ const FileList: React.FC = () => {
fetch(assUrl)
.then(r => r.ok ? r.text() : Promise.reject())
.then(text => {
const { items, scrollTime } = parseAss(text);
const { items, scrollTime, resY } = parseAss(text);
if (items.length === 0) return;
parsedItemsRef.current = { items, scrollTime };
parsedItemsRef.current = { items, scrollTime, resY };
// 统计弹幕类型
let danmakuCount = 0, giftCount = 0, giftAmount = 0, guardCount = 0, guardAmount = 0, scCount = 0, scAmount = 0;
@@ -635,7 +680,10 @@ const FileList: React.FC = () => {
overlay.className = 'danmaku-overlay';
artInner.appendChild(overlay);
const renderer = new DanmakuRenderer(overlay, artRef.current!.video, items, scrollTime, { danmaku: filterDanmaku, gift: filterGift, guard: filterGuard, sc: filterSC });
// 使用 ASS 解析的 scrollTime 作为初始值
const initialSettings = { ...danmakuSettings, scrollTime };
setDanmakuSettings(initialSettings);
const renderer = new DanmakuRenderer(overlay, artRef.current!.video, items, { danmaku: filterDanmaku, gift: filterGift, guard: filterGuard, sc: filterSC }, resY, initialSettings);
danmakuRef.current = renderer;
renderer.start();
if (artRef.current!.video.currentTime > 0) {
@@ -643,13 +691,19 @@ const FileList: React.FC = () => {
}
})
.catch(() => { /* 没有 ASS 文件或加载失败,静默忽略 */ });
}, [filterDanmaku, filterGift, filterGuard, filterSC]); // eslint-disable-line react-hooks/exhaustive-deps
}, [filterDanmaku, filterGift, filterGuard, filterSC, danmakuSettings]); // eslint-disable-line react-hooks/exhaustive-deps
// 过滤开关变化时刷新弹幕渲染器
// 过滤开关或设置变化时更新弹幕渲染器
useEffect(() => {
if (!danmakuRef.current || !parsedItemsRef.current || !artRef.current) return;
const currentTime = danmakuRef.current.getCurrentTime();
danmakuRef.current.stop();
if (!parsedItemsRef.current || !artRef.current) return;
const filter = { danmaku: filterDanmaku, gift: filterGift, guard: filterGuard, sc: filterSC };
// 如果渲染器已存在,直接动态更新,不销毁重建
if (danmakuRef.current) {
danmakuRef.current.update(filter, danmakuSettings);
return;
}
const artContainer = document.getElementById('art-container');
if (!artContainer) return;
@@ -658,12 +712,14 @@ const FileList: React.FC = () => {
overlay.className = 'danmaku-overlay';
artInner.appendChild(overlay);
const { items, scrollTime } = parsedItemsRef.current;
const renderer = new DanmakuRenderer(overlay, artRef.current.video, items, scrollTime, { danmaku: filterDanmaku, gift: filterGift, guard: filterGuard, sc: filterSC });
const { items, resY } = parsedItemsRef.current;
const renderer = new DanmakuRenderer(overlay, artRef.current.video, items, filter, resY, danmakuSettings);
danmakuRef.current = renderer;
renderer.start();
if (currentTime > 0) renderer.seek(currentTime);
}, [filterDanmaku, filterGift, filterGuard, filterSC]); // eslint-disable-line react-hooks/exhaustive-deps
if (artRef.current.video.currentTime > 0) {
renderer.seek(artRef.current.video.currentTime);
}
}, [filterDanmaku, filterGift, filterGuard, filterSC, danmakuSettings]); // eslint-disable-line react-hooks/exhaustive-deps
const showBatchRenameModal = () => {
setBatchFind("");
@@ -903,6 +959,7 @@ const FileList: React.FC = () => {
danmakuRef.current.stop();
danmakuRef.current = null;
}
parsedItemsRef.current = null;
if (artRef.current) {
artRef.current.destroy(true);
}
@@ -974,9 +1031,9 @@ const FileList: React.FC = () => {
fetch(assUrl)
.then(r => r.ok ? r.text() : Promise.reject())
.then(text => {
const { items, scrollTime } = parseAss(text);
const { items, scrollTime, resY } = parseAss(text);
if (items.length === 0) return;
parsedItemsRef.current = { items, scrollTime };
parsedItemsRef.current = { items, scrollTime, resY };
let danmakuCount = 0, giftCount = 0, giftAmount = 0, guardCount = 0, guardAmount = 0, scCount = 0, scAmount = 0;
for (const item of items) {
if (item.style === 'Danmaku') danmakuCount++;
@@ -1010,7 +1067,10 @@ const FileList: React.FC = () => {
overlay.className = 'danmaku-overlay';
artInner.appendChild(overlay);
const renderer = new DanmakuRenderer(overlay, art.video, items, scrollTime, { danmaku: filterDanmaku, gift: filterGift, guard: filterGuard, sc: filterSC });
// 使用 ASS 解析的 scrollTime 作为初始值
const initialSettings = { ...danmakuSettings, scrollTime };
setDanmakuSettings(initialSettings);
const renderer = new DanmakuRenderer(overlay, art.video, items, { danmaku: filterDanmaku, gift: filterGift, guard: filterGuard, sc: filterSC }, resY, initialSettings);
danmakuRef.current = renderer;
renderer.start();
// 如果浏览器恢复了播放位置,跳转到当前时间
@@ -1192,7 +1252,7 @@ const FileList: React.FC = () => {
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
{hasAss && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, fontWeight: 400 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, fontWeight: 400 }}>
<span style={{ color: loadDanmaku ? '#1890ff' : '#ffffff73' }}>弹幕</span>
<Switch
size="small"
@@ -1202,21 +1262,80 @@ const FileList: React.FC = () => {
{loadDanmaku && (
<>
<span style={{ color: '#ffffff40' }}>|</span>
{([
{ label: '文字', checked: filterDanmaku, onChange: setFilterDanmaku },
{ label: '礼物', checked: filterGift, onChange: setFilterGift },
{ label: '上舰', checked: filterGuard, onChange: setFilterGuard },
{ label: 'SC', checked: filterSC, onChange: setFilterSC },
] as const).map(({ label, checked, onChange }) => (
<div key={label} style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12 }}>
<span style={{ color: checked ? '#1890ff' : '#ffffff73' }}>{label}</span>
<Switch size="small" checked={checked} onChange={onChange} />
</div>
))}
<Checkbox checked={filterDanmaku} onChange={(e) => setFilterDanmaku(e.target.checked)}>
<span style={{ fontSize: 12 }}>文字</span>
</Checkbox>
<Checkbox checked={filterGift} onChange={(e) => setFilterGift(e.target.checked)}>
<span style={{ fontSize: 12 }}>礼物</span>
</Checkbox>
<Checkbox checked={filterGuard} onChange={(e) => setFilterGuard(e.target.checked)}>
<span style={{ fontSize: 12 }}>上舰</span>
</Checkbox>
<Checkbox checked={filterSC} onChange={(e) => setFilterSC(e.target.checked)}>
<span style={{ fontSize: 12 }}>SC</span>
</Checkbox>
</>
)}
</div>
)}
{hasAss && loadDanmaku && (
<Popover
content={
<div style={{ width: 220 }}>
<div style={{ marginBottom: 12 }}>
<div style={{ marginBottom: 4, fontSize: 12, color: '#999' }}>显示区域</div>
<Select
value={danmakuSettings.area}
onChange={(v) => setDanmakuSettings(s => ({ ...s, area: v }))}
style={{ width: '100%' }}
size="small"
options={[
{ label: '全屏', value: 'full' },
{ label: '顶部半屏', value: 'top' },
{ label: '底部半屏', value: 'bottom' },
{ label: '顶部1/4屏', value: 'quarter' },
{ label: '顶部3/4屏', value: 'three-quarter' },
]}
/>
</div>
<div style={{ marginBottom: 12 }}>
<div style={{ marginBottom: 4, fontSize: 12, color: '#999' }}>字体大小: {danmakuSettings.fontSize}px</div>
<Slider
min={12}
max={48}
value={danmakuSettings.fontSize}
onChange={(v) => setDanmakuSettings(s => ({ ...s, fontSize: v }))}
/>
</div>
<div style={{ marginBottom: 12 }}>
<div style={{ marginBottom: 4, fontSize: 12, color: '#999' }}>透明度: {Math.round(danmakuSettings.opacity * 100)}%</div>
<Slider
min={20}
max={100}
value={Math.round(danmakuSettings.opacity * 100)}
onChange={(v) => setDanmakuSettings(s => ({ ...s, opacity: v / 100 }))}
/>
</div>
<div>
<div style={{ marginBottom: 4, fontSize: 12, color: '#999' }}>滚动速度: {danmakuSettings.scrollTime}秒</div>
<Slider
min={3}
max={20}
value={danmakuSettings.scrollTime}
onChange={(v) => setDanmakuSettings(s => ({ ...s, scrollTime: v }))}
/>
</div>
<div style={{ marginTop: 8, textAlign: 'right' }}>
<Button size="small" onClick={() => setDanmakuSettings(DEFAULT_DANMAKU_SETTINGS)}>恢复默认</Button>
</div>
</div>
}
trigger="click"
placement="bottomRight"
>
<span style={{ color: '#1890ff', cursor: 'pointer', fontSize: 13, fontWeight: 600, background: '#e6f4ff', padding: '2px 8px', borderRadius: 4 }}>弹</span>
</Popover>
)}
<div className="close-btn" onClick={hidePlayer} title="退出播放 (Esc)">
<CloseOutlined />
</div>
+4 -2
View File
@@ -18,6 +18,8 @@ import {
import './layout.css';
const { Header, Content, Sider } = Layout;
// 功能开关:IO 统计(开发中,设为 false 隐藏 UI)
const ENABLE_IO_STATS_UI = false;
interface Props {
children?: React.ReactNode;
@@ -142,11 +144,11 @@ class RootLayout extends React.Component<Props, State> {
icon: <CalendarOutlined />,
label: <a href="/scheduler/" target="_blank" rel="noopener noreferrer">调度器</a>,
},
{
...(ENABLE_IO_STATS_UI ? [{
key: 'iostats',
icon: <LineChartOutlined />,
label: <Link to="/iostats">IO 统计</Link>,
},
}] : []),
{
key: 'update',
icon: <CloudUploadOutlined />,
+9 -19
View File
@@ -2,7 +2,7 @@ import React from "react";
import { Button, Divider, Table, Tag, Tabs, Row, Col, Tooltip, message, List, Typography, Switch, Space, Popconfirm, Select, Spin } from 'antd';
import { EditOutlined, SyncOutlined, CloudSyncOutlined, ReloadOutlined, SwapOutlined, CheckCircleOutlined, ExclamationCircleOutlined, CommentOutlined } from '@ant-design/icons';
import PopDialog from '../pop-dialog/index';
import AddRoomDialog from '../add-room-dialog/index';
import BatchAddRoomDialog from '../batch-add-room-dialog/index';
import LogPanel from '../log-panel/index';
import HistoryPanel from '../history-panel/index';
import DanmakuPanel, { DanmakuMessage } from '../danmaku-panel/index';
@@ -302,7 +302,7 @@ type RefreshStatus = 'idle' | 'waiting_interval' | 'waiting_rate_limit' | 'refre
interface IState {
list: ItemData[],
cookieList: CookieItemData[],
addRoomDialogVisible: boolean,
batchAddDialogVisible: boolean,
window: any,
expandedRowKeys: string[], // 展开的行
expandedDetails: { [key: string]: any }, // 直播间详细信息缓存
@@ -344,9 +344,6 @@ interface Room {
}
class LiveList extends React.Component<Props, IState> {
//子控件
child!: AddRoomDialog;
//弹幕批量缓冲(高频场景优化)
private danmakuBuffer: { [roomId: string]: DanmakuMessage[] } = {};
private danmakuFlushTimer: ReturnType<typeof setTimeout> | null = null;
@@ -614,7 +611,7 @@ class LiveList extends React.Component<Props, IState> {
this.state = {
list: [],
cookieList: [],
addRoomDialogVisible: false,
batchAddDialogVisible: false,
window: window,
expandedRowKeys: [],
expandedDetails: {},
@@ -891,21 +888,10 @@ class LiveList extends React.Component<Props, IState> {
});
}
onRef = (ref: AddRoomDialog) => {
this.child = ref
}
onCookieRef = (ref: EditCookieDialog) => {
this.cookieChild = ref
}
/**
* 当添加房间按钮点击,弹出Dialog
*/
onAddRoomClick = () => {
this.child.showModal()
}
onEditCookitClick = (data: any) => {
this.cookieChild.showModal(data)
}
@@ -2102,10 +2088,14 @@ class LiveList extends React.Component<Props, IState> {
</Space>
</Tooltip>
<Button key="2" type="default" onClick={this.onSettingSave}>保存设置</Button>
<Button key="1" type="primary" onClick={this.onAddRoomClick}>
<Button key="1" type="primary" onClick={() => this.setState({ batchAddDialogVisible: true })}>
添加房间
</Button>
<AddRoomDialog key="0" ref={this.onRef} refresh={this.refresh} />
<BatchAddRoomDialog
visible={this.state.batchAddDialogVisible}
onClose={() => this.setState({ batchAddDialogVisible: false })}
onSuccess={this.refresh}
/>
</div>
</div>
<Table
+10
View File
@@ -40,6 +40,16 @@ class API {
return utils.requestPost(`${BASE_URL}/lives`, reqBody);
}
/**
* 批量添加直播间
* @param urls URL列表
* @param listen 是否立即监听
* @param batchId 客户端生成的批次ID(可选,用于SSE进度追踪)
*/
batchAddRooms(urls: string[], listen: boolean = true, notifyOnly: boolean = false, batchId?: string) {
return utils.requestPost(`${BASE_URL}/lives/batch`, { urls, listen, notify_only: notifyOnly, batch_id: batchId });
}
/**
* 删除直播间
* @param id 直播间id
+13 -1
View File
@@ -18,7 +18,9 @@ export type SSEEventType =
| 'update_error'
| 'danmaku'
| 'ffmpeg_status'
| 'memory_warning';
| 'memory_warning'
| 'batch_progress'
| 'batch_complete';
// SSE 消息结构
export interface SSEMessage {
@@ -157,6 +159,16 @@ class SSEManager {
this.handleMessage('memory_warning', event.data);
});
// 监听 batch_progress 事件(批量添加进度)
this.eventSource.addEventListener('batch_progress', (event: MessageEvent) => {
this.handleMessage('batch_progress', event.data);
});
// 监听 batch_complete 事件(批量添加完成)
this.eventSource.addEventListener('batch_complete', (event: MessageEvent) => {
this.handleMessage('batch_complete', event.data);
});
} catch (error) {
console.error('[SSE] Failed to create EventSource:', error);
this.isConnecting = false;
+5 -1
View File
@@ -29,6 +29,11 @@ test.describe('设置页面测试', () => {
expect(count).toBeGreaterThan(0);
});
test('云盘上传设置已隐藏', async ({ page }) => {
await expect(page.getByRole('main')).toBeVisible();
await expect(page.getByText('云盘上传')).toHaveCount(0);
});
test('输入框可以编辑', async ({ page }) => {
await page.waitForTimeout(1000);
@@ -161,4 +166,3 @@ test.describe('设置页面 - 标签页切换', () => {
}
});
});
+10 -7
View File
@@ -111,8 +111,8 @@ test.describe('添加直播间对话框测试', () => {
// 验证对话框标题
await expect(page.getByText('添加直播间')).toBeVisible();
// 验证输入框存在
const input = modal.locator('input');
// 验证输入框存在(单个添加模式下有文本输入框)
const input = modal.locator('input[type="text"]');
await expect(input).toBeVisible();
// 关闭对话框
@@ -122,14 +122,17 @@ test.describe('添加直播间对话框测试', () => {
});
test('对话框取消按钮可以关闭', async ({ page }) => {
const addButton = page.locator('button').filter({ hasText: /添加|新增/i }).first();
const addButton = page.locator('button').filter({ hasText: '添加房间' });
if (await addButton.isVisible()) {
await addButton.click();
await page.waitForTimeout(500);
// 点击取消按钮
await page.getByRole('button', { name: /取消|Cancel/i }).click();
// 等待对话框完全渲染(包括标题和按钮)
await expect(page.getByText('添加直播间')).toBeVisible({ timeout: 5000 });
await page.waitForTimeout(300);
// 关闭对话框(点击右上角 X 按钮)
await page.locator('.ant-modal-close').click();
await page.waitForTimeout(300);
// 验证对话框关闭
@@ -144,7 +147,7 @@ test.describe('添加直播间对话框测试', () => {
await addButton.click();
await page.waitForTimeout(500);
const input = page.locator('.ant-modal input');
const input = page.locator('.ant-modal input[type="text"]');
await input.fill('https://live.bilibili.com/123456');
// 验证输入值
+5 -17
View File
@@ -11,15 +11,11 @@ test.describe('导航功能测试', () => {
await page.waitForLoadState('domcontentloaded');
});
test('侧边栏包含所有菜单项', async ({ page }) => {
test('侧边栏包含已开放的菜单项', async ({ page }) => {
// 验证侧边栏存在
const sider = page.getByRole('complementary');
await expect(sider).toBeVisible();
// 验证菜单项(使用 menuitem 角色)
const menuItems = page.getByRole('menuitem');
await expect(menuItems).toHaveCount(10); // 10个菜单项
// 验证各个菜单项文本
await expect(page.getByRole('menuitem', { name: /监控列表/ })).toBeVisible();
await expect(page.getByRole('menuitem', { name: /系统状态/ })).toBeVisible();
@@ -29,10 +25,13 @@ test.describe('导航功能测试', () => {
await expect(page.getByRole('menuitem', { name: /工具/ })).toBeVisible();
await expect(page.getByRole('menuitem', { name: /任务队列/ })).toBeVisible();
await expect(page.getByRole('menuitem', { name: /调度器/ })).toBeVisible();
await expect(page.getByRole('menuitem', { name: /IO 统计/ })).toBeVisible();
await expect(page.getByRole('menuitem', { name: /更新/ })).toBeVisible();
});
test('侧边栏不显示 IO 统计入口', async ({ page }) => {
await expect(page.getByRole('menuitem', { name: /IO 统计/ })).toHaveCount(0);
});
test('点击导航到系统状态页面', async ({ page }) => {
await page.getByRole('menuitem', { name: /系统状态/ }).click();
await page.waitForTimeout(500);
@@ -77,17 +76,6 @@ test.describe('导航功能测试', () => {
await expect(page.getByRole('main')).toBeVisible();
});
test('点击导航到IO统计页面', async ({ page }) => {
await page.getByRole('menuitem', { name: /IO 统计/ }).click();
await page.waitForTimeout(500);
// 验证 URL 包含 iostats
expect(page.url()).toContain('iostats');
// 验证页面内容加载
await expect(page.getByRole('main')).toBeVisible();
});
test('点击导航到更新页面', async ({ page }) => {
await page.getByRole('menuitem', { name: /更新/ }).click();
await page.waitForTimeout(500);