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:
commit
6096171663
22 files changed
+1153
-351
No files matched your search
@@ -191,6 +191,7 @@ test/launcher-config-local.json
|
||||
|
||||
# E2E 测试输出(录制文件等)
|
||||
/test-output/
|
||||
test-output*appdata/
|
||||
|
||||
# npm 锁文件(项目统一使用 yarn)
|
||||
package-lock.json
|
||||
|
||||
+110
-61
@@ -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"`
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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('设置页面 - 标签页切换', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
// 验证输入值
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user