Files
vtsuru.live/src/views/obs/DanmujiOBS.vue

424 lines
11 KiB
Vue

<script setup lang="ts">
import { useDanmakuClient } from '@/store/useDanmakuClient';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import MessageRender from './blivechat/MessageRender.vue';
// @ts-ignore
import * as constants from './blivechat/constants';
// @ts-ignore
import * as pronunciation from './blivechat/utils/pronunciation';
// @ts-ignore
import { DownloadConfig, useAccount } from '@/api/account';
import { QueryGetAPI } from '@/api/query';
import { VTSURU_API_URL } from '@/data/constants';
import { DanmakuInfo, GiftInfo, GuardInfo, SCInfo } from '@/data/DanmakuClients/OpenLiveClient';
import { useWebRTC } from '@/store/useRTC';
import { NAlert } from 'naive-ui';
import { useRoute } from 'vue-router';
// @ts-ignore
import * as trie from './blivechat/utils/trie';
export interface DanmujiConfig {
minGiftPrice: number,
showDanmaku: boolean,
showGift: boolean,
showGiftName: boolean,
mergeSimilarDanmaku: boolean,
mergeGift: boolean,
maxNumber: number,
blockLevel: number,
blockKeywords: string,
blockUsers: string,
blockMedalLevel: number,
giftUsernamePronunciation: string,
importPresetCss: boolean
emoticons: {
keyword: string,
url: string
}[]
}
defineExpose({ setCss })
const { customCss, isOBS = true } = defineProps<{
customCss?: string
isOBS?: boolean,
active?: boolean,
visible?: boolean,
}>()
const messageRender = ref()
const client = await useDanmakuClient().initOpenlive()
const pronunciationConverter = new pronunciation.PronunciationConverter()
const accountInfo = useAccount()
const route = useRoute()
const defaultConfig: DanmujiConfig = {
minGiftPrice: 0.1,
showDanmaku: true,
showGift: true,
showGiftName: true,
mergeSimilarDanmaku: false,
mergeGift: true,
maxNumber: 60,
blockLevel: 0,
blockKeywords: '',
blockUsers: '',
blockMedalLevel: 0,
giftUsernamePronunciation: '',
importPresetCss: false,
emoticons: []
} as DanmujiConfig
let textEmoticons: { keyword: string, url: string }[] = []
const config = ref<DanmujiConfig>(JSON.parse(JSON.stringify(defaultConfig)))
const rtc = await useWebRTC().Init('slave')
const emoticonsTrie = computed(() => {
let res = new trie.Trie()
for (let emoticons of [config.value.emoticons, textEmoticons]) {
for (let emoticon of emoticons) {
if (emoticon.keyword !== '' && emoticon.url !== '') {
res.set(emoticon.keyword, emoticon)
}
}
}
return res
})
const blockKeywordsTrie = computed(() => {
let blockKeywords = config.value.blockKeywords.split('\n')
let res = new trie.Trie()
for (let keyword of blockKeywords) {
if (keyword !== '') {
res.set(keyword, true)
}
}
return res
})
function setCss(css: string) {
messageRender.value?.setCss(css)
}
/** @param {chatModels.AddTextMsg} data */
async function onAddText(data: DanmakuInfo, command: unknown) {
if (!config.value.showDanmaku || !filterTextMessage(data)) {
return
}
let richContent = await getRichContent(data)
// 合并要放在异步调用后面,因为异步调用后可能有新的消息,会漏合并
if (mergeSimilarText(data.msg)) {
return
}
let message = {
id: data.msg_id,
type: constants.MESSAGE_TYPE_TEXT,
avatarUrl: data.uface,
time: new Date(data.timestamp * 1000),
authorName: data.uname,
authorType: getAuthorType(data.open_id, data.guard_level),
content: data.msg,
richContent: richContent,
privilegeType: data.guard_level,
repeated: 1,
translation: ''
}
messageRender.value.addMessage(message)
}
/** @param {chatModels.AddGiftMsg} data */
function onAddGift(data: GiftInfo, command: any) {
if (!config.value.showGift) {
return
}
let price = (data.price * data.gift_num) / 1000
if (mergeSimilarGift(data.uname, price, !data.paid ? price : 0, data.gift_name, data.gift_num)) {
return
}
if (price < (config.value.minGiftPrice ?? 0)) { // 丢人
return
}
let message = {
id: data.msg_id,
type: constants.MESSAGE_TYPE_GIFT,
avatarUrl: data.uface,
time: new Date(data.timestamp * 1000),
authorName: data.uname,
authorNamePronunciation: getPronunciation(data.uname),
price: price,
// freePrice: data.totalFreeCoin, // 暂时没用到
giftName: data.gift_name,
num: data.gift_num
}
messageRender.value.addMessage(message)
}
/** @param {chatModels.AddMemberMsg} data */
function onAddMember(data: GuardInfo, command: any) {
if (!config.value.showGift || !filterNewMemberMessage(data)) {
return
}
let message = {
id: data.msg_id,
type: constants.MESSAGE_TYPE_MEMBER,
avatarUrl: data.user_info.uface,
time: new Date(data.timestamp * 1000),
authorName: data.user_info.uname,
authorNamePronunciation: getPronunciation(data.user_info.uname),
privilegeType: data.guard_level,
title: '新舰长'
}
messageRender.value.addMessage(message)
}
/** @param {chatModels.AddSuperChatMsg} data */
function onAddSuperChat(data: SCInfo) {
if (!config.value.showGift || !filterSuperChatMessage(data)) {
return
}
if (data.rmb < (config.value.minGiftPrice ?? 0)) { // 丢人
return
}
let message = {
id: data.msg_id,
type: constants.MESSAGE_TYPE_SUPER_CHAT,
avatarUrl: data.uface,
authorName: data.uname,
authorNamePronunciation: getPronunciation(data.uname),
price: data.rmb,
time: new Date(data.timestamp * 1000),
content: data.msg_id.trim(),
translation: ''
}
messageRender.value.addMessage(message)
}
/** @param {chatModels.DelSuperChatMsg} data */
function onDelSuperChat(data: any) {
messageRender.value.deleteMessage(data.id)
}
function getAuthorType(open_id: string, guard_level: number) {
let authorType
if (open_id === client.authInfo?.anchor_info.open_id) {
authorType = 3
} else if (guard_level !== 0) {
authorType = 1
} else {
authorType = 0
}
}
type RichContentType = {
type: string,
text: string,
url?: string,
width?: number,
height?: number
}
async function getRichContent(data: DanmakuInfo) {
let richContent: RichContentType[] = []
// 官方的非文本表情
if (data.emoji_img_url) {
richContent.push({
type: constants.CONTENT_TYPE_IMAGE,
text: data.msg,
url: data.emoji_img_url + '@256w_256h_1e_1c',
width: 256,
height: 256
})
//await fillImageContentSizes(richContent)
return richContent
}
// 没有文本表情,只能是纯文本
if (config.value.emoticons.length === 0 && textEmoticons.length === 0) {
richContent.push({
type: constants.CONTENT_TYPE_TEXT,
text: data.msg
})
return richContent
}
// 可能含有文本表情,需要解析
let startPos = 0
let pos = 0
while (pos < data.msg.length) {
let remainContent = data.msg.substring(pos)
let matchEmoticon = emoticonsTrie.value.lazyMatch(remainContent)
if (matchEmoticon === null) {
pos++
continue
}
// 加入之前的文本
if (pos !== startPos) {
richContent.push({
type: constants.CONTENT_TYPE_TEXT,
text: data.msg.slice(startPos, pos)
})
}
// 加入表情
richContent.push({
type: constants.CONTENT_TYPE_IMAGE,
text: matchEmoticon.keyword,
url: matchEmoticon.url,
width: 0,
height: 0
})
pos += matchEmoticon.keyword.length
startPos = pos
}
// 加入尾部的文本
if (pos !== startPos) {
richContent.push({
type: constants.CONTENT_TYPE_TEXT,
text: data.msg.slice(startPos, pos)
})
}
await fillImageContentSizes(richContent)
return richContent
}
async function fillImageContentSizes(richContent: RichContentType[]) {
let urlSizeMap = new Map()
for (let content of richContent) {
if (content.type === constants.CONTENT_TYPE_IMAGE) {
urlSizeMap.set(content.url, { width: 0, height: 0 })
}
}
if (urlSizeMap.size === 0) {
return
}
let promises = []
for (let url of urlSizeMap.keys()) {
let urlInClosure = url
promises.push(new Promise(
resolve => {
let img = document.createElement('img')
img.onload = () => {
let size = urlSizeMap.get(urlInClosure)
size.width = img.naturalWidth
size.height = img.naturalHeight
// @ts-expect-error 忽略这里测错误
resolve()
}
// 获取失败了默认为0
img.onerror = resolve
// 超时保底
window.setTimeout(resolve, 5000)
img.src = urlInClosure
}
))
}
await Promise.all(promises)
for (let content of richContent) {
if (content.type === constants.CONTENT_TYPE_IMAGE) {
let size = urlSizeMap.get(content.url)
content.width = size.width
content.height = size.height
}
}
}
function getPronunciation(text: string) {
if (pronunciationConverter === null) {
return ''
}
return pronunciationConverter.getPronunciation(text)
}
function filterSuperChatMessage(data: SCInfo) {
return filterByContent(data.message) && filterByAuthorName(data.uname)
}
function filterNewMemberMessage(data: GuardInfo) {
return filterByAuthorName(data.user_info.uname)
}
function filterByContent(content: string) {
for (let i = 0; i < content.length; i++) {
let remainContent = content.substring(i)
if (blockKeywordsTrie.value.lazyMatch(remainContent) !== null) {
return false
}
}
return true
}
function filterByAuthorName(id: string) {
return Object.keys(accountInfo.value.biliBlackList).indexOf(id) === -1
}
function filterTextMessage(data: DanmakuInfo) {
if (config.value.blockLevel > 0 && data.guard_level < config.value.blockLevel) {
return false
} else if (config.value.blockMedalLevel > 0 && data.fans_medal_level < config.value.blockMedalLevel) {
return false
}
return filterByContent(data.msg) && filterByAuthorName(data.uname)
}
function mergeSimilarText(content: string) {
if (!config.value.mergeSimilarDanmaku) {
return false
}
return messageRender.value.mergeSimilarText(content)
}
function mergeSimilarGift(authorName: string, price: number, freePrice: number, giftName: string, num: number) {
if (!config.value.mergeGift) {
return false
}
return messageRender.value.mergeSimilarGift(authorName, price, freePrice, giftName, num)
}
function onReceiveConfig(data: DanmujiConfig) {
config.value = data
}
onMounted(async () => {
client.on('danmaku', onAddText)
client.on('gift', onAddGift)
client.on('sc', onAddSuperChat)
//client.onEvent('delsc', onSuperChatDel)
client.on('guard', onAddMember)
rtc?.on('danmuji.config', onReceiveConfig)
QueryGetAPI<{ keyword: string, url: string }[]>(VTSURU_API_URL + 'blivechat/emoticon').then((data) => {
if (data.code === 200) {
textEmoticons = data.data
}
})
return
while (true) {
const result = await DownloadConfig('OBS.Danmuji')
if (result.msg === undefined) {
config.value = result.data as DanmujiConfig
break
}
else if (result.status == 'error') {
await new Promise(resolve => setTimeout(resolve, 1000))
}
}
})
onUnmounted(() => {
client.off('danmaku', onAddText)
client.off('gift', onAddGift)
client.off('sc', onAddSuperChat)
//client.offEvent('delsc', onSuperChatDel)
client.off('guard', onAddMember)
rtc?.off('danmuji.config', onReceiveConfig)
})
</script>
<template>
<NAlert
v-if="!$route.query.token && isOBS"
type="error"
>
未携带token参数
</NAlert>
<MessageRender
v-else
ref="messageRender"
:custom-css="customCss"
:show-gift-name="config.showGiftName"
style="height: 100%; width: 100%"
/>
</template>