diff --git a/src/configs/config.go b/src/configs/config.go
index 9d504de..4945cd1 100644
--- a/src/configs/config.go
+++ b/src/configs/config.go
@@ -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)
diff --git a/src/recorders/danmaku/ass.go b/src/recorders/danmaku/ass.go
index 1e54003..85251e4 100644
--- a/src/recorders/danmaku/ass.go
+++ b/src/recorders/danmaku/ass.go
@@ -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 {
diff --git a/src/webapp/src/component/danmaku-config/index.tsx b/src/webapp/src/component/danmaku-config/index.tsx
index ff4c84f..bea9902 100644
--- a/src/webapp/src/component/danmaku-config/index.tsx
+++ b/src/webapp/src/component/danmaku-config/index.tsx
@@ -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' },
]} />
= ({ messages }) => {
{isAllFiltered ? `共 ${displayMessages.length} 条` : `已筛选 ${filteredMessages.length}/${displayMessages.length} 条`}
- {displayMessages.length >= MAX_MESSAGES && (
- (已达上限)
- )}
{autoScroll && (
自动滚动中
)}
diff --git a/src/webapp/src/component/file-list/file-list.css b/src/webapp/src/component/file-list/file-list.css
index 8921222..81579db 100644
--- a/src/webapp/src/component/file-list/file-list.css
+++ b/src/webapp/src/component/file-list/file-list.css
@@ -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;
}
\ No newline at end of file
diff --git a/src/webapp/src/component/file-list/index.tsx b/src/webapp/src/component/file-list/index.tsx
index c5f06a6..ef5432d 100644
--- a/src/webapp/src/component/file-list/index.tsx
+++ b/src/webapp/src/component/file-list/index.tsx
@@ -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 = {};
const styleBackColors: Record = {};
+ // 样式名 → MarginV
+ const styleMarginV: Record = {};
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(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 = () => {
{hasAss && (
-
+
弹幕
{
{loadDanmaku && (
<>
|
- {([
- { 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 }) => (
-
- {label}
-
-
- ))}
+ setFilterDanmaku(e.target.checked)}>
+ 文字
+
+ setFilterGift(e.target.checked)}>
+ 礼物
+
+ setFilterGuard(e.target.checked)}>
+ 上舰
+
+ setFilterSC(e.target.checked)}>
+ SC
+
>
)}
)}
+ {hasAss && loadDanmaku && (
+
+
+
显示区域
+
+
+
字体大小: {danmakuSettings.fontSize}px
+
setDanmakuSettings(s => ({ ...s, fontSize: v }))}
+ />
+
+
+
透明度: {Math.round(danmakuSettings.opacity * 100)}%
+
setDanmakuSettings(s => ({ ...s, opacity: v / 100 }))}
+ />
+
+
+
滚动速度: {danmakuSettings.scrollTime}秒
+
setDanmakuSettings(s => ({ ...s, scrollTime: v }))}
+ />
+
+
+
+
+
+ }
+ trigger="click"
+ placement="bottomRight"
+ >
+
弹
+
+ )}