From 6a039dc6a6d71dbc065d15beae059050425586d5 Mon Sep 17 00:00:00 2001 From: Joftal <42807405+Joftal@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:43:44 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat=EF=BC=9A=E6=96=B0=E5=A2=9E1/4=E3=80=81?= =?UTF-8?q?3/4=E5=BC=B9=E5=B9=95=E5=BD=95=E5=88=B6=E5=8C=BA=E5=9F=9F?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E3=80=82=E4=BC=98=E5=8C=96=E5=BC=B9=E5=B9=95?= =?UTF-8?q?=E9=98=B2=E9=87=8D=E5=8F=A0=E9=80=BB=E8=BE=91=EF=BC=88=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E5=9F=BA=E4=BA=8E=E6=96=87=E5=AD=97=E5=AE=BD=E5=BA=A6?= =?UTF-8?q?=E8=BF=9B=E8=A1=8C=E8=AE=A1=E7=AE=97=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/configs/config.go | 12 +++-- src/recorders/danmaku/ass.go | 54 +++++++++++++------ .../src/component/danmaku-config/index.tsx | 2 + 3 files changed, 48 insertions(+), 20 deletions(-) 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..182cc7e 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, endCS, 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, endCS, textWidth) + // 基于文字宽度的防重叠:使用调整后的起始时间 + if adjustedStartCS != startCS { + startCS = adjustedStartCS + endCS = startCS + durationCS + } laneHeight := w.cfg.FontSize + 4 marginV := (lane + w.laneStart) * laneHeight @@ -373,24 +387,34 @@ func (w *AssWriter) AddSuperChat(recvAt time.Time, username, text string, price } } -func (w *AssWriter) assignLane(startCS, endCS int64) int { +// assignLane 分配一个空闲 lane,基于文字宽度的防重叠。 +// 参数:startCS 弹幕开始时间,endCS 弹幕结束时间,textWidth 文字像素宽度。 +// 返回值:lane 索引、调整后的 startCS(可能延迟)。 +func (w *AssWriter) assignLane(startCS, endCS int64, textWidth int) (int, int64) { + // 优先找空闲 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(textWidth)/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(textWidth)/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' }, ]} /> Date: Mon, 29 Jun 2026 17:06:32 +0800 Subject: [PATCH 2/5] =?UTF-8?q?feat=EF=BC=9A=E5=90=8C=E6=AD=A5=E4=BC=98?= =?UTF-8?q?=E5=8C=96web=E7=95=8C=E9=9D=A2=E5=BC=B9=E5=B9=95=E9=98=B2?= =?UTF-8?q?=E9=87=8D=E5=8F=A0=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/webapp/src/component/file-list/index.tsx | 141 +++++++++---------- 1 file changed, 63 insertions(+), 78 deletions(-) diff --git a/src/webapp/src/component/file-list/index.tsx b/src/webapp/src/component/file-list/index.tsx index c5f06a6..38784f6 100644 --- a/src/webapp/src/component/file-list/index.tsx +++ b/src/webapp/src/component/file-list/index.tsx @@ -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[22].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,8 @@ 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 marginV = parseInt(parts[7].trim()) || styleMarginV[style] || 0; const raw = parts.slice(9).join(','); // 优先使用 Dialogue 行中的 {\c} 覆盖色,否则回退到样式定义的 PrimaryColour let color = styleColors[style] || 'rgba(255,255,255,1)'; @@ -104,22 +113,20 @@ 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 }; } // ==================== 弹幕渲染器 ==================== 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; @@ -137,20 +144,22 @@ class DanmakuRenderer { 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[], scrollTime: number, filter: DanmakuFilter, resY: number) { 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; } private matchesFilter(style: string): boolean { @@ -169,8 +178,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 +186,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 +200,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 = []; @@ -225,12 +229,13 @@ 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. 清理已结束的滚动弹幕 + for (let i = this.scrollEls.length - 1; i >= 0; i--) { + if (this.scrollEls[i].endTime <= t) { + this.scrollEls[i].el.remove(); + this.scrollEls.splice(i, 1); } } @@ -258,7 +263,7 @@ 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); } @@ -266,15 +271,13 @@ class DanmakuRenderer { } // 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 + '%'; - } + for (const d of this.scrollEls) { + 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 + '%'; } }; @@ -297,33 +300,12 @@ class DanmakuRenderer { return px; } - private spawnScroll(item: DanmakuEntry, t: number, cw: number) { + private spawnScroll(item: DanmakuEntry, t: number, cw: number, ch: number) { 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; + // 计算结束时间(从 ASS 的 start + scrollTime) + const endTime = item.start + this.scrollTime + (textPx / cw) * this.scrollTime; // 创建 DOM const el = document.createElement('div'); @@ -333,12 +315,15 @@ class DanmakuRenderer { el.style.fontSize = DANMAKU_FONT_SIZE + 'px'; el.style.lineHeight = (DANMAKU_FONT_SIZE + 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 +443,7 @@ 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 parsedItemsRef = useRef<{ items: DanmakuEntry[]; scrollTime: number; resY: number } | null>(null); const currentPlayingRef = useRef<{ record: CurrentFolderFile; fullPath: string } | null>(null); // 重命名相关状态 @@ -601,9 +586,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 +620,7 @@ 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 }); + const renderer = new DanmakuRenderer(overlay, artRef.current!.video, items, scrollTime, { danmaku: filterDanmaku, gift: filterGift, guard: filterGuard, sc: filterSC }, resY); danmakuRef.current = renderer; renderer.start(); if (artRef.current!.video.currentTime > 0) { @@ -658,8 +643,8 @@ 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, scrollTime, resY } = parsedItemsRef.current; + const renderer = new DanmakuRenderer(overlay, artRef.current.video, items, scrollTime, { danmaku: filterDanmaku, gift: filterGift, guard: filterGuard, sc: filterSC }, resY); danmakuRef.current = renderer; renderer.start(); if (currentTime > 0) renderer.seek(currentTime); @@ -974,9 +959,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 +995,7 @@ 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 }); + const renderer = new DanmakuRenderer(overlay, art.video, items, scrollTime, { danmaku: filterDanmaku, gift: filterGift, guard: filterGuard, sc: filterSC }, resY); danmakuRef.current = renderer; renderer.start(); // 如果浏览器恢复了播放位置,跳转到当前时间 From d7e832905dfe97aaf97fcdb58422b4356f30c896 Mon Sep 17 00:00:00 2001 From: Joftal <42807405+Joftal@users.noreply.github.com> Date: Mon, 29 Jun 2026 17:51:38 +0800 Subject: [PATCH 3/5] =?UTF-8?q?feat=EF=BC=9A=E4=BC=98=E5=8C=96web=E6=92=AD?= =?UTF-8?q?=E6=94=BE=E5=99=A8=E5=BC=B9=E5=B9=95=E6=B8=B2=E6=9F=93=E9=80=BB?= =?UTF-8?q?=E8=BE=91=EF=BC=8C=E6=94=AF=E6=8C=81=E5=AE=9E=E7=8E=B0=E6=B8=B2?= =?UTF-8?q?=E6=9F=93=E5=AD=97=E4=BD=93=E5=A4=A7=E5=B0=8F=E3=80=81=E9=80=8F?= =?UTF-8?q?=E6=98=8E=E5=BA=A6=E3=80=81=E5=BC=B9=E5=B9=95=E5=8C=BA=E5=9F=9F?= =?UTF-8?q?=E3=80=81=E9=80=9F=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/component/danmaku-panel/index.tsx | 5 +- .../src/component/file-list/file-list.css | 14 ++ src/webapp/src/component/file-list/index.tsx | 154 +++++++++++++++--- 3 files changed, 142 insertions(+), 31 deletions(-) diff --git a/src/webapp/src/component/danmaku-panel/index.tsx b/src/webapp/src/component/danmaku-panel/index.tsx index 4c0e873..d6dc873 100644 --- a/src/webapp/src/component/danmaku-panel/index.tsx +++ b/src/webapp/src/component/danmaku-panel/index.tsx @@ -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 = ({ 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 38784f6..f70b0c1 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, @@ -119,6 +119,24 @@ function parseAss(content: string): { items: DanmakuEntry[]; scrollTime: number; 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; @@ -141,6 +159,7 @@ class DanmakuRenderer { private items: DanmakuEntry[]; private scrollTime: number; private filter: DanmakuFilter; + private settings: DanmakuSettings; private rafId = 0; private running = false; private nextIdx = 0; @@ -153,13 +172,26 @@ class DanmakuRenderer { // ASS 原始分辨率(用于 marginV 缩放) private resY = 1080; - constructor(overlay: HTMLDivElement, video: HTMLVideoElement, items: DanmakuEntry[], scrollTime: number, filter: DanmakuFilter, resY: number) { + constructor(overlay: HTMLDivElement, video: HTMLVideoElement, items: DanmakuEntry[], scrollTime: number, 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; + this.settings = settings; + } + + /** 判断弹幕是否在指定区域内 */ + 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 { @@ -270,10 +302,11 @@ class DanmakuRenderer { this.nextIdx++; } - // 4. 更新所有滚动弹幕位置 + // 4. 更新所有滚动弹幕位置(使用 settings 中的 scrollTime) + const scrollTime = this.settings.scrollTime; for (const d of this.scrollEls) { const elapsed = t - d.spawnTime; - const progress = elapsed / this.scrollTime; + const progress = elapsed / scrollTime; const el = d.el; const twPct = parseFloat(el.dataset.tw || '20'); const leftPct = 100 - progress * (100 + twPct); @@ -283,37 +316,44 @@ class DanmakuRenderer { /** 估算文本宽度(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, 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; - // 计算结束时间(从 ASS 的 start + scrollTime) - const endTime = item.start + this.scrollTime + (textPx / cw) * this.scrollTime; + // 计算结束时间(使用 settings 中的 scrollTime) + const endTime = item.start + 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.opacity = String(this.settings.opacity); 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)'; // 直接使用 ASS 的 marginV,按比例缩放到 overlay 高度 @@ -443,6 +483,7 @@ const FileList: React.FC = () => { const [filterGift, setFilterGift] = useState(true); const [filterGuard, setFilterGuard] = useState(true); const [filterSC, setFilterSC] = useState(true); + 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); @@ -620,7 +661,7 @@ 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 }, resY); + const renderer = new DanmakuRenderer(overlay, artRef.current!.video, items, scrollTime, { danmaku: filterDanmaku, gift: filterGift, guard: filterGuard, sc: filterSC }, resY, danmakuSettings); danmakuRef.current = renderer; renderer.start(); if (artRef.current!.video.currentTime > 0) { @@ -644,11 +685,11 @@ const FileList: React.FC = () => { artInner.appendChild(overlay); const { items, scrollTime, resY } = parsedItemsRef.current; - const renderer = new DanmakuRenderer(overlay, artRef.current.video, items, scrollTime, { danmaku: filterDanmaku, gift: filterGift, guard: filterGuard, sc: filterSC }, resY); + const renderer = new DanmakuRenderer(overlay, artRef.current.video, items, scrollTime, { danmaku: filterDanmaku, gift: filterGift, guard: filterGuard, sc: filterSC }, resY, danmakuSettings); danmakuRef.current = renderer; renderer.start(); if (currentTime > 0) renderer.seek(currentTime); - }, [filterDanmaku, filterGift, filterGuard, filterSC]); // eslint-disable-line react-hooks/exhaustive-deps + }, [filterDanmaku, filterGift, filterGuard, filterSC, danmakuSettings]); // eslint-disable-line react-hooks/exhaustive-deps const showBatchRenameModal = () => { setBatchFind(""); @@ -995,7 +1036,7 @@ 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 }, resY); + const renderer = new DanmakuRenderer(overlay, art.video, items, scrollTime, { danmaku: filterDanmaku, gift: filterGift, guard: filterGuard, sc: filterSC }, resY, danmakuSettings); danmakuRef.current = renderer; renderer.start(); // 如果浏览器恢复了播放位置,跳转到当前时间 @@ -1177,7 +1218,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 && ( + +
+
显示区域
+