Merge pull request #1154 from Joftal/dev

新增1/4、3/4弹幕录制区域设置。优化弹幕防重叠逻辑。优化web播放器弹幕渲染逻辑
This commit is contained in:
Eric authored and GitHub committed 2026-06-30 11:33:12 +08:00
commit 8b2a0dc88a
6 files changed
+297 -137

No files matched your search

+7 -5
View File
@@ -79,7 +79,7 @@ func (f *Feature) GetEffectiveDownloaderType() DownloaderType {
type DanmakuConfig struct {
FontSize int `yaml:"font_size" json:"font_size"` // 字体大小 (12~120)
FontName string `yaml:"font_name" json:"font_name"` // 字体名称
ScrollArea string `yaml:"scroll_area" json:"scroll_area"` // 滚动区域: full(全屏), top(顶部), bottom(底部)
ScrollArea string `yaml:"scroll_area" json:"scroll_area"` // 滚动区域: full(全屏), top(顶部半屏), bottom(底部半屏), quarter(1/4屏), three-quarter(3/4屏)
ScrollTime int `yaml:"scroll_time" json:"scroll_time"` // 弹幕滚过屏幕的秒数 (5~20)
Resolution string `yaml:"resolution" json:"resolution"` // 播放分辨率
Outline *int `yaml:"outline,omitempty" json:"outline,omitempty"` // 描边粗细 (0~4),nil 表示使用默认值
@@ -115,9 +115,11 @@ var defaultDanmakuConfig = DanmakuConfig{
// validScrollAreas 支持的滚动区域
var validScrollAreas = map[string]bool{
"full": true, // 全屏滚动
"top": true, // 仅在屏幕上半部分滚动
"bottom": true, // 仅在屏幕下半部分滚动
"full": true, // 全屏滚动
"top": true, // 仅在屏幕上半部分滚动
"bottom": true, // 仅在屏幕下半部分滚动
"quarter": true, // 仅在屏幕上1/4部分滚动
"three-quarter": true, // 仅在屏幕上3/4部分滚动
}
// validResolutions 支持的分辨率列表
@@ -221,7 +223,7 @@ func (d *DanmakuConfig) ValidateWithPlatform(platformKey string) error {
return fmt.Errorf("字体名称不能为空")
}
if !validScrollAreas[d.ScrollArea] {
return fmt.Errorf("不支持的滚动区域: %s,可选值: full, top, bottom", d.ScrollArea)
return fmt.Errorf("不支持的滚动区域: %s,可选值: full, top, bottom, quarter, three-quarter", d.ScrollArea)
}
if d.ScrollTime < 5 || d.ScrollTime > 20 {
return fmt.Errorf("滚动时间必须在 5~20 秒之间,当前值: %d", d.ScrollTime)
+41 -15
View File
@@ -28,7 +28,7 @@ type AssWriter struct {
laneEnd int // last usable lane index (exclusive)
laneNum int // total lanes in the usable range
nextLane int
laneLast []int64 // last end time (centiseconds) per lane
laneLast []int64 // last tail-clear time (centiseconds) per lane
}
func parseResolution(res string) (int, int) {
@@ -72,6 +72,10 @@ func NewAssWriter(filePath string, startAt time.Time, cfg configs.DanmakuConfig,
laneEnd = totalLanes / 2
case "bottom":
laneStart = totalLanes / 2
case "quarter":
laneEnd = totalLanes / 4
case "three-quarter":
laneEnd = totalLanes * 3 / 4
}
laneNum := laneEnd - laneStart
if laneNum < 1 {
@@ -84,14 +88,14 @@ func NewAssWriter(filePath string, startAt time.Time, cfg configs.DanmakuConfig,
cfg: cfg,
title: title,
resX: resX,
resY: resY,
resY: resY,
scrollTimeMs: scrollTimeMs,
bannerSpeed: bannerSpeed,
laneStart: laneStart,
laneEnd: laneEnd,
laneNum: laneNum,
nextLane: 0,
laneLast: make([]int64, laneNum),
bannerSpeed: bannerSpeed,
laneStart: laneStart,
laneEnd: laneEnd,
laneNum: laneNum,
nextLane: 0,
laneLast: make([]int64, laneNum),
}
if err := w.writeHeader(); err != nil {
@@ -251,7 +255,12 @@ func (w *AssWriter) AddDanmaku(recvAt time.Time, username, text string, color in
}
endCS := startCS + durationCS
lane := w.assignLane(startCS, endCS)
lane, adjustedStartCS := w.assignLane(startCS, textWidth)
// 基于文字宽度的防重叠:使用调整后的起始时间
if adjustedStartCS != startCS {
startCS = adjustedStartCS
endCS = startCS + durationCS
}
laneHeight := w.cfg.FontSize + 4
marginV := (lane + w.laneStart) * laneHeight
@@ -298,7 +307,12 @@ func (w *AssWriter) AddGift(recvAt time.Time, username, giftName string, num int
}
endCS := startCS + durationCS
lane := w.assignLane(startCS, endCS)
lane, adjustedStartCS := w.assignLane(startCS, textWidth)
// 基于文字宽度的防重叠:使用调整后的起始时间
if adjustedStartCS != startCS {
startCS = adjustedStartCS
endCS = startCS + durationCS
}
laneHeight := w.cfg.FontSize + 4
marginV := (lane + w.laneStart) * laneHeight
@@ -373,24 +387,36 @@ func (w *AssWriter) AddSuperChat(recvAt time.Time, username, text string, price
}
}
func (w *AssWriter) assignLane(startCS, endCS int64) int {
// assignLane 分配一个空闲 lane,基于文字宽度的防重叠。
// 参数:startCS 弹幕开始时间,textWidth 文字像素宽度。
// 返回值:lane 索引、调整后的 startCS(可能延迟)。
func (w *AssWriter) assignLane(startCS int64, textWidth int) (int, int64) {
// 安全间距:防止因字体渲染差异导致的重叠
safeTextWidth := textWidth + w.cfg.FontSize
// 优先找空闲 lane(前一条弹幕的尾部已离开屏幕右侧)
for i := 0; i < w.laneNum; i++ {
idx := (w.nextLane + i) % w.laneNum
if w.laneLast[idx] <= startCS {
w.laneLast[idx] = endCS
// 存储该弹幕尾部离开右侧的时间点(用于下一条弹幕判断)
tailClearCS := startCS + int64(w.scrollTimeMs)*int64(safeTextWidth)/int64(w.resX)/10
w.laneLast[idx] = tailClearCS
w.nextLane = (idx + 1) % w.laneNum
return idx
return idx, startCS
}
}
// 所有 lane 都被占用,延迟到最早可用的时间点
earliest := 0
for i := 1; i < w.laneNum; i++ {
if w.laneLast[i] < w.laneLast[earliest] {
earliest = i
}
}
w.laneLast[earliest] = endCS
newStartCS := w.laneLast[earliest]
// 存储新弹幕尾部离开右侧的时间点
tailClearCS := newStartCS + int64(w.scrollTimeMs)*int64(safeTextWidth)/int64(w.resX)/10
w.laneLast[earliest] = tailClearCS
w.nextLane = (earliest + 1) % w.laneNum
return earliest
return earliest, newStartCS
}
func (w *AssWriter) OutputPath() string {
@@ -173,6 +173,8 @@ const DanmakuParamForm: React.FC<{
{ label: '全屏', value: 'full' },
{ label: '顶部半屏', value: 'top' },
{ label: '底部半屏', value: 'bottom' },
{ label: '1/4屏', value: 'quarter' },
{ label: '3/4屏', value: 'three-quarter' },
]} />
</Form.Item>
<Form.Item
@@ -5,7 +5,7 @@ import {
} from '@ant-design/icons';
import './danmaku-panel.css';
const MAX_MESSAGES = 500;
const MAX_MESSAGES = 5000;
const ITEM_HEIGHT = 32;
const OVERSCAN = 5;
@@ -320,9 +320,6 @@ const DanmakuPanel: React.FC<DanmakuPanelProps> = ({ messages }) => {
<span>
{isAllFiltered ? `共 ${displayMessages.length} 条` : `已筛选 ${filteredMessages.length}/${displayMessages.length} 条`}
</span>
{displayMessages.length >= MAX_MESSAGES && (
<span className="dm-limit-hint">(已达上限)</span>
)}
{autoScroll && (
<span className="dm-autoscroll-hint">自动滚动中</span>
)}
@@ -162,4 +162,18 @@
white-space: nowrap;
font-weight: bold;
will-change: left;
}
/* 播放器头部的复选框样式 */
.player-header .ant-checkbox-wrapper {
color: #ffffffb3 !important;
}
.player-header .ant-checkbox-wrapper:hover .ant-checkbox-inner {
border-color: #1890ff;
}
.player-header .ant-checkbox-checked .ant-checkbox-inner {
background-color: #1890ff;
border-color: #1890ff;
}
+232 -113
View File
@@ -1,6 +1,6 @@
import React, { useState, useEffect, useCallback, useRef } from "react";
import API from "../../utils/api";
import { Breadcrumb, Table, Button, Modal, Input, Popconfirm, message, Space, Tooltip, Switch } from "antd";
import { Breadcrumb, Table, Button, Modal, Input, Popconfirm, message, Space, Tooltip, Switch, Popover, Slider, Select, Checkbox } from "antd";
import {
// @ts-ignore
FolderOutlined,
@@ -41,23 +41,28 @@ function parseAssColor(c: string): string {
return `rgba(${r},${g},${b},${a.toFixed(2)})`;
}
type DanmakuEntry = { start: number; end: number; color: string; text: string; style: string; align: number; bgColor: string };
type DanmakuEntry = { start: number; end: number; color: string; text: string; style: string; align: number; bgColor: string; marginV: number };
/** 解析 ASS 文件,提取所有弹幕条目 */
function parseAss(content: string): { items: DanmakuEntry[]; scrollTime: number } {
function parseAss(content: string): { items: DanmakuEntry[]; scrollTime: number; resY: number } {
const lines = content.split('\n');
const items: DanmakuEntry[] = [];
let inStyles = false;
let inEvents = false;
let resX = 1920;
let resY = 1080;
let bannerSpeed = 80; // ms per pixel
// 样式名 → PrimaryColour / BackColour CSS 颜色
const styleColors: Record<string, string> = {};
const styleBackColors: Record<string, string> = {};
// 样式名 → MarginV
const styleMarginV: Record<string, number> = {};
for (const line of lines) {
const mr = line.match(/^PlayResX:\s*(\d+)/);
if (mr) resX = parseInt(mr[1]);
const my = line.match(/^PlayResY:\s*(\d+)/);
if (my) resY = parseInt(my[1]);
// 解析样式定义中的颜色(忽略大小写)
const trimmed = line.trim().toLowerCase();
@@ -66,12 +71,13 @@ function parseAss(content: string): { items: DanmakuEntry[]; scrollTime: number
if (line.startsWith('[')) { inStyles = false; inEvents = false; continue; }
if (inStyles && line.startsWith('Style:')) {
// Style: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, ...
// Style: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
const sp = line.substring('Style:'.length).split(',');
if (sp.length >= 7) {
if (sp.length >= 23) {
const name = sp[0].trim();
styleColors[name] = parseAssColor(sp[3].trim());
styleBackColors[name] = parseAssColor(sp[6].trim());
styleMarginV[name] = parseInt(sp[21].trim()) || 0;
}
continue;
}
@@ -79,6 +85,7 @@ function parseAss(content: string): { items: DanmakuEntry[]; scrollTime: number
if (!inEvents) continue;
if (!line.startsWith('Dialogue:')) continue;
// Dialogue: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
const parts = line.substring('Dialogue:'.length).split(',');
if (parts.length < 10) continue;
@@ -93,6 +100,9 @@ function parseAss(content: string): { items: DanmakuEntry[]; scrollTime: number
const start = parseAssTime(parts[1]);
const end = parseAssTime(parts[2]);
// MarginV: Dialogue 行的第 8 个字段(0-indexed: 7)
const mv = parseInt(parts[7].trim(), 10);
const marginV = Number.isNaN(mv) ? (styleMarginV[style] ?? 0) : mv;
const raw = parts.slice(9).join(',');
// 优先使用 Dialogue 行中的 {\c} 覆盖色,否则回退到样式定义的 PrimaryColour
let color = styleColors[style] || 'rgba(255,255,255,1)';
@@ -104,22 +114,38 @@ function parseAss(content: string): { items: DanmakuEntry[]; scrollTime: number
if (am) align = parseInt(am[1]);
const text = raw.replace(/\{[^}]*\}/g, '');
const bgColor = styleBackColors[style] || '';
if (end > start && text) items.push({ start, end, color, text, style, align, bgColor });
if (end > start && text) items.push({ start, end, color, text, style, align, bgColor, marginV });
}
return { items, scrollTime: (bannerSpeed * resX) / 1000 };
return { items, scrollTime: (bannerSpeed * resX) / 1000, resY };
}
// ==================== 弹幕设置 ====================
export type DanmakuArea = 'full' | 'top' | 'bottom' | 'quarter' | 'three-quarter';
export interface DanmakuSettings {
area: DanmakuArea; // 渲染区域
fontSize: number; // 字体大小 (12-48)
opacity: number; // 透明度 (0.2-1.0)
scrollTime: number; // 滚动时间 (3-20秒)
}
const DEFAULT_DANMAKU_SETTINGS: DanmakuSettings = {
area: 'full',
fontSize: 22,
opacity: 1.0,
scrollTime: 10,
};
// ==================== 弹幕渲染器 ====================
const DANMAKU_FONT_SIZE = 22;
const DANMAKU_GAP = 50; // 同轨道弹幕间隔(px)
/**
* 弹幕渲染器 — 核心:同轨道可同时显示多条弹幕
* 使用 requestAnimationFrame 手动控制 left 位置,而非 CSS animation,
* 这样可以精确跟踪每条弹幕的右边缘,实现真正的多弹幕同轨。
* 同时支持固定位置的消息(Guard/SC/Toast)。
* 弹幕渲染器 — 直接使用 ASS 文件中的时间轴和位置信息
* ASS 已包含防重叠逻辑,Web 端无需重复计算
* 使用 requestAnimationFrame 手动控制 left 位置实现滚动动画
*/
export interface DanmakuFilter {
danmaku: boolean;
@@ -132,25 +158,40 @@ class DanmakuRenderer {
private overlay: HTMLDivElement;
private video: HTMLVideoElement;
private items: DanmakuEntry[];
private scrollTime: number;
private filter: DanmakuFilter;
private settings: DanmakuSettings;
private rafId = 0;
private running = false;
private nextIdx = 0;
// 滚动弹幕轨道
private lanes: Array<{ el: HTMLDivElement; spawnTime: number; rightEdgeOutTime: number; tailEnterTime: number }[]> = [];
private laneCount = 0;
// 滚动弹幕(按结束时间排序,用于清理)
private scrollEls: { el: HTMLDivElement; spawnTime: number; endTime: number }[] = [];
// 固定位置消息(按位置分组堆叠)
private fixedEls: { el: HTMLDivElement; endTime: number; fadeOutTime: number; posKey: string; fadingOut: boolean }[] = [];
// 滚动样式白名单
private static SCROLL_STYLES = new Set(['Danmaku', 'Gift']);
// ASS 原始分辨率(用于 marginV 缩放)
private resY = 1080;
constructor(overlay: HTMLDivElement, video: HTMLVideoElement, items: DanmakuEntry[], scrollTime: number, filter: DanmakuFilter) {
constructor(overlay: HTMLDivElement, video: HTMLVideoElement, items: DanmakuEntry[], filter: DanmakuFilter, resY: number, settings: DanmakuSettings) {
this.overlay = overlay;
this.video = video;
this.items = items.slice().sort((a, b) => a.start - b.start);
this.scrollTime = scrollTime;
this.filter = filter;
this.resY = resY > 0 ? resY : 1080;
this.settings = settings;
this.overlay.style.opacity = String(settings.opacity);
}
/** 判断弹幕是否在指定区域内 */
private isInArea(marginV: number): boolean {
const ratio = marginV / this.resY;
switch (this.settings.area) {
case 'top': return ratio <= 0.5;
case 'bottom': return ratio > 0.5;
case 'quarter': return ratio <= 0.25;
case 'three-quarter': return ratio <= 0.75;
default: return true; // full
}
}
private matchesFilter(style: string): boolean {
@@ -169,8 +210,7 @@ class DanmakuRenderer {
if (this.running) return;
this.running = true;
this.nextIdx = 0;
this.laneCount = Math.max(1, Math.floor(this.overlay.clientHeight / (DANMAKU_FONT_SIZE + 4)));
this.lanes = Array.from({ length: this.laneCount }, () => []);
this.scrollEls = [];
this.rafId = requestAnimationFrame(this.tick);
}
@@ -178,14 +218,12 @@ class DanmakuRenderer {
this.running = false;
if (this.rafId) cancelAnimationFrame(this.rafId);
this.rafId = 0;
// 逐个移除固定消息(触发猴子补丁的定时器清理)
// 移除所有滚动弹幕
for (const d of this.scrollEls) d.el.remove();
this.scrollEls = [];
// 移除固定消息
for (const f of this.fixedEls) f.el.remove();
this.fixedEls = [];
// 逐个移除滚动弹幕
for (const lane of this.lanes) {
for (const d of lane) d.el.remove();
}
this.lanes = [];
// 移除 overlay 元素本身
if (this.overlay.parentNode) {
this.overlay.parentNode.removeChild(this.overlay);
@@ -194,11 +232,9 @@ class DanmakuRenderer {
/** 跳转到指定时间:清理已有弹幕,找到正确起始索引 */
seek(time: number) {
// 清理 DOM 和轨道
for (const lane of this.lanes) {
for (const d of lane) d.el.remove();
}
this.lanes = Array.from({ length: this.laneCount }, () => []);
// 清理 DOM
for (const d of this.scrollEls) d.el.remove();
this.scrollEls = [];
for (const f of this.fixedEls) f.el.remove();
this.fixedEls = [];
@@ -209,13 +245,32 @@ class DanmakuRenderer {
if (this.items[mid].start <= time) lo = mid + 1;
else hi = mid;
}
// 向前扫描:仅包含仍在显示期的固定消息(SC/上舰)
// 滚动弹幕不重新发射(它们无法从中间位置开始)
let idx = lo;
while (idx > 0 && this.items[idx - 1].end > time && !DanmakuRenderer.SCROLL_STYLES.has(this.items[idx - 1].style)) {
idx--;
this.nextIdx = lo;
// 向前寻找仍在显示期的固定消息并直接渲染
for (let i = lo - 1; i >= 0; i--) {
const item = this.items[i];
if (time - item.start > 10) break; // 超过 10 秒的消息肯定已结束
if (item.end > time && !DanmakuRenderer.SCROLL_STYLES.has(item.style)) {
if (this.matchesFilter(item.style)) {
this.spawnFixed(item, time);
}
}
}
this.nextIdx = idx;
}
/** 动态更新设置,无需销毁重建 */
update(filter: DanmakuFilter, settings: DanmakuSettings) {
this.filter = filter;
// 检测是否需要重绘:区域、字体大小、滚动时间变化会影响已上屏弹幕
const needReset = settings.area !== this.settings.area
|| settings.fontSize !== this.settings.fontSize
|| settings.scrollTime !== this.settings.scrollTime;
this.settings = settings;
// 透明度直接应用到 overlay
this.overlay.style.opacity = String(settings.opacity);
// 属性变化时重绘,避免新旧弹幕视觉不一致
if (needReset) this.seek(this.video.currentTime);
}
private tick = () => {
@@ -225,14 +280,15 @@ class DanmakuRenderer {
const t = this.video.currentTime;
const cw = this.overlay.clientWidth || 1;
const ch = this.overlay.clientHeight || 1;
// 1. 清理已完全滚出的弹幕
for (const lane of this.lanes) {
while (lane.length > 0 && lane[0].rightEdgeOutTime <= t) {
lane[0].el.remove();
lane.shift();
}
// 1. 清理已结束的滚动弹幕(endTime 大致递增,从队头移除)
let scrollRemoved = 0;
while (scrollRemoved < this.scrollEls.length && this.scrollEls[scrollRemoved].endTime <= t) {
this.scrollEls[scrollRemoved].el.remove();
scrollRemoved++;
}
if (scrollRemoved > 0) this.scrollEls.splice(0, scrollRemoved);
// 2. 清理过期的固定位置消息 + 淡出动画
let fixedChanged = false;
@@ -258,87 +314,74 @@ class DanmakuRenderer {
continue;
}
if (DanmakuRenderer.SCROLL_STYLES.has(item.style)) {
this.spawnScroll(item, t, cw);
this.spawnScroll(item, t, cw, ch);
} else {
this.spawnFixed(item, t);
}
this.nextIdx++;
}
// 4. 更新所有滚动弹幕位置
for (const lane of this.lanes) {
for (const d of lane) {
const elapsed = t - d.spawnTime;
const progress = elapsed / this.scrollTime;
const el = d.el;
const twPct = parseFloat(el.dataset.tw || '20');
const leftPct = 100 - progress * (100 + twPct);
el.style.left = leftPct + '%';
}
// 4. 更新所有滚动弹幕位置(使用 settings 中的 scrollTime)
const scrollTime = this.settings.scrollTime;
for (const d of this.scrollEls) {
const elapsed = t - d.spawnTime;
const progress = elapsed / scrollTime;
const el = d.el;
const twPct = parseFloat(el.dataset.tw || '20');
const leftPct = 100 - progress * (100 + twPct);
el.style.left = leftPct + '%';
}
};
/** 估算文本宽度(px),处理 surrogate pair 和粗体 */
private estimateTextWidth(text: string): number {
const fontSize = this.settings.fontSize;
let px = 0;
for (const ch of text) {
const code = ch.codePointAt(0)!;
if (code > 0xFFFF) {
// emoji / 扩展 CJK,算 2 个字符宽
px += DANMAKU_FONT_SIZE * 2;
px += fontSize * 2;
} else if (code > 0x7F) {
// CJK / 全角
px += DANMAKU_FONT_SIZE;
px += fontSize;
} else {
// ASCII(粗体下约 0.65x)
px += DANMAKU_FONT_SIZE * 0.65;
px += fontSize * 0.65;
}
}
return px;
}
private spawnScroll(item: DanmakuEntry, t: number, cw: number) {
private spawnScroll(item: DanmakuEntry, t: number, cw: number, ch: number) {
// 区域过滤:跳过不在指定区域的弹幕
if (!this.isInArea(item.marginV)) return;
const fontSize = this.settings.fontSize;
const scrollTime = this.settings.scrollTime;
const textPx = this.estimateTextWidth(item.text);
const twPct = (textPx / cw) * 100;
// 尾部进入屏幕的时间
const textEnterTime = t + (textPx / cw) * this.scrollTime;
// 完全滚出时间 = 滚动时间 + 文字滚入时间
const rightEdgeOutTime = t + this.scrollTime + (textPx / cw) * this.scrollTime;
// 安全间隔时间
const safeGapTime = (DANMAKU_GAP / cw) * this.scrollTime;
// 寻找可容纳的轨道
let laneIdx = -1;
for (let i = 0; i < this.laneCount; i++) {
const lane = this.lanes[i];
if (lane.length === 0) { laneIdx = i; break; }
const last = lane[lane.length - 1];
// 条件:最后一条弹幕的尾部已进入屏幕 + 安全间隔
if (t >= last.tailEnterTime + safeGapTime) { laneIdx = i; break; }
}
if (laneIdx < 0) {
let minLen = Infinity;
for (let i = 0; i < this.laneCount; i++) {
if (this.lanes[i].length < minLen) { minLen = this.lanes[i].length; laneIdx = i; }
}
}
if (laneIdx < 0) return;
// 计算结束时间:基于实际 spawn 时间 t,确保弹幕完整滚出屏幕
const endTime = t + scrollTime + (textPx / cw) * scrollTime;
// 创建 DOM
const el = document.createElement('div');
el.className = 'danmaku-item';
el.textContent = item.text;
el.style.color = item.color;
el.style.fontSize = DANMAKU_FONT_SIZE + 'px';
el.style.lineHeight = (DANMAKU_FONT_SIZE + 4) + 'px';
el.style.fontSize = fontSize + 'px';
el.style.lineHeight = (fontSize + 4) + 'px';
el.style.textShadow = '1px 1px 2px rgba(0,0,0,0.8),-1px -1px 2px rgba(0,0,0,0.8),1px -1px 2px rgba(0,0,0,0.8),-1px 1px 2px rgba(0,0,0,0.8)';
el.style.top = (laneIdx * (DANMAKU_FONT_SIZE + 4)) + 'px';
// 直接使用 ASS 的 marginV,按比例缩放到 overlay 高度
const scaledTop = (item.marginV / this.resY) * ch;
el.style.top = scaledTop + 'px';
el.style.left = '100%';
el.dataset.tw = twPct.toString();
this.overlay.appendChild(el);
this.lanes[laneIdx].push({ el, spawnTime: t, rightEdgeOutTime, tailEnterTime: textEnterTime });
this.scrollEls.push({ el, spawnTime: t, endTime });
}
/** 重新布局固定位置消息,消除过期后留下的空隙 */
@@ -458,7 +501,8 @@ const FileList: React.FC = () => {
const [filterGift, setFilterGift] = useState(true);
const [filterGuard, setFilterGuard] = useState(true);
const [filterSC, setFilterSC] = useState(true);
const parsedItemsRef = useRef<{ items: DanmakuEntry[]; scrollTime: number } | null>(null);
const [danmakuSettings, setDanmakuSettings] = useState<DanmakuSettings>(DEFAULT_DANMAKU_SETTINGS);
const parsedItemsRef = useRef<{ items: DanmakuEntry[]; scrollTime: number; resY: number } | null>(null);
const currentPlayingRef = useRef<{ record: CurrentFolderFile; fullPath: string } | null>(null);
// 重命名相关状态
@@ -516,6 +560,7 @@ const FileList: React.FC = () => {
danmakuRef.current.stop();
danmakuRef.current = null;
}
parsedItemsRef.current = null;
if (artRef.current) {
artRef.current.destroy(true);
artRef.current = null;
@@ -601,9 +646,9 @@ const FileList: React.FC = () => {
fetch(assUrl)
.then(r => r.ok ? r.text() : Promise.reject())
.then(text => {
const { items, scrollTime } = parseAss(text);
const { items, scrollTime, resY } = parseAss(text);
if (items.length === 0) return;
parsedItemsRef.current = { items, scrollTime };
parsedItemsRef.current = { items, scrollTime, resY };
// 统计弹幕类型
let danmakuCount = 0, giftCount = 0, giftAmount = 0, guardCount = 0, guardAmount = 0, scCount = 0, scAmount = 0;
@@ -635,7 +680,10 @@ const FileList: React.FC = () => {
overlay.className = 'danmaku-overlay';
artInner.appendChild(overlay);
const renderer = new DanmakuRenderer(overlay, artRef.current!.video, items, scrollTime, { danmaku: filterDanmaku, gift: filterGift, guard: filterGuard, sc: filterSC });
// 使用 ASS 解析的 scrollTime 作为初始值
const initialSettings = { ...danmakuSettings, scrollTime };
setDanmakuSettings(initialSettings);
const renderer = new DanmakuRenderer(overlay, artRef.current!.video, items, { danmaku: filterDanmaku, gift: filterGift, guard: filterGuard, sc: filterSC }, resY, initialSettings);
danmakuRef.current = renderer;
renderer.start();
if (artRef.current!.video.currentTime > 0) {
@@ -643,13 +691,19 @@ const FileList: React.FC = () => {
}
})
.catch(() => { /* 没有 ASS 文件或加载失败,静默忽略 */ });
}, [filterDanmaku, filterGift, filterGuard, filterSC]); // eslint-disable-line react-hooks/exhaustive-deps
}, [filterDanmaku, filterGift, filterGuard, filterSC, danmakuSettings]); // eslint-disable-line react-hooks/exhaustive-deps
// 过滤开关变化时刷新弹幕渲染器
// 过滤开关或设置变化时更新弹幕渲染器
useEffect(() => {
if (!danmakuRef.current || !parsedItemsRef.current || !artRef.current) return;
const currentTime = danmakuRef.current.getCurrentTime();
danmakuRef.current.stop();
if (!parsedItemsRef.current || !artRef.current) return;
const filter = { danmaku: filterDanmaku, gift: filterGift, guard: filterGuard, sc: filterSC };
// 如果渲染器已存在,直接动态更新,不销毁重建
if (danmakuRef.current) {
danmakuRef.current.update(filter, danmakuSettings);
return;
}
const artContainer = document.getElementById('art-container');
if (!artContainer) return;
@@ -658,12 +712,14 @@ const FileList: React.FC = () => {
overlay.className = 'danmaku-overlay';
artInner.appendChild(overlay);
const { items, scrollTime } = parsedItemsRef.current;
const renderer = new DanmakuRenderer(overlay, artRef.current.video, items, scrollTime, { danmaku: filterDanmaku, gift: filterGift, guard: filterGuard, sc: filterSC });
const { items, resY } = parsedItemsRef.current;
const renderer = new DanmakuRenderer(overlay, artRef.current.video, items, filter, resY, danmakuSettings);
danmakuRef.current = renderer;
renderer.start();
if (currentTime > 0) renderer.seek(currentTime);
}, [filterDanmaku, filterGift, filterGuard, filterSC]); // eslint-disable-line react-hooks/exhaustive-deps
if (artRef.current.video.currentTime > 0) {
renderer.seek(artRef.current.video.currentTime);
}
}, [filterDanmaku, filterGift, filterGuard, filterSC, danmakuSettings]); // eslint-disable-line react-hooks/exhaustive-deps
const showBatchRenameModal = () => {
setBatchFind("");
@@ -903,6 +959,7 @@ const FileList: React.FC = () => {
danmakuRef.current.stop();
danmakuRef.current = null;
}
parsedItemsRef.current = null;
if (artRef.current) {
artRef.current.destroy(true);
}
@@ -974,9 +1031,9 @@ const FileList: React.FC = () => {
fetch(assUrl)
.then(r => r.ok ? r.text() : Promise.reject())
.then(text => {
const { items, scrollTime } = parseAss(text);
const { items, scrollTime, resY } = parseAss(text);
if (items.length === 0) return;
parsedItemsRef.current = { items, scrollTime };
parsedItemsRef.current = { items, scrollTime, resY };
let danmakuCount = 0, giftCount = 0, giftAmount = 0, guardCount = 0, guardAmount = 0, scCount = 0, scAmount = 0;
for (const item of items) {
if (item.style === 'Danmaku') danmakuCount++;
@@ -1010,7 +1067,10 @@ const FileList: React.FC = () => {
overlay.className = 'danmaku-overlay';
artInner.appendChild(overlay);
const renderer = new DanmakuRenderer(overlay, art.video, items, scrollTime, { danmaku: filterDanmaku, gift: filterGift, guard: filterGuard, sc: filterSC });
// 使用 ASS 解析的 scrollTime 作为初始值
const initialSettings = { ...danmakuSettings, scrollTime };
setDanmakuSettings(initialSettings);
const renderer = new DanmakuRenderer(overlay, art.video, items, { danmaku: filterDanmaku, gift: filterGift, guard: filterGuard, sc: filterSC }, resY, initialSettings);
danmakuRef.current = renderer;
renderer.start();
// 如果浏览器恢复了播放位置,跳转到当前时间
@@ -1192,7 +1252,7 @@ const FileList: React.FC = () => {
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
{hasAss && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, fontWeight: 400 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, fontWeight: 400 }}>
<span style={{ color: loadDanmaku ? '#1890ff' : '#ffffff73' }}>弹幕</span>
<Switch
size="small"
@@ -1202,21 +1262,80 @@ const FileList: React.FC = () => {
{loadDanmaku && (
<>
<span style={{ color: '#ffffff40' }}>|</span>
{([
{ label: '文字', checked: filterDanmaku, onChange: setFilterDanmaku },
{ label: '礼物', checked: filterGift, onChange: setFilterGift },
{ label: '上舰', checked: filterGuard, onChange: setFilterGuard },
{ label: 'SC', checked: filterSC, onChange: setFilterSC },
] as const).map(({ label, checked, onChange }) => (
<div key={label} style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12 }}>
<span style={{ color: checked ? '#1890ff' : '#ffffff73' }}>{label}</span>
<Switch size="small" checked={checked} onChange={onChange} />
</div>
))}
<Checkbox checked={filterDanmaku} onChange={(e) => setFilterDanmaku(e.target.checked)}>
<span style={{ fontSize: 12 }}>文字</span>
</Checkbox>
<Checkbox checked={filterGift} onChange={(e) => setFilterGift(e.target.checked)}>
<span style={{ fontSize: 12 }}>礼物</span>
</Checkbox>
<Checkbox checked={filterGuard} onChange={(e) => setFilterGuard(e.target.checked)}>
<span style={{ fontSize: 12 }}>上舰</span>
</Checkbox>
<Checkbox checked={filterSC} onChange={(e) => setFilterSC(e.target.checked)}>
<span style={{ fontSize: 12 }}>SC</span>
</Checkbox>
</>
)}
</div>
)}
{hasAss && loadDanmaku && (
<Popover
content={
<div style={{ width: 220 }}>
<div style={{ marginBottom: 12 }}>
<div style={{ marginBottom: 4, fontSize: 12, color: '#999' }}>显示区域</div>
<Select
value={danmakuSettings.area}
onChange={(v) => setDanmakuSettings(s => ({ ...s, area: v }))}
style={{ width: '100%' }}
size="small"
options={[
{ label: '全屏', value: 'full' },
{ label: '顶部半屏', value: 'top' },
{ label: '底部半屏', value: 'bottom' },
{ label: '顶部1/4屏', value: 'quarter' },
{ label: '顶部3/4屏', value: 'three-quarter' },
]}
/>
</div>
<div style={{ marginBottom: 12 }}>
<div style={{ marginBottom: 4, fontSize: 12, color: '#999' }}>字体大小: {danmakuSettings.fontSize}px</div>
<Slider
min={12}
max={48}
value={danmakuSettings.fontSize}
onChange={(v) => setDanmakuSettings(s => ({ ...s, fontSize: v }))}
/>
</div>
<div style={{ marginBottom: 12 }}>
<div style={{ marginBottom: 4, fontSize: 12, color: '#999' }}>透明度: {Math.round(danmakuSettings.opacity * 100)}%</div>
<Slider
min={20}
max={100}
value={Math.round(danmakuSettings.opacity * 100)}
onChange={(v) => setDanmakuSettings(s => ({ ...s, opacity: v / 100 }))}
/>
</div>
<div>
<div style={{ marginBottom: 4, fontSize: 12, color: '#999' }}>滚动速度: {danmakuSettings.scrollTime}秒</div>
<Slider
min={3}
max={20}
value={danmakuSettings.scrollTime}
onChange={(v) => setDanmakuSettings(s => ({ ...s, scrollTime: v }))}
/>
</div>
<div style={{ marginTop: 8, textAlign: 'right' }}>
<Button size="small" onClick={() => setDanmakuSettings(DEFAULT_DANMAKU_SETTINGS)}>恢复默认</Button>
</div>
</div>
}
trigger="click"
placement="bottomRight"
>
<span style={{ color: '#1890ff', cursor: 'pointer', fontSize: 13, fontWeight: 600, background: '#e6f4ff', padding: '2px 8px', borderRadius: 4 }}>弹</span>
</Popover>
)}
<div className="close-btn" onClick={hidePlayer} title="退出播放 (Esc)">
<CloseOutlined />
</div>