HumanBreak/packages/legacy-ui/src/danmaku.ts

489 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { EventEmitter } from 'eventemitter3';
import { logger } from '@motajs/common';
import { ResponseBase } from '@motajs/client-base';
import axios, { AxiosResponse, toFormData } from 'axios';
import { VNode, h, shallowReactive } from 'vue';
import { ensureArray, parseCss } from './utils';
import { deleteWith } from '@motajs/legacy-common';
import { tip } from './use';
// /* @__PURE__ */ import { id, password } from '../../../../user';
type CSSObj = Partial<Record<CanParseCss, string>>;
interface DanmakuResponse extends ResponseBase {
total: number;
list: DanmakuInfo[];
}
interface DanmakuInfo {
id: string;
comment: string;
tags: string;
love: string;
my_love_type: boolean;
userid: string;
deler: string;
upload_time: string;
tower_name: string;
}
interface DanmakuPostInfo extends Partial<DanmakuContentInfo> {
type: 1 | 2 | 3;
towername: 'HumanBreak';
id?: number;
}
interface DanmakuContentInfo {
comment: string;
tags: string;
}
interface PostDanmakuResponse extends ResponseBase {
id: number;
}
interface PostLikeResponse extends ResponseBase {
liked: boolean;
}
interface DanmakuEvent {
showStart: [danmaku: Danmaku];
showEnd: [danmaku: Danmaku];
like: [liked: boolean, danmaku: Danmaku];
}
type SpecContentFn = (content: string, type: string) => VNode;
interface AllowedCSS {
property: string;
check: (value: string, prop: string) => true | string;
}
const allowedCSS: Partial<Record<CanParseCss, AllowedCSS>> = {
color: {
property: 'color',
check: () => true
},
backgroundColor: {
property: 'backgroundColor',
check: () => true
},
fontSize: {
property: 'fontSize',
check: value => {
if (!/^\d+%$/.test(value)) {
return '字体大小只能设置为百分格式';
}
if (parseInt(value) > 200) {
return '字体最大只能为200%';
}
return true;
}
}
};
export class Danmaku extends EventEmitter<DanmakuEvent> {
static backend: string = `/backend/tower/barrage.php`;
static all: Set<Danmaku> = new Set();
static allInPos: Partial<Record<FloorIds, Record<LocString, Danmaku[]>>> =
{};
static showList: Danmaku[] = shallowReactive([]);
static showMap: Map<number, Danmaku> = new Map();
static specList: Record<string, SpecContentFn> = {};
static lastEditoredDanmaku?: Danmaku;
id: number = -1;
text: string = '';
x: number = 0;
y: number = 0;
floor?: FloorIds;
showing: boolean = false;
likedNum: number = 0;
liked: boolean = false;
style: CSSObj = {};
textColor: string = 'white';
strokeColor: string = 'black';
private posted: boolean = false;
vNode?: VNode;
private posting: boolean = false;
/**
* 发送弹幕
* @returns 弹幕发送的 Axios Post 信息,为 Promise
*/
async post(): Promise<AxiosResponse<PostDanmakuResponse>> {
if (this.posted || this.posting) {
logger.warn(5);
return Promise.reject();
}
const data: DanmakuPostInfo = {
type: 2,
towername: 'HumanBreak',
...this.encode()
};
this.posting = true;
const form = toFormData(data);
// /* @__PURE__ */ form.append('userid', id);
// /* @__PURE__ */ form.append('password', password);
try {
const res = await axios.post<PostDanmakuResponse>(
Danmaku.backend,
form
);
this.id = res.data.id;
this.posting = false;
if (res.data.code === 0) {
this.posted = true;
tip('success', '发送成功');
this.addToList();
} else {
tip('error', res.data.message);
}
return res;
} catch (e) {
this.posted = false;
this.posting = false;
logger.error(1, String(e));
return Promise.reject();
}
}
/**
* 将弹幕整合为可以发送的格式
*/
encode(): DanmakuContentInfo {
const css = this.getEncodedCSS();
return {
comment: this.text,
tags: JSON.stringify([
`!css:${JSON.stringify(css)}`,
`!pos:${this.x},${this.y},${this.floor}`
])
};
}
/**
* 解析弹幕信息
* @param info 要被解析的弹幕信息
*/
decode(info: DanmakuContentInfo) {
this.text = info.comment;
ensureArray(JSON.parse(info.tags) as string[]).forEach(v => {
if (v.startsWith('!css:')) {
this.style = JSON.parse(v.slice(5));
} else if (v.startsWith('!pos:')) {
const [x, y, f] = v.slice(5).split(',');
this.x = parseInt(x);
this.y = parseInt(y);
this.floor = f as FloorIds;
} else {
logger.warn(3, v);
}
});
}
getEncodedCSS() {
const css = JSON.parse(JSON.stringify(this.style)) as CSSObj;
if (!css.color) css.color = this.textColor;
if (!css.textShadow)
css.textShadow = `1px 1px 1px ${this.strokeColor}, 1px -1px 1px ${this.strokeColor}, -1px 1px 1px ${this.strokeColor}, -1px -1px 1px ${this.strokeColor}`;
return { ...css, ...this.style };
}
/**
* 设置文字的颜色
* @param fill 填充颜色
* @param stroke 描边颜色
*/
color(fill?: string, stroke?: string) {
if (fill) this.textColor = fill;
if (stroke) this.strokeColor = stroke;
}
/**
* 添加一个图标
* @param icon 要显示的图标id
*/
addIcon(icon: AllIds) {
this.text += `[i:${icon}]`;
}
/**
* 设置这个弹幕整体的css信息
* @param str css字符串
* @param overwrite 是否完全覆写原来的css
*/
css(str: string, overwrite?: boolean): void;
/**
* 设置这个弹幕整体的css信息
* @param str css对象参考 CSSStyleDeclaration
* @param overwrite 是否完全覆写原来的css
*/
css(obj: CSSObj, overwrite?: boolean): void;
css(obj: string | CSSObj, overwrite: boolean = false) {
const res = typeof obj === 'string' ? parseCss(obj) : obj;
const allow = Danmaku.checkCSSAllow(res);
if (allow.length === 0) {
if (overwrite) this.style = res;
else {
this.style = { ...this.style, ...res };
}
} else {
logger.error(8, allow.join(','));
}
}
/**
* 将这个弹幕添加至弹幕列表
*/
addToList() {
Danmaku.all.add(this);
if (!this.floor) return;
Danmaku.allInPos[this.floor] ??= {};
Danmaku.allInPos[this.floor]![`${this.x},${this.y}`] ??= [];
Danmaku.allInPos[this.floor]![`${this.x},${this.y}`].push(this);
}
/**
* 解析这个弹幕为 VNode
*/
parse() {
let pointer = -1;
let ignore = false;
let str = '';
let spec = false;
let specType = '';
let specTypeEnd = false;
let specContent = '';
const children: VNode[] = [];
while (++pointer < this.text.length) {
const char = this.text[pointer];
if (char === '\\' && !ignore) {
ignore = true;
continue;
}
if (ignore) {
str += char;
continue;
}
if (char === '[') {
spec = true;
children.push(h('span', str));
str = '';
continue;
}
if (char === ']') {
if (!spec) {
logger.warn(4);
str += char;
} else {
spec = false;
specTypeEnd = false;
children.push(this.createSpecVNode(specType, specContent));
specType = '';
specContent = '';
}
continue;
}
if (spec) {
if (!specTypeEnd) {
if (char !== ':') {
specType += char;
} else {
specTypeEnd = true;
}
} else {
specContent += char;
}
continue;
}
str += char;
}
if (str.length > 0) {
children.push(h('span', str));
}
return h(
'span',
{ class: 'danmaku', style: this.getEncodedCSS() },
children
);
}
/**
* 获取本弹幕的VNode
*/
getVNode(nocache: boolean = false) {
if (nocache) return (this.vNode = this.parse());
return this.vNode ?? (this.vNode = this.parse());
}
/**
* 显示这个弹幕
*/
show() {
if (this.showing) return;
this.showing = true;
Danmaku.showList.push(this);
Danmaku.showMap.set(this.id, this);
this.emit('showStart', this);
}
/**
* 显示结束这个弹幕
*/
showEnd() {
if (!this.showing) return;
this.showing = false;
deleteWith(Danmaku.showList, this);
Danmaku.showMap.delete(this.id);
this.emit('showEnd', this);
}
/**
* 点赞或取消点赞
*/
async triggerLike() {
const post: DanmakuPostInfo = {
type: 3,
towername: 'HumanBreak',
id: this.id
};
const form = toFormData(post);
// /* @__PURE__ */ form.append('userid', id);
// /* @__PURE__ */ form.append('password', password);
const res = await axios.post<PostLikeResponse>(Danmaku.backend, form);
if (res.data.code !== 0) {
logger.warn(18, this.id.toString());
tip('error', `Error ${res.data.code}. ${res.data.message}`);
} else {
tip('success', res.data.message);
if (res.data.liked) {
this.liked = true;
this.likedNum++;
} else {
this.liked = false;
this.likedNum--;
}
this.emit('like', this.liked, this);
}
return res;
}
/**
* 销毁这个弹幕
*/
destroy() {
this.showEnd();
Danmaku.all.delete(this);
if (this.floor) {
const floor = Danmaku.allInPos[this.floor];
if (floor) {
delete floor[`${this.x},${this.y}`];
}
}
}
private createSpecVNode(type: string, content: string): VNode {
if (Danmaku.specList[type]) {
return Danmaku.specList[type](content, type);
} else {
logger.warn(7, type);
}
return h('span');
}
/**
* 检查CSS内容是否符合发弹幕要求
* @param css 要检查的CSS内容
*/
static checkCSSAllow(css: CSSObj) {
const problem: string[] = [];
for (const [key, value] of Object.entries(css)) {
if (!allowedCSS[key as CanParseCss]) {
problem.push(`不允许的CSS:${key}`);
continue;
} else {
const res = allowedCSS[key as CanParseCss]!.check(value, key);
if (res !== true) {
problem.push(res);
}
}
}
return problem;
}
/**
* 拉取本塔所有弹幕
*/
static async fetch() {
Danmaku.all.clear();
Danmaku.allInPos = {};
const form = toFormData({
type: 1,
towername: 'HumanBreak'
});
// /* @__PURE__ */ form.append('userid', id);
// /* @__PURE__ */ form.append('password', password);
const data = await axios.post<DanmakuResponse>(Danmaku.backend, form);
data.data.list.forEach(v => {
const dan = new Danmaku();
dan.id = parseInt(v.id);
dan.likedNum = parseInt(v.love);
dan.liked = v.my_love_type;
dan.decode(v);
dan.posted = true;
dan.addToList();
});
}
/**
* 显示一个弹幕
* @param dan 要显示的弹幕
*/
static show(dan: Danmaku) {
dan.show();
}
/**
* 注册一个特殊显示内容
* @param type 特殊内容类型
* @param fn 特殊内容显示函数返回VNode
*/
static registerSpecContent(type: string, fn: SpecContentFn) {
if (this.specList[type]) {
logger.warn(6, type);
}
this.specList[type] = fn;
}
}