fix wrong langue value when adding songs

This commit is contained in:
2024-11-21 00:53:15 +08:00
parent 45bc8485b3
commit 537ea7bbe6
52 changed files with 46594 additions and 394 deletions

View File

@@ -549,8 +549,7 @@ onMounted(() => {
<NLayoutContent style="box-sizing: border-box; padding: 20px; min-width: 300px; height: 100%">
<RouterView v-if="accountInfo?.isEmailVerified" v-slot="{ Component, route }">
<KeepAlive>
<DanmakuLayout v-if="route.meta.danmaku" :component="Component" />
<Suspense v-else>
<Suspense>
<component :is="Component" />
<template #fallback>
<NSpin show />

View File

@@ -2,6 +2,7 @@
import { isDarkMode } from '@/Utils'
import { ThemeType } from '@/api/api-models'
import DanmakuClient, { AuthInfo } from '@/data/DanmakuClient'
import { useDanmakuClient } from '@/store/useDanmakuClient'
import { Lottery24Filled, PeopleQueue24Filled, TabletSpeaker24Filled } from '@vicons/fluent'
import { Moon, MusicalNote, Sunny } from '@vicons/ionicons5'
import { useElementSize, useStorage } from '@vueuse/core'
@@ -38,7 +39,7 @@ const sider = ref()
const { width } = useElementSize(sider)
const authInfo = ref<AuthInfo>()
const client = ref<DanmakuClient>()
const danmakuClient = useDanmakuClient()
const menuOptions = [
{
@@ -110,18 +111,12 @@ const danmakuClientError = ref<string>()
onMounted(async () => {
authInfo.value = route.query as unknown as AuthInfo
if (authInfo.value?.Code) {
client.value = new DanmakuClient(authInfo.value)
const result = await client.value.Start()
if (!result.success) {
message.error('无法启动弹幕客户端: ' + result.message)
danmakuClientError.value = result.message
}
danmakuClient.initClient(authInfo.value)
} else {
message.error('你不是从幻星平台访问此页面, 或未提供对应参数, 无法使用此功能')
}
})
onUnmounted(() => {
client.value?.Stop()
})
</script>
@@ -150,8 +145,8 @@ onUnmounted(() => {
<NPageHeader :subtitle="($route.meta.title as string) ?? ''">
<template #extra>
<NSpace align="center">
<NTag :type="client?.roomAuthInfo.value ? 'success' : 'warning'">
{{ client?.roomAuthInfo.value ? `已连接 | ${client.roomAuthInfo.value?.anchor_info.uname}` : '未连接' }}
<NTag :type="danmakuClient.connected ? 'success' : 'warning'">
{{ danmakuClient.connected ? `已连接 | ${danmakuClient.authInfo?.anchor_info?.uname}` : '未连接' }}
</NTag>
<NSwitch
:default-value="!isDarkMode"
@@ -190,10 +185,10 @@ onUnmounted(() => {
style="height: 100%"
>
<Transition>
<div v-if="client?.roomAuthInfo" style="margin-top: 8px">
<div v-if="danmakuClient.authInfo" style="margin-top: 8px">
<NSpace vertical justify="center" align="center">
<NAvatar
:src="client?.roomAuthInfo.value?.anchor_info.uface"
:src="danmakuClient.authInfo?.anchor_info?.uface"
:img-props="{ referrerpolicy: 'no-referrer' }"
round
bordered
@@ -203,7 +198,7 @@ onUnmounted(() => {
/>
<NEllipsis v-if="width > 100" style="max-width: 100%">
<NText strong>
{{ client?.roomAuthInfo.value?.anchor_info.uname }}
{{ danmakuClient.authInfo?.anchor_info?.uname }}
</NText>
</NEllipsis>
</NSpace>
@@ -226,9 +221,9 @@ onUnmounted(() => {
<NAlert v-if="danmakuClientError" type="error" title="无法启动弹幕客户端">
{{ danmakuClientError }}
</NAlert>
<RouterView v-if="client?.roomAuthInfo.value" v-slot="{ Component }">
<RouterView v-if="danmakuClient.authInfo" v-slot="{ Component }">
<KeepAlive>
<component :is="Component" :room-info="client?.roomAuthInfo" :client="client" :code="authInfo.Code" />
<component :is="Component" :room-info="danmakuClient.authInfo" :code="authInfo.Code" />
</KeepAlive>
</RouterView>
<template v-else>

View File

@@ -1,11 +1,12 @@
<script setup lang="ts">
import { useAccount } from '@/api/account';
import { MasterRTCClient, SlaveRTCClient } from '@/data/RTCClient';
import { useDanmakuClient } from '@/store/useDanmakuClient';
import { useWebRTC } from '@/store/useRTC';
import { NButton, NInput, NSpin } from 'naive-ui';
import { LogLevel, Peer } from 'peerjs';
import { computed, onMounted, Ref, ref } from 'vue';
import { computed, Ref, ref } from 'vue';
import { useRoute } from 'vue-router';
import DanmujiOBS from './obs/DanmujiOBS.vue';
const target = ref('');
const accountInfo = useAccount()
@@ -15,11 +16,15 @@ const inputMsg = ref('')
const isMaster = computed(() => {
return route.query.slave == null || route.query.slave == undefined
})
const dc = useDanmakuClient()
const customCss = ref('')
let rtc: Ref<MasterRTCClient | undefined, MasterRTCClient | undefined> | Ref<SlaveRTCClient | undefined, SlaveRTCClient | undefined>
let rtc: Ref<MasterRTCClient | SlaveRTCClient | undefined> = ref()
const danmujiRef = ref()
function mount() {
rtc = useWebRTC().Init(isMaster.value ? 'master' : 'slave')
rtc.value = useWebRTC().Init(isMaster.value ? 'master' : 'slave')
dc.initClient()
}
</script>
@@ -32,5 +37,8 @@ function mount() {
<NInput v-model:value="inputMsg" />
<NButton @click="rtc.send('test', inputMsg)"> 发送 </NButton>
</template>
<NInput v-model:value="customCss" placeholder="css" @update:value="s => danmujiRef?.setCss(s.toString())" />
<DanmujiOBS ref="danmujiRef" :customCss="customCss" style="width: 400px;height: 700px;" />
</div>
</template>

View File

@@ -1,64 +0,0 @@
<script setup lang="ts">
import { useAccount } from '@/api/account'
import DanmakuClient from '@/data/DanmakuClient'
import { NAlert, NSpin, useMessage } from 'naive-ui'
import { VNode, onMounted, onUnmounted, ref } from 'vue'
const props = defineProps<{
component: VNode
}>()
const accountInfo = useAccount()
const message = useMessage()
const client = new DanmakuClient(null)
const isClientLoading = ref(true)
let bc: BroadcastChannel
onMounted(async () => {
if (window.BroadcastChannel) {
bc = new BroadcastChannel('vtsuru.danmaku')
let isCreated = false
bc.onmessage = (event) => {
switch (event.data) {
case 'ping':
bc.postMessage('pong')
break
case 'pong': //已存在其他客户端
if (!isCreated) {
isCreated = true
}
break
case 'danmaku':
props.component.props?.onDanmaku?.(event.type)
break
}
}
bc.postMessage('ping')
setTimeout(() => {
}, 50);
}
const result = await client.Start()
if (!result.success) {
message.error('无法启动弹幕客户端: ' + result.message)
}
isClientLoading.value = false
})
onUnmounted(() => {
client.Stop()
})
</script>
<template>
<NAlert v-if="accountInfo?.isBiliVerified != true" type="info"> 尚未进行Bilibili认证 </NAlert>
<NSpin v-else-if="isClientLoading" show />
<KeepAlive v-else>
<component
:is="component"
:client="client"
:room-info="client.roomAuthInfo?.value"
:code="accountInfo?.biliAuthCode"
/>
</KeepAlive>
</template>

View File

@@ -1,22 +1,15 @@
<script setup lang="ts">
import { useAccount } from '@/api/account'
import DanmakuClient from '@/data/DanmakuClient'
import { useDanmakuClient } from '@/store/useDanmakuClient'
import { NAlert } from 'naive-ui'
import { onMounted, onUnmounted } from 'vue'
import OpenLottery from '../open_live/OpenLottery.vue'
const accountInfo = useAccount()
const client = new DanmakuClient(null)
const client = await useDanmakuClient().initClient()
onMounted(() => {
client.Start()
})
onUnmounted(() => {
client.Stop()
})
</script>
<template>
<NAlert v-if="accountInfo?.isBiliVerified != true" type="info"> 尚未进行Bilibili认证 </NAlert>
<OpenLottery v-else :client="client" :room-info="client.roomAuthInfo.value" :code="accountInfo?.biliAuthCode" />
<OpenLottery v-else :room-info="client.authInfo!" :code="accountInfo?.biliAuthCode" />
</template>

View File

@@ -260,7 +260,7 @@ const addLinkUrl = ref('')
const linkKey = ref(0)
async function RequestBiliUserData() {
await fetch(FETCH_API + `https://account.bilibili.com/api/member/getCardByMid?mid=10021741`).then(async (respone) => {
await fetch(FETCH_API + `https://workers.vrp.moe/api/bilibili/user-info/10021741`).then(async (respone) => {
const data = await respone.json()
if (data.code == 0) {
biliUserInfo.value = data.card
@@ -657,7 +657,7 @@ onMounted(async () => {
<NDivider />
<Transition name="fade" mode="out-in">
<div v-if="selectedComponent" :key="selectedTemplateData.Selected">
<component ref="dynamicConfigRef" @vue:mounted="getTemplateConfig" :is="selectedComponent"
<component ref="dynamicConfigRef" @vue:mounted="getTemplateConfig" :is="selectedComponent"
:user-info="accountInfo" :bili-info="biliUserInfo" :data="selectedTemplateData.Data"
:config="selectedTemplateData.Config" />
</div>

View File

@@ -8,6 +8,7 @@ import { FETCH_API, SONG_API_URL } from '@/data/constants'
import { Info24Filled } from '@vicons/fluent'
import { ArchiveOutline } from '@vicons/ionicons5'
import { format } from 'date-fns'
// @ts-ignore
import { saveAs } from 'file-saver'
import { List } from 'linqts'
import {
@@ -129,27 +130,27 @@ const addSongRules: FormRules = {
const songSelectOption = [
{
label: '中文',
value: SongLanguage.Chinese,
value: '中文',
},
{
label: '日语',
value: SongLanguage.Japanese,
value: '日语',
},
{
label: '英语',
value: SongLanguage.English,
value: '英语',
},
{
label: '法语',
value: SongLanguage.French,
value: '法语',
},
{
label: '西语',
value: SongLanguage.Spanish,
value: '西语',
},
{
label: '其他',
value: SongLanguage.Other,
value: '其他',
},
]
const languageSelectOption = computed(() => {

View File

@@ -1,22 +1,14 @@
<script setup lang="ts">
import { useAccount } from '@/api/account'
import DanmakuClient from '@/data/DanmakuClient'
import { useDanmakuClient } from '@/store/useDanmakuClient'
import { NAlert } from 'naive-ui'
import { onMounted, onUnmounted } from 'vue'
import MusicRequest from '../open_live/MusicRequest.vue'
const accountInfo = useAccount()
const client = new DanmakuClient(null)
onMounted(() => {
client.Start()
})
onUnmounted(() => {
client.Stop()
})
const client = await useDanmakuClient().initClient()
</script>
<template>
<NAlert v-if="accountInfo?.isBiliVerified != true" type="info"> 尚未进行Bilibili认证 </NAlert>
<MusicRequest v-else :client="client" :room-info="client.roomAuthInfo.value" :code="accountInfo?.biliAuthCode" />
<MusicRequest v-else :client="client" :room-info="client.authInfo!" :code="accountInfo?.biliAuthCode" />
</template>

View File

@@ -0,0 +1,410 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue';
import MessageRender from './blivechat/MessageRender.vue';
import { useDanmakuClient } from '@/store/useDanmakuClient';
// @ts-ignore
import * as constants from './blivechat/constants';
// @ts-ignore
import * as chatModels from './blivechat/models';
// @ts-ignore
import * as pronunciation from './blivechat/utils/pronunciation'
// @ts-ignore
import * as trie from './blivechat/utils/trie'
import { DanmakuInfo, GiftInfo, GuardInfo, SCInfo } from '@/data/DanmakuClient';
import { EventModel } from '@/api/api-models';
import { DownloadConfig, useAccount } from '@/api/account';
import { useWebRTC } from '@/store/useRTC';
import { QueryGetAPI } from '@/api/query';
import { OPEN_LIVE_API_URL, VTSURU_API_URL } from '@/data/constants';
import { CustomChart } from 'echarts/charts';
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 props = defineProps<{
customCss?: string
}>()
const messageRender = ref()
const client = await useDanmakuClient().initClient()
const pronunciationConverter = new pronunciation.PronunciationConverter()
const accountInfo = useAccount()
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 = 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 {
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>
<MessageRender ref="messageRender" :customCss="customCss" :showGiftName="config.showGiftName" style="height: 100%; width: 100%"/>
</template>

View File

@@ -0,0 +1,60 @@
<template>
<yt-live-chat-author-badge-renderer :type="authorTypeText">
<NTooltip :content="readableAuthorTypeText" placement="top">
<template #trigger>
<div id="image" class="style-scope yt-live-chat-author-badge-renderer">
<yt-icon v-if="isAdmin" class="style-scope yt-live-chat-author-badge-renderer">
<svg viewBox="0 0 16 16" class="style-scope yt-icon" preserveAspectRatio="xMidYMid meet" focusable="false"
style="pointer-events: none; display: block; width: 100%; height: 100%;">
<g class="style-scope yt-icon">
<path class="style-scope yt-icon"
d="M9.64589146,7.05569719 C9.83346524,6.562372 9.93617022,6.02722257 9.93617022,5.46808511 C9.93617022,3.00042984 7.93574038,1 5.46808511,1 C4.90894765,1 4.37379823,1.10270499 3.88047304,1.29027875 L6.95744681,4.36725249 L4.36725255,6.95744681 L1.29027875,3.88047305 C1.10270498,4.37379824 1,4.90894766 1,5.46808511 C1,7.93574038 3.00042984,9.93617022 5.46808511,9.93617022 C6.02722256,9.93617022 6.56237198,9.83346524 7.05569716,9.64589147 L12.4098057,15 L15,12.4098057 L9.64589146,7.05569719 Z">
</path>
</g>
</svg>
</yt-icon>
<img v-else :src="`${fileServerUrl}/blivechat/icons/guard-level-${privilegeType}.png`"
class="style-scope yt-live-chat-author-badge-renderer" :alt="readableAuthorTypeText">
</div>
</template>
{{ readableAuthorTypeText }}
</NTooltip>
</yt-live-chat-author-badge-renderer>
</template>
<script>
import { NTooltip } from 'naive-ui';
import * as constants from './constants'
import { FILE_BASE_URL } from '@/data/constants';
export default {
name: 'AuthorBadge',
props: {
isAdmin: Boolean,
privilegeType: Number
},
components: {
NTooltip
},
computed: {
authorTypeText() {
if (this.isAdmin) {
return 'moderator'
}
return this.privilegeType > 0 ? 'member' : ''
},
readableAuthorTypeText() {
if (this.isAdmin) {
return '管理员'
}
return constants.getShowGuardLevelText(this.privilegeType)
},
fileServerUrl() {
return FILE_BASE_URL
}
}
}
</script>
<style src="@/assets/css/youtube/yt-live-chat-author-badge-renderer.css"></style>
<style src="@/assets/css/youtube/yt-icon.css"></style>

View File

@@ -0,0 +1,51 @@
<template>
<yt-live-chat-author-chip>
<span id="author-name" dir="auto" class="style-scope yt-live-chat-author-chip"
:class="{ member: isInMemberMessage }" :type="authorTypeText">
{{ authorName }}
<!-- 这里是已验证勋章 -->
<span id="chip-badges" class="style-scope yt-live-chat-author-chip"></span>
</span>
<span id="chat-badges" class="style-scope yt-live-chat-author-chip">
<author-badge v-if="isInMemberMessage" class="style-scope yt-live-chat-author-chip" :isAdmin="false"
:privilegeType="privilegeType"></author-badge>
<template v-else>
<author-badge v-if="authorType === AUTHOR_TYPE_ADMIN" class="style-scope yt-live-chat-author-chip" isAdmin
:privilegeType="0"></author-badge>
<author-badge v-if="privilegeType > 0" class="style-scope yt-live-chat-author-chip" :isAdmin="false"
:privilegeType="privilegeType"></author-badge>
</template>
</span>
</yt-live-chat-author-chip>
</template>
<script>
import { defineComponent } from 'vue';
import AuthorBadge from './AuthorBadge.vue'
import * as constants from './constants'
export default defineComponent({
name: 'AuthorChip',
components: {
AuthorBadge
},
props: {
isInMemberMessage: Boolean,
authorName: String,
authorType: Number,
privilegeType: Number
},
data() {
return {
AUTHOR_TYPE_ADMIN: constants.AUTHOR_TYPE_ADMIN
}
},
computed: {
authorTypeText() {
return constants.AUTHOR_TYPE_TO_TEXT[this.authorType]
}
}
})
</script>
<style src="@/assets/css/youtube/yt-live-chat-author-chip.css"></style>

View File

@@ -0,0 +1,38 @@
<template>
<yt-img-shadow class="no-transition" :height="height" :width="width" style="background-color: transparent;" loaded>
<img id="img" class="style-scope yt-img-shadow" alt="" :height="height" :width="width" :src="showImgUrl"
@error="onLoadError" referrerpolicy="no-referrer">
</yt-img-shadow>
</template>
<script>
import * as models from './models'
export default {
name: 'ImgShadow',
props: {
imgUrl: String,
height: String,
width: String
},
data() {
return {
showImgUrl: this.imgUrl
}
},
watch: {
imgUrl(val) {
this.showImgUrl = val
}
},
methods: {
onLoadError() {
if (this.showImgUrl !== models.DEFAULT_AVATAR_URL) {
this.showImgUrl = models.DEFAULT_AVATAR_URL
}
}
}
}
</script>
<style src="@/assets/css/youtube/yt-img-shadow.css"></style>

View File

@@ -0,0 +1,52 @@
<template>
<yt-live-chat-membership-item-renderer class="style-scope yt-live-chat-item-list-renderer" show-only-header
:blc-guard-level="privilegeType"
>
<div id="card" class="style-scope yt-live-chat-membership-item-renderer">
<div id="header" class="style-scope yt-live-chat-membership-item-renderer">
<img-shadow id="author-photo" height="40" width="40" class="style-scope yt-live-chat-membership-item-renderer"
:imgUrl="avatarUrl"
></img-shadow>
<div id="header-content" class="style-scope yt-live-chat-membership-item-renderer">
<div id="header-content-primary-column" class="style-scope yt-live-chat-membership-item-renderer">
<div id="header-content-inner-column" class="style-scope yt-live-chat-membership-item-renderer">
<author-chip class="style-scope yt-live-chat-membership-item-renderer"
isInMemberMessage :authorName="authorName" :authorType="0" :privilegeType="privilegeType"
></author-chip>
</div>
<div id="header-subtext" class="style-scope yt-live-chat-membership-item-renderer">{{ title }}</div>
</div>
<div id="timestamp" class="style-scope yt-live-chat-membership-item-renderer">{{ timeText }}</div>
</div>
</div>
</div>
</yt-live-chat-membership-item-renderer>
</template>
<script>
import ImgShadow from './ImgShadow.vue'
import AuthorChip from './AuthorChip.vue'
import * as utils from './utils'
export default {
name: 'MembershipItem',
components: {
ImgShadow,
AuthorChip
},
props: {
avatarUrl: String,
authorName: String,
privilegeType: Number,
title: String,
time: Date
},
computed: {
timeText() {
return utils.getTimeTextHourMin(this.time)
}
}
}
</script>
<style src="@/assets/css/youtube/yt-live-chat-membership-item-renderer.css"></style>

View File

@@ -0,0 +1,630 @@
<template>
<yt-live-chat-renderer class="style-scope yt-live-chat-app" style="--scrollbar-width:11px;" hide-timestamps
@mousemove="refreshCantScrollStartTime">
<ticker class="style-scope yt-live-chat-renderer" :messages.sync="paidMessages" :showGiftName="showGiftName || undefined">
</ticker>
<yt-live-chat-item-list-renderer class="style-scope yt-live-chat-renderer" allow-scroll>
<div ref="scroller" id="item-scroller" class="style-scope yt-live-chat-item-list-renderer animated"
@scroll="onScroll">
<div ref="itemOffset" id="item-offset" class="style-scope yt-live-chat-item-list-renderer">
<div ref="items" id="items" class="style-scope yt-live-chat-item-list-renderer" style="overflow: hidden"
:style="{ transform: `translateY(${Math.floor(scrollPixelsRemaining)}px)` }">
<template v-for="message in messages" :key="message.id">
<text-message v-if="message.type === MESSAGE_TYPE_TEXT"
class="style-scope yt-live-chat-item-list-renderer" :time="message.time" :avatarUrl="message.avatarUrl"
:authorName="message.authorName" :authorType="message.authorType" :privilegeType="message.privilegeType"
:richContent="getShowRichContent(message)" :repeated="message.repeated"></text-message>
<paid-message v-else-if="message.type === MESSAGE_TYPE_GIFT"
class="style-scope yt-live-chat-item-list-renderer" :time="message.time" :avatarUrl="message.avatarUrl"
:authorName="getShowAuthorName(message)" :price="message.price"
:priceText="message.price <= 0 ? getGiftShowNameAndNum(message) : ''"
:content="message.price <= 0 ? '' : getGiftShowContent(message, showGiftName)"></paid-message>
<membership-item v-else-if="message.type === MESSAGE_TYPE_MEMBER"
class="style-scope yt-live-chat-item-list-renderer" :time="message.time" :avatarUrl="message.avatarUrl"
:authorName="getShowAuthorName(message)" :privilegeType="message.privilegeType"
:title="message.title"></membership-item>
<paid-message v-else-if="message.type === MESSAGE_TYPE_SUPER_CHAT"
class="style-scope yt-live-chat-item-list-renderer" :time="message.time" :avatarUrl="message.avatarUrl"
:authorName="getShowAuthorName(message)" :price="message.price"
:content="getShowContent(message)"></paid-message>
</template>
</div>
</div>
</div>
</yt-live-chat-item-list-renderer>
</yt-live-chat-renderer>
</template>
<script>
import _ from 'lodash'
import Ticker from './Ticker.vue'
import TextMessage from './TextMessage.vue'
import MembershipItem from './MembershipItem.vue'
import PaidMessage from './PaidMessage.vue'
import * as constants from './constants'
import { defineComponent } from 'vue'
import { useDebounceFn } from '@vueuse/core'
// 要添加的消息类型
const ADD_MESSAGE_TYPES = [
constants.MESSAGE_TYPE_TEXT,
constants.MESSAGE_TYPE_GIFT,
constants.MESSAGE_TYPE_MEMBER,
constants.MESSAGE_TYPE_SUPER_CHAT,
]
// 发送消息时间间隔范围
const MESSAGE_MIN_INTERVAL = 80
const MESSAGE_MAX_INTERVAL = 1000
// 每次发送消息后增加的动画时间要比MESSAGE_MIN_INTERVAL稍微大一点太小了动画不连续太大了发送消息时会中断动画
// 84 = ceil((1000 / 60) * 5)
const CHAT_SMOOTH_ANIMATION_TIME_MS = 84
// 滚动条距离底部小于多少像素则认为在底部
const SCROLLED_TO_BOTTOM_EPSILON = 15
export default defineComponent({
name: 'ChatRenderer',
components: {
Ticker,
TextMessage,
MembershipItem,
PaidMessage
},
props: {
maxNumber: {
type: Number,
default: 60
},
showGiftName: {
type: Boolean,
default: false
},
customCss: {
type: String,
default: ''
},
},
data() {
let customStyleElement = document.createElement('style')
document.head.appendChild(customStyleElement)
const setCssDebounce = useDebounceFn(() => {
customStyleElement.innerHTML = this.customCss ?? ''
console.log('[blivechat] 已设置自定义样式')
}, 1000)
return {
MESSAGE_TYPE_TEXT: constants.MESSAGE_TYPE_TEXT,
MESSAGE_TYPE_GIFT: constants.MESSAGE_TYPE_GIFT,
MESSAGE_TYPE_MEMBER: constants.MESSAGE_TYPE_MEMBER,
MESSAGE_TYPE_SUPER_CHAT: constants.MESSAGE_TYPE_SUPER_CHAT,
messages: [], // 显示的消息
paidMessages: [], // 固定在上方的消息
smoothedMessageQueue: [], // 平滑消息队列由外部调用addMessages等方法添加
emitSmoothedMessageTimerId: null, // 消费平滑消息队列的定时器ID
enqueueIntervals: [], // 最近进队列的时间间隔,用来估计下次进队列的时间
lastEnqueueTime: null, // 上次进队列的时间
estimatedEnqueueInterval: null, // 估计的下次进队列时间间隔
messagesBuffer: [], // 暂时未显示的消息,当不能自动滚动时会积压在这
preinsertHeight: 0, // 插入新消息之前items的高度
isSmoothed: true, // 是否平滑滚动,当消息太快时不平滑滚动
chatRateMs: 1000, // 用来计算消息速度
scrollPixelsRemaining: 0, // 平滑滚动剩余像素
scrollTimeRemainingMs: 0, // 平滑滚动剩余时间
lastSmoothChatMessageAddMs: null, // 上次showNewMessages时间
smoothScrollRafHandle: null, // 平滑滚动requestAnimationFrame句柄
lastSmoothScrollUpdate: null, // 平滑滚动上一帧时间
atBottom: true, // 滚动到底部,用来判断能否自动滚动
cantScrollStartTime: null, // 开始不能自动滚动的时间,用来防止卡住
customStyleElement,
setCssDebounce,
}
},
computed: {
canScrollToBottom() {
return this.atBottom/* || this.allowScroll */
}
},
watch: {
canScrollToBottom(val) {
this.cantScrollStartTime = val ? null : new Date()
},
watchCustomCss: {
immediate: true,
handler(val, oldVal) {
this.setCssDebounce(val)
}
}
},
mounted() {
this.scrollToBottom()
},
beforeDestroy() {
if (this.emitSmoothedMessageTimerId) {
window.clearTimeout(this.emitSmoothedMessageTimerId)
this.emitSmoothedMessageTimerId = null
}
this.clearMessages()
document.head.removeChild(this.customStyleElement)
},
methods: {
getGiftShowContent(message) {
return constants.getGiftShowContent(message, this.showGiftName)
},
getGiftShowNameAndNum: constants.getGiftShowNameAndNum,
getShowContent: constants.getShowContent,
getShowRichContent: constants.getShowRichContent,
getShowAuthorName: constants.getShowAuthorName,
addMessage(message) {
this.addMessages([message])
},
addMessages(messages) {
this.enqueueMessages(messages)
},
setCss(css) {
this.setCssDebounce(css)
},
// 后悔加这个功能了
mergeSimilarText(content) {
content = content.trim().toLowerCase()
for (let message of this.iterRecentMessages(5)) {
if (message.type !== constants.MESSAGE_TYPE_TEXT) {
continue
}
let messageContent = message.content.trim().toLowerCase()
let longer, shorter
if (messageContent.length > content.length) {
longer = messageContent
shorter = content
} else {
longer = content
shorter = messageContent
}
if (
longer.indexOf(shorter) !== -1 // 长的包含短的
&& longer.length - shorter.length < shorter.length // 长度差较小
) {
this.updateMessage(message.id, {
$add: {
repeated: 1
}
})
return true
}
}
return false
},
mergeSimilarGift(authorName, price, _freePrice, giftName, num) {
for (let message of this.iterRecentMessages(5)) {
if (
message.type === constants.MESSAGE_TYPE_GIFT
&& message.authorName === authorName
&& message.giftName === giftName
) {
this.updateMessage(message.id, {
$add: {
price: price,
// freePrice: freePrice, // 暂时没用到
num: num
}
})
return true
}
}
return false
},
// 从新到老迭代num条消息注意会迭代smoothedMessageQueue不会迭代paidMessages
*iterRecentMessages(num, onlyCountAddMessages = true) {
if (num <= 0) {
return
}
for (let arr of this.iterMessageArrs()) {
for (let i = arr.length - 1; i >= 0 && num > 0; i--) {
let message = arr[i]
yield message
if (!onlyCountAddMessages || this.isAddMessage(message)) {
num--
}
}
if (num <= 0) {
break
}
}
},
// 从新到老迭代消息的数组
*iterMessageArrs() {
for (let i = this.smoothedMessageQueue.length - 1; i >= 0; i--) {
yield this.smoothedMessageQueue[i]
}
yield this.messagesBuffer
yield this.messages
},
delMessage(id) {
this.delMessages([id])
},
delMessages(ids) {
this.enqueueMessages(ids.map(
id => ({
type: constants.MESSAGE_TYPE_DEL,
id
})
))
},
clearMessages() {
this.messages = []
this.paidMessages = []
this.smoothedMessageQueue = []
this.messagesBuffer = []
this.isSmoothed = true
this.lastSmoothChatMessageAddMs = null
this.chatRateMs = 1000
this.lastSmoothScrollUpdate = null
this.scrollTimeRemainingMs = this.scrollPixelsRemaining = 0
this.smoothScrollRafHandle = null
this.preinsertHeight = 0
this.maybeResizeScrollContainer()
if (!this.atBottom) {
this.scrollToBottom()
}
},
updateMessage(id, newValuesObj) {
this.enqueueMessages([{
type: constants.MESSAGE_TYPE_UPDATE,
id,
newValuesObj
}])
},
enqueueMessages(messages) {
// 估计进队列时间间隔
if (!this.lastEnqueueTime) {
this.lastEnqueueTime = new Date()
} else {
let curTime = new Date()
let interval = curTime - this.lastEnqueueTime
// 真实的进队列时间间隔模式大概是这样2500, 300, 300, 300, 2500, 300, ...
// B站消息有缓冲会一次发多条消息。这里把波峰视为发送了一次真实的WS消息所以要过滤掉间隔太小的
if (interval > 1000 || this.enqueueIntervals.length < 5) {
this.enqueueIntervals.push(interval)
if (this.enqueueIntervals.length > 5) {
this.enqueueIntervals.splice(0, this.enqueueIntervals.length - 5)
}
// 这边估计得尽量大只要不太早把消息缓冲发完就是平滑的。有MESSAGE_MAX_INTERVAL保底不会让消息延迟太大
// 其实可以用单调队列求最大值,偷懒不写了
this.estimatedEnqueueInterval = Math.max(...this.enqueueIntervals)
}
// 上次入队时间还是要设置,否则会太早把消息缓冲发完,然后较长时间没有新消息
this.lastEnqueueTime = curTime
}
// 把messages分成messageGroup每个组里最多有1个需要平滑的消息
let messageGroup = []
for (let message of messages) {
messageGroup.push(message)
if (this.isAddMessage(message)) {
this.smoothedMessageQueue.push(messageGroup)
messageGroup = []
}
}
// 还剩下不需要平滑的消息
if (messageGroup.length > 0) {
if (this.smoothedMessageQueue.length > 0) {
// 和上一组合并
let lastMessageGroup = this.smoothedMessageQueue[this.smoothedMessageQueue.length - 1]
for (let message of messageGroup) {
lastMessageGroup.push(message)
}
} else {
// 自己一个组
this.smoothedMessageQueue.push(messageGroup)
}
}
if (!this.emitSmoothedMessageTimerId) {
this.emitSmoothedMessageTimerId = window.setTimeout(this.emitSmoothedMessages)
}
},
isAddMessage({ type }) {
return ADD_MESSAGE_TYPES.indexOf(type) !== -1
},
emitSmoothedMessages() {
this.emitSmoothedMessageTimerId = null
if (this.smoothedMessageQueue.length <= 0) {
return
}
// 估计的下次进队列剩余时间
let estimatedNextEnqueueRemainTime = 10 * 1000
if (this.estimatedEnqueueInterval) {
estimatedNextEnqueueRemainTime = Math.max(this.lastEnqueueTime - new Date() + this.estimatedEnqueueInterval, 1)
}
// 计算发送的消息数,保证在下次进队列之前发完
// 下次进队列之前应该发多少条消息
let shouldEmitGroupNum = Math.max(this.smoothedMessageQueue.length, 0)
// 下次进队列之前最多能发多少次
let maxCanEmitCount = estimatedNextEnqueueRemainTime / MESSAGE_MIN_INTERVAL
// 这次发多少条消息
let groupNumToEmit
if (shouldEmitGroupNum < maxCanEmitCount) {
// 队列中消息数很少每次发1条也能发完
groupNumToEmit = 1
} else {
// 每次发1条以上保证按最快速度能发完
groupNumToEmit = Math.ceil(shouldEmitGroupNum / maxCanEmitCount)
}
// 发消息
let messageGroups = this.smoothedMessageQueue.splice(0, groupNumToEmit)
let mergedGroup = []
for (let messageGroup of messageGroups) {
for (let message of messageGroup) {
mergedGroup.push(message)
}
}
this.handleMessageGroup(mergedGroup)
if (this.smoothedMessageQueue.length <= 0) {
return
}
// 消息没发完,计算下次发消息时间
let sleepTime
if (groupNumToEmit === 1) {
// 队列中消息数很少,随便定个[MESSAGE_MIN_INTERVAL, MESSAGE_MAX_INTERVAL]的时间
sleepTime = estimatedNextEnqueueRemainTime / this.smoothedMessageQueue.length
sleepTime *= 0.5 + Math.random()
if (sleepTime > MESSAGE_MAX_INTERVAL) {
sleepTime = MESSAGE_MAX_INTERVAL
} else if (sleepTime < MESSAGE_MIN_INTERVAL) {
sleepTime = MESSAGE_MIN_INTERVAL
}
} else {
// 按最快速度发
sleepTime = MESSAGE_MIN_INTERVAL
}
this.emitSmoothedMessageTimerId = window.setTimeout(this.emitSmoothedMessages, sleepTime)
},
handleMessageGroup(messageGroup) {
if (messageGroup.length <= 0) {
return
}
for (let message of messageGroup) {
switch (message.type) {
case constants.MESSAGE_TYPE_TEXT:
case constants.MESSAGE_TYPE_GIFT:
case constants.MESSAGE_TYPE_MEMBER:
case constants.MESSAGE_TYPE_SUPER_CHAT:
// 这里处理的类型要和 ADD_MESSAGE_TYPES 一致
this.handleAddMessage(message)
break
case constants.MESSAGE_TYPE_DEL:
this.handleDelMessage(message)
break
case constants.MESSAGE_TYPE_UPDATE:
this.handleUpdateMessage(message)
break
}
}
this.maybeResizeScrollContainer()
this.flushMessagesBuffer()
this.$nextTick(this.maybeScrollToBottom)
},
handleAddMessage(message) {
// 添加一个本地时间给Ticker用防止本地时间和服务器时间相差很大的情况
message.addTime = new Date()
if (message.type !== constants.MESSAGE_TYPE_TEXT) {
this.paidMessages.unshift(_.cloneDeep(message))
const MAX_PAID_MESSAGE_NUM = 100
if (this.paidMessages.length > MAX_PAID_MESSAGE_NUM) {
this.paidMessages.splice(MAX_PAID_MESSAGE_NUM, this.paidMessages.length - MAX_PAID_MESSAGE_NUM)
}
}
// 不知道cloneDeep拷贝Vue的响应式对象会不会有问题保险起见把这句放在后面
this.messagesBuffer.push(message)
},
handleDelMessage({ id }) {
let arrs = [this.messages, this.paidMessages, this.messagesBuffer]
let needResetSmoothScroll = false
for (let arr of arrs) {
for (let i = 0; i < arr.length; i++) {
if (arr[i].id !== id) {
continue
}
arr.splice(i, 1)
if (arr === this.messages) {
needResetSmoothScroll = true
}
break
}
}
if (needResetSmoothScroll) {
this.resetSmoothScroll()
}
},
handleUpdateMessage({ id, newValuesObj }) {
let arrs = [this.messages, this.paidMessages, this.messagesBuffer]
let needResetSmoothScroll = false
for (let arr of arrs) {
for (let message of arr) {
if (message.id !== id) {
continue
}
this.doUpdateMessage(message, newValuesObj)
if (arr === this.messages) {
needResetSmoothScroll = true
}
break
}
}
if (needResetSmoothScroll) {
this.resetSmoothScroll()
}
},
doUpdateMessage(message, newValuesObj) {
// +=
let addValuesObj = newValuesObj.$add
if (addValuesObj !== undefined) {
for (let name in addValuesObj) {
message[name] += addValuesObj[name]
}
}
// =
for (let name in newValuesObj) {
if (!name.startsWith('$')) {
message[name] = newValuesObj[name]
}
}
},
async flushMessagesBuffer() {
if (this.messagesBuffer.length <= 0) {
return
}
if (!this.canScrollToBottomOrTimedOut()) {
if (this.messagesBuffer.length > this.maxNumber) {
// 未显示消息数 > 最大可显示数,丢弃
this.messagesBuffer.splice(0, this.messagesBuffer.length - this.maxNumber)
}
return
}
let removeNum = Math.max(this.messages.length + this.messagesBuffer.length - this.maxNumber, 0)
if (removeNum > 0) {
this.messages.splice(0, removeNum)
// 防止同时添加和删除项目时所有的项目重新渲染 https://github.com/vuejs/vue/issues/6857
await this.$nextTick()
}
this.preinsertHeight = this.$refs.items.clientHeight
for (let message of this.messagesBuffer) {
this.messages.push(message)
}
this.messagesBuffer = []
// 等items高度变化
await this.$nextTick()
this.showNewMessages()
},
showNewMessages() {
let hasScrollBar = this.$refs.items.clientHeight > this.$refs.scroller.clientHeight
this.$refs.itemOffset.style.height = `${this.$refs.items.clientHeight}px`
if (!this.canScrollToBottomOrTimedOut() || !hasScrollBar) {
return
}
// 计算剩余像素
this.scrollPixelsRemaining += this.$refs.items.clientHeight - this.preinsertHeight
this.scrollToBottom()
// 计算是否平滑滚动、剩余时间
if (!this.lastSmoothChatMessageAddMs) {
this.lastSmoothChatMessageAddMs = performance.now()
}
let interval = performance.now() - this.lastSmoothChatMessageAddMs
this.chatRateMs = (0.9 * this.chatRateMs) + (0.1 * interval)
if (this.isSmoothed) {
if (this.chatRateMs < 400) {
this.isSmoothed = false
}
} else {
if (this.chatRateMs > 450) {
this.isSmoothed = true
}
}
this.scrollTimeRemainingMs += this.isSmoothed ? CHAT_SMOOTH_ANIMATION_TIME_MS : 0
if (!this.smoothScrollRafHandle) {
this.smoothScrollRafHandle = window.requestAnimationFrame(this.smoothScroll)
}
this.lastSmoothChatMessageAddMs = performance.now()
},
smoothScroll(time) {
if (!this.lastSmoothScrollUpdate) {
// 第一帧
this.lastSmoothScrollUpdate = time
this.smoothScrollRafHandle = window.requestAnimationFrame(this.smoothScroll)
return
}
let interval = time - this.lastSmoothScrollUpdate
if (
this.scrollPixelsRemaining <= 0 || this.scrollPixelsRemaining >= 400 // 已经滚动到底部或者离底部太远则结束
|| interval >= 1000 // 离上一帧时间太久,可能用户切换到其他网页
|| this.scrollTimeRemainingMs <= 0 // 时间已结束
) {
this.resetSmoothScroll()
return
}
let pixelsToScroll = interval / this.scrollTimeRemainingMs * this.scrollPixelsRemaining
this.scrollPixelsRemaining -= pixelsToScroll
if (this.scrollPixelsRemaining < 0) {
this.scrollPixelsRemaining = 0
}
this.scrollTimeRemainingMs -= interval
if (this.scrollTimeRemainingMs < 0) {
this.scrollTimeRemainingMs = 0
}
this.lastSmoothScrollUpdate = time
this.smoothScrollRafHandle = window.requestAnimationFrame(this.smoothScroll)
},
resetSmoothScroll() {
this.scrollTimeRemainingMs = this.scrollPixelsRemaining = 0
this.lastSmoothScrollUpdate = null
if (this.smoothScrollRafHandle) {
window.cancelAnimationFrame(this.smoothScrollRafHandle)
this.smoothScrollRafHandle = null
}
},
maybeResizeScrollContainer() {
this.$refs.itemOffset.style.height = `${this.$refs.items.clientHeight}px`
this.$refs.itemOffset.style.minHeight = `${this.$refs.scroller.clientHeight}px`
this.maybeScrollToBottom()
},
maybeScrollToBottom() {
if (this.canScrollToBottomOrTimedOut()) {
this.scrollToBottom()
}
},
scrollToBottom() {
this.$refs.scroller.scrollTop = Math.pow(2, 24)
this.atBottom = true
},
onScroll() {
this.refreshCantScrollStartTime()
let scroller = this.$refs.scroller
this.atBottom = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight < SCROLLED_TO_BOTTOM_EPSILON
this.flushMessagesBuffer()
},
canScrollToBottomOrTimedOut() {
if (this.canScrollToBottom) {
return true
}
// 防止在OBS中卡住超过一定时间也可以自动滚动
return new Date() - this.cantScrollStartTime >= 5 * 1000
},
refreshCantScrollStartTime() {
// 有鼠标事件时刷新,防止用户看弹幕时自动滚动
if (this.cantScrollStartTime) {
this.cantScrollStartTime = new Date()
}
}
}
})
</script>
<style src="@/assets/css/youtube/yt-html.css"></style>
<style src="@/assets/css/youtube/yt-live-chat-renderer.css"></style>
<style src="@/assets/css/youtube/yt-live-chat-item-list-renderer.css"></style>

View File

@@ -0,0 +1,68 @@
<template>
<yt-live-chat-paid-message-renderer class="style-scope yt-live-chat-item-list-renderer" allow-animations
:show-only-header="!content || undefined" :style="{
'--yt-live-chat-paid-message-primary-color': color.contentBg,
'--yt-live-chat-paid-message-secondary-color': color.headerBg,
'--yt-live-chat-paid-message-header-color': color.header,
'--yt-live-chat-paid-message-author-name-color': color.authorName,
'--yt-live-chat-paid-message-timestamp-color': color.time,
'--yt-live-chat-paid-message-color': color.content
}"
:blc-price-level="priceConfig.priceLevel"
>
<div id="card" class="style-scope yt-live-chat-paid-message-renderer">
<div id="header" class="style-scope yt-live-chat-paid-message-renderer">
<img-shadow id="author-photo" height="40" width="40" class="style-scope yt-live-chat-paid-message-renderer"
:imgUrl="avatarUrl"
></img-shadow>
<div id="header-content" class="style-scope yt-live-chat-paid-message-renderer">
<div id="header-content-primary-column" class="style-scope yt-live-chat-paid-message-renderer">
<div id="author-name" class="style-scope yt-live-chat-paid-message-renderer">{{ authorName }}</div>
<div id="purchase-amount" class="style-scope yt-live-chat-paid-message-renderer">{{ showPriceText }}</div>
</div>
<span id="timestamp" class="style-scope yt-live-chat-paid-message-renderer">{{ timeText }}</span>
</div>
</div>
<div id="content" class="style-scope yt-live-chat-paid-message-renderer">
<div id="message" dir="auto" class="style-scope yt-live-chat-paid-message-renderer">{{ content }}</div>
</div>
</div>
</yt-live-chat-paid-message-renderer>
</template>
<script>
import ImgShadow from './ImgShadow.vue'
import * as constants from './constants'
import * as utils from './utils'
export default {
name: 'PaidMessage',
components: {
ImgShadow
},
props: {
avatarUrl: String,
authorName: String,
price: Number, // 价格,人民币
priceText: String,
time: Date,
content: String
},
computed: {
priceConfig() {
return constants.getPriceConfig(this.price)
},
color() {
return this.priceConfig.colors
},
showPriceText() {
return this.priceText || `CN¥${utils.formatCurrency(this.price)}`
},
timeText() {
return utils.getTimeTextHourMin(this.time)
}
}
}
</script>
<style src="@/assets/css/youtube/yt-live-chat-paid-message-renderer.css"></style>

View File

@@ -0,0 +1,106 @@
<template>
<yt-live-chat-text-message-renderer :author-type="authorTypeText" :blc-guard-level="privilegeType">
<img-shadow id="author-photo" height="24" width="24" class="style-scope yt-live-chat-text-message-renderer"
:imgUrl="avatarUrl"
></img-shadow>
<div id="content" class="style-scope yt-live-chat-text-message-renderer">
<span id="timestamp" class="style-scope yt-live-chat-text-message-renderer">{{ timeText }}</span>
<author-chip class="style-scope yt-live-chat-text-message-renderer"
:isInMemberMessage="false" :authorName="authorName" :authorType="authorType" :privilegeType="privilegeType"
></author-chip>
<span id="message" class="style-scope yt-live-chat-text-message-renderer">
<template v-for="(content, index) in richContent">
<span :key="index" v-if="content.type === CONTENT_TYPE_TEXT">{{ content.text }}</span>
<!-- 如果CSS设置的尺寸比属性设置的尺寸还大在图片加载完后布局会变化可能导致滚动卡住没什么好的解决方法 -->
<img :key="'_' + index" v-else-if="content.type === CONTENT_TYPE_IMAGE"
class="emoji yt-formatted-string style-scope yt-live-chat-text-message-renderer"
:src="content.url" :alt="content.text" :shared-tooltip-text="content.text" :id="`emoji-${content.text}`"
:width="content.width" :height="content.height"
:class="{ 'blc-large-emoji': content.height >= 100 }"
referrerpolicy="no-referrer"
>
</template>
<NBadge :value="repeated" :max="99" v-if="repeated > 1" class="style-scope yt-live-chat-text-message-renderer"
:style="{ '--repeated-mark-color': repeatedMarkColor }"
></NBadge>
</span>
</div>
</yt-live-chat-text-message-renderer>
</template>
<script>
import ImgShadow from './ImgShadow.vue'
import AuthorChip from './AuthorChip.vue'
import * as constants from './constants'
import * as utils from './utils'
import { NBadge } from 'naive-ui'
// HSL
const REPEATED_MARK_COLOR_START = [210, 100.0, 62.5]
const REPEATED_MARK_COLOR_END = [360, 87.3, 69.2]
export default {
name: 'TextMessage',
components: {
ImgShadow,
AuthorChip,
NBadge
},
props: {
avatarUrl: String,
time: Date,
authorName: String,
authorType: Number,
richContent: Array,
privilegeType: Number,
repeated: Number
},
data() {
return {
CONTENT_TYPE_TEXT: constants.CONTENT_TYPE_TEXT,
CONTENT_TYPE_IMAGE: constants.CONTENT_TYPE_IMAGE
}
},
computed: {
timeText() {
return utils.getTimeTextHourMin(this.time)
},
authorTypeText() {
return constants.AUTHOR_TYPE_TO_TEXT[this.authorType]
},
repeatedMarkColor() {
let color
if (this.repeated <= 2) {
color = REPEATED_MARK_COLOR_START
} else if (this.repeated >= 10) {
color = REPEATED_MARK_COLOR_END
} else {
color = [0, 0, 0]
let t = (this.repeated - 2) / (10 - 2)
for (let i = 0; i < 3; i++) {
color[i] = REPEATED_MARK_COLOR_START[i] + ((REPEATED_MARK_COLOR_END[i] - REPEATED_MARK_COLOR_START[i]) * t)
}
}
return `hsl(${color[0]}, ${color[1]}%, ${color[2]}%)`
}
}
}
</script>
<style>
yt-live-chat-text-message-renderer>#content>#message>.el-badge {
margin-left: 10px;
}
yt-live-chat-text-message-renderer>#content>#message>.el-badge .el-badge__content {
font-size: 12px !important;
line-height: 18px !important;
text-shadow: none !important;
font-family: sans-serif !important;
color: #FFF !important;
background-color: var(--repeated-mark-color) !important;
border: none;
}
</style>
<style src="@/assets/css/youtube/yt-live-chat-text-message-renderer.css"></style>

View File

@@ -0,0 +1,201 @@
<template>
<yt-live-chat-ticker-renderer :hidden="showMessages.length === 0">
<div id="container" dir="ltr" class="style-scope yt-live-chat-ticker-renderer">
<transition-group tag="div" :css="false" @enter="onTickerItemEnter" @leave="onTickerItemLeave" id="items"
class="style-scope yt-live-chat-ticker-renderer">
<yt-live-chat-ticker-paid-message-item-renderer v-for="message in showMessages" :key="message.raw.id"
tabindex="0" class="style-scope yt-live-chat-ticker-renderer" style="overflow: hidden;"
@click="onItemClick(message.raw)">
<div id="container" dir="ltr" class="style-scope yt-live-chat-ticker-paid-message-item-renderer" :style="{
background: message.bgColor,
}">
<div id="content" class="style-scope yt-live-chat-ticker-paid-message-item-renderer" :style="{
color: message.color
}">
<img-shadow id="author-photo" height="24" width="24"
class="style-scope yt-live-chat-ticker-paid-message-item-renderer"
:imgUrl="message.raw.avatarUrl"></img-shadow>
<span id="text" dir="ltr"
class="style-scope yt-live-chat-ticker-paid-message-item-renderer">{{ message.text }}</span>
</div>
</div>
</yt-live-chat-ticker-paid-message-item-renderer>
</transition-group>
</div>
<template v-if="pinnedMessage">
<membership-item :key="pinnedMessage.id" v-if="pinnedMessage.type === MESSAGE_TYPE_MEMBER"
class="style-scope yt-live-chat-ticker-renderer" :avatarUrl="pinnedMessage.avatarUrl"
:authorName="getShowAuthorName(pinnedMessage)" :privilegeType="pinnedMessage.privilegeType"
:title="pinnedMessage.title" :time="pinnedMessage.time"></membership-item>
<paid-message :key="pinnedMessage.id" v-else class="style-scope yt-live-chat-ticker-renderer"
:price="pinnedMessage.price" :avatarUrl="pinnedMessage.avatarUrl" :authorName="getShowAuthorName(pinnedMessage)"
:time="pinnedMessage.time" :content="pinnedMessageShowContent"></paid-message>
</template>
</yt-live-chat-ticker-renderer>
</template>
<script>
// @ts-nocheck
import { formatCurrency } from './utils'
import ImgShadow from './ImgShadow.vue'
import MembershipItem from './MembershipItem.vue'
import PaidMessage from './PaidMessage.vue'
import * as constants from './constants'
export default {
name: 'Ticker',
components: {
ImgShadow,
MembershipItem,
PaidMessage
},
props: {
messages: Array,
showGiftName: {
type: Boolean,
default: false
}
},
data() {
return {
MESSAGE_TYPE_MEMBER: constants.MESSAGE_TYPE_MEMBER,
curTime: new Date(),
updateTimerId: window.setInterval(this.updateProgress, 1000),
pinnedMessage: null
}
},
computed: {
showMessages() {
let res = []
for (let message of this.messages) {
if (!this.needToShow(message)) {
continue
}
res.push({
raw: message,
bgColor: this.getBgColor(message),
color: this.getColor(message),
text: this.getText(message)
})
}
return res
},
pinnedMessageShowContent() {
if (!this.pinnedMessage) {
return ''
}
if (this.pinnedMessage.type === constants.MESSAGE_TYPE_GIFT) {
return constants.getGiftShowContent(this.pinnedMessage, this.showGiftName)
} else {
return constants.getShowContent(this.pinnedMessage)
}
}
},
beforeDestroy() {
window.clearInterval(this.updateTimerId)
},
methods: {
async onTickerItemEnter(el, done) {
let width = el.clientWidth
if (width === 0) {
// CSS指定了不显示固定栏
done()
return
}
el.style.width = 0
await this.$nextTick()
el.style.width = `${width}px`
window.setTimeout(done, 200)
},
onTickerItemLeave(el, done) {
el.classList.add('sliding-down')
window.setTimeout(() => {
el.classList.add('collapsing')
el.style.width = 0
window.setTimeout(() => {
el.classList.remove('sliding-down')
el.classList.remove('collapsing')
el.style.width = 'auto'
done()
}, 200)
}, 200)
},
getShowAuthorName: constants.getShowAuthorName,
needToShow(message) {
let pinTime = this.getPinTime(message)
return (new Date() - message.addTime) / (60 * 1000) < pinTime
},
getBgColor(message) {
let color1, color2
if (message.type === constants.MESSAGE_TYPE_MEMBER) {
color1 = 'rgba(15,157,88,1)'
color2 = 'rgba(11,128,67,1)'
} else {
let config = constants.getPriceConfig(message.price)
color1 = config.colors.contentBg
color2 = config.colors.headerBg
}
let pinTime = this.getPinTime(message)
let progress = (1 - ((this.curTime - message.addTime) / (60 * 1000) / pinTime)) * 100
if (progress < 0) {
progress = 0
} else if (progress > 100) {
progress = 100
}
return `linear-gradient(90deg, ${color1}, ${color1} ${progress}%, ${color2} ${progress}%, ${color2})`
},
getColor(message) {
if (message.type === constants.MESSAGE_TYPE_MEMBER) {
return 'rgb(255,255,255)'
}
return constants.getPriceConfig(message.price).colors.header
},
getText(message) {
if (message.type === constants.MESSAGE_TYPE_MEMBER) {
return this.$t('chat.tickerMembership')
}
return `CN¥${formatCurrency(message.price)}`
},
getPinTime(message) {
if (message.type === constants.MESSAGE_TYPE_MEMBER) {
return 2
}
return constants.getPriceConfig(message.price).pinTime
},
updateProgress() {
// 更新进度
this.curTime = new Date()
// 删除过期的消息
let filteredMessages = []
let messagesChanged = false
for (let message of this.messages) {
let pinTime = this.getPinTime(message)
if ((this.curTime - message.addTime) / (60 * 1000) >= pinTime) {
messagesChanged = true
if (this.pinnedMessage === message) {
this.pinnedMessage = null
}
continue
}
filteredMessages.push(message)
}
if (messagesChanged) {
this.$emit('update:messages', filteredMessages)
}
},
onItemClick(message) {
if (this.pinnedMessage == message) {
this.pinnedMessage = null
} else {
this.pinnedMessage = message
}
}
}
}
</script>
<style src="@/assets/css/youtube/yt-live-chat-ticker-renderer.css"></style>
<style src="@/assets/css/youtube/yt-live-chat-ticker-paid-message-item-renderer.css"></style>

View File

@@ -0,0 +1,200 @@
export const AUTHOR_TYPE_NORMAL = 0
export const AUTHOR_TYPE_MEMBER = 1
export const AUTHOR_TYPE_ADMIN = 2
export const AUTHOR_TYPE_OWNER = 3
export const AUTHOR_TYPE_TO_TEXT = [
'',
'member', // 舰队
'moderator', // 房管
'owner' // 主播
]
export function getShowGuardLevelText(guardLevel) {
switch (guardLevel) {
case 1:
return '总督'
case 2:
return '提督'
case 3:
return '舰长'
default:
return ''
}
}
export const MESSAGE_TYPE_TEXT = 0
export const MESSAGE_TYPE_GIFT = 1
export const MESSAGE_TYPE_MEMBER = 2
export const MESSAGE_TYPE_SUPER_CHAT = 3
export const MESSAGE_TYPE_DEL = 4
export const MESSAGE_TYPE_UPDATE = 5
export const CONTENT_TYPE_TEXT = 0
export const CONTENT_TYPE_IMAGE = 1
// 美元 -> 人民币 汇率
const EXCHANGE_RATE = 7
const PRICE_CONFIGS = [
// 0 淡蓝
{
price: 0,
colors: {
contentBg: 'rgba(153, 236, 255, 1)',
headerBg: 'rgba(153, 236, 255, 1)',
header: 'rgba(0,0,0,1)',
authorName: 'rgba(0,0,0,0.701961)',
time: 'rgba(0,0,0,0.501961)',
content: 'rgba(0,0,0,1)'
},
pinTime: 0,
priceLevel: 0,
},
// ¥0.01 蓝
{
price: 0.01,
colors: {
contentBg: 'rgba(30,136,229,1)',
headerBg: 'rgba(21,101,192,1)',
header: 'rgba(255,255,255,1)',
authorName: 'rgba(255,255,255,0.701961)',
time: 'rgba(255,255,255,0.501961)',
content: 'rgba(255,255,255,1)'
},
pinTime: 0,
priceLevel: 1,
},
// $2 浅蓝
{
price: 2 * EXCHANGE_RATE,
colors: {
contentBg: 'rgba(0,229,255,1)',
headerBg: 'rgba(0,184,212,1)',
header: 'rgba(0,0,0,1)',
authorName: 'rgba(0,0,0,0.701961)',
time: 'rgba(0,0,0,0.501961)',
content: 'rgba(0,0,0,1)'
},
pinTime: 0,
priceLevel: 2,
},
// $5 绿
{
price: 5 * EXCHANGE_RATE,
colors: {
contentBg: 'rgba(29,233,182,1)',
headerBg: 'rgba(0,191,165,1)',
header: 'rgba(0,0,0,1)',
authorName: 'rgba(0,0,0,0.541176)',
time: 'rgba(0,0,0,0.501961)',
content: 'rgba(0,0,0,1)'
},
pinTime: 2,
priceLevel: 3,
},
// $10 黄
{
price: 10 * EXCHANGE_RATE,
colors: {
contentBg: 'rgba(255,202,40,1)',
headerBg: 'rgba(255,179,0,1)',
header: 'rgba(0,0,0,0.87451)',
authorName: 'rgba(0,0,0,0.541176)',
time: 'rgba(0,0,0,0.501961)',
content: 'rgba(0,0,0,0.87451)'
},
pinTime: 5,
priceLevel: 4,
},
// $20 橙
{
price: 20 * EXCHANGE_RATE,
colors: {
contentBg: 'rgba(245,124,0,1)',
headerBg: 'rgba(230,81,0,1)',
header: 'rgba(255,255,255,0.87451)',
authorName: 'rgba(255,255,255,0.701961)',
time: 'rgba(255,255,255,0.501961)',
content: 'rgba(255,255,255,0.87451)'
},
pinTime: 10,
priceLevel: 5,
},
// $50 品红
{
price: 50 * EXCHANGE_RATE,
colors: {
contentBg: 'rgba(233,30,99,1)',
headerBg: 'rgba(194,24,91,1)',
header: 'rgba(255,255,255,1)',
authorName: 'rgba(255,255,255,0.701961)',
time: 'rgba(255,255,255,0.501961)',
content: 'rgba(255,255,255,1)'
},
pinTime: 30,
priceLevel: 6,
},
// $100 红
{
price: 100 * EXCHANGE_RATE,
colors: {
contentBg: 'rgba(230,33,23,1)',
headerBg: 'rgba(208,0,0,1)',
header: 'rgba(255,255,255,1)',
authorName: 'rgba(255,255,255,0.701961)',
time: 'rgba(255,255,255,0.501961)',
content: 'rgba(255,255,255,1)'
},
pinTime: 60,
priceLevel: 7,
},
]
export function getPriceConfig(price) {
let i = 0
// 根据先验知识,从小找到大通常更快结束
for (; i < PRICE_CONFIGS.length - 1; i++) {
let nextConfig = PRICE_CONFIGS[i + 1]
if (price < nextConfig.price) {
return PRICE_CONFIGS[i]
}
}
return PRICE_CONFIGS[i]
}
export function getShowContent(message) {
if (message.translation) {
return `${message.content}${message.translation}`
}
return message.content
}
export function getShowRichContent(message) {
let richContent = [...message.richContent]
if (message.translation) {
richContent.push({
type: CONTENT_TYPE_TEXT,
text: `${message.translation}`
})
}
return richContent
}
export function getGiftShowContent(message, showGiftName) {
if (!showGiftName) {
return ''
}
return `赠送 ${message.giftName}x${message.num}`
}
export function getGiftShowNameAndNum(message) {
return `${message.giftName}x${message.num}`
}
export function getShowAuthorName(message) {
if (message.authorNamePronunciation && message.authorNamePronunciation !== message.authorName) {
return `${message.authorName}(${message.authorNamePronunciation})`
}
return message.authorName
}

View File

@@ -0,0 +1,126 @@
import { getUuid4Hex } from './utils'
import * as constants from './constants'
export const DEFAULT_AVATAR_URL = 'https://i0.hdslb.com/bfs/face/member/noface.jpg@64w_64h'
export class AddTextMsg {
constructor({
avatarUrl = DEFAULT_AVATAR_URL,
timestamp = new Date().getTime() / 1000,
authorName = '',
authorType = constants.AUTHOR_TYPE_NORMAL,
content = '',
privilegeType = 0,
isGiftDanmaku = false,
authorLevel = 1,
isNewbie = false,
isMobileVerified = true,
medalLevel = 0,
id = getUuid4Hex(),
translation = '',
emoticon = null
} = {}) {
this.avatarUrl = avatarUrl
this.timestamp = timestamp
this.authorName = authorName
this.authorType = authorType
this.content = content
this.privilegeType = privilegeType
this.isGiftDanmaku = isGiftDanmaku
this.authorLevel = authorLevel
this.isNewbie = isNewbie
this.isMobileVerified = isMobileVerified
this.medalLevel = medalLevel
this.id = id
this.translation = translation
this.emoticon = emoticon
}
}
export class AddGiftMsg {
constructor({
id = getUuid4Hex(),
avatarUrl = DEFAULT_AVATAR_URL,
timestamp = new Date().getTime() / 1000,
authorName = '',
totalCoin = 0,
totalFreeCoin = 0,
giftName = '',
num = 1
} = {}) {
this.id = id
this.avatarUrl = avatarUrl
this.timestamp = timestamp
this.authorName = authorName
this.totalCoin = totalCoin
this.totalFreeCoin = totalFreeCoin
this.giftName = giftName
this.num = num
}
}
export class AddMemberMsg {
constructor({
id = getUuid4Hex(),
avatarUrl = DEFAULT_AVATAR_URL,
timestamp = new Date().getTime() / 1000,
authorName = '',
privilegeType = 1
} = {}) {
this.id = id
this.avatarUrl = avatarUrl
this.timestamp = timestamp
this.authorName = authorName
this.privilegeType = privilegeType
}
}
export class AddSuperChatMsg {
constructor({
id = getUuid4Hex(),
avatarUrl = DEFAULT_AVATAR_URL,
timestamp = new Date().getTime() / 1000,
authorName = '',
price = 0,
content = '',
translation = ''
} = {}) {
this.id = id
this.avatarUrl = avatarUrl
this.timestamp = timestamp
this.authorName = authorName
this.price = price
this.content = content
this.translation = translation
}
}
export class DelSuperChatMsg {
constructor({ ids = [] } = {}) {
this.ids = ids
}
}
export class UpdateTranslationMsg {
constructor({ id = getUuid4Hex(), translation = '' } = {}) {
this.id = id
this.translation = translation
}
}
export const FATAL_ERROR_TYPE_AUTH_CODE_ERROR = 1
export const FATAL_ERROR_TYPE_TOO_MANY_RETRIES = 2
export const FATAL_ERROR_TYPE_TOO_MANY_CONNECTIONS = 3
export class ChatClientFatalError extends Error {
constructor(type, message) {
super(message)
this.type = type
}
}
export class DebugMsg {
constructor({ content = '' } = {}) {
this.content = content
}
}

View File

@@ -0,0 +1,51 @@
export function mergeConfig(config, defaultConfig) {
let res = {}
for (let i in defaultConfig) {
res[i] = i in config ? config[i] : defaultConfig[i]
}
return res
}
export function toBool(val) {
if (typeof val === 'string') {
return ['false', 'no', 'off', '0', ''].indexOf(val.toLowerCase()) === -1
}
return Boolean(val)
}
export function toInt(val, _default) {
let res = parseInt(val)
if (isNaN(res)) {
res = _default
}
return res
}
export function toFloat(val, _default) {
let res = parseFloat(val)
if (isNaN(res)) {
res = _default
}
return res
}
export function formatCurrency(price) {
return new Intl.NumberFormat('zh-CN', {
minimumFractionDigits: price < 100 ? 2 : 0
}).format(price)
}
export function getTimeTextHourMin(date) {
let hour = date.getHours()
let min = `00${date.getMinutes()}`.slice(-2)
return `${hour}:${min}`
}
export function getUuid4Hex() {
let chars = []
for (let i = 0; i < 32; i++) {
let char = Math.floor(Math.random() * 16).toString(16)
chars.push(char)
}
return chars.join('')
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,54 @@
export const DICT_PINYIN = 'pinyin'
export const DICT_KANA = 'kana'
export class PronunciationConverter {
constructor() {
this.pronunciationMap = new Map()
}
async loadDict(dictName) {
let promise
switch (dictName) {
case DICT_PINYIN:
promise = import('./dictPinyin')
break
case DICT_KANA:
promise = import('./dictKana')
break
default:
return
}
let dictTxt = (await promise).default
let pronunciationMap = new Map()
for (let item of dictTxt.split('\n')) {
if (item.length === 0) {
continue
}
pronunciationMap.set(item.substring(0, 1), item.substring(1))
}
this.pronunciationMap = pronunciationMap
}
getPronunciation(text) {
let res = []
let lastHasPronunciation = null
for (let char of text) {
let pronunciation = this.pronunciationMap.get(char)
if (pronunciation === undefined) {
if (lastHasPronunciation !== null && lastHasPronunciation) {
res.push(' ')
}
lastHasPronunciation = false
res.push(char)
} else {
if (lastHasPronunciation !== null) {
res.push(' ')
}
lastHasPronunciation = true
res.push(pronunciation)
}
}
return res.join('')
}
}

View File

@@ -0,0 +1,58 @@
export class Trie {
constructor() {
this._root = this._createNode()
}
_createNode() {
return {
children: {}, // char -> node
value: null
}
}
set(key, value) {
if (key === '') {
throw new Error('key is empty')
}
let node = this._root
for (let char of key) {
let nextNode = node.children[char]
if (nextNode === undefined) {
nextNode = node.children[char] = this._createNode()
}
node = nextNode
}
node.value = value
}
get(key) {
let node = this._root
for (let char of key) {
let nextNode = node.children[char]
if (nextNode === undefined) {
return null
}
node = nextNode
}
return node.value
}
has(key) {
return this.get(key) !== null
}
lazyMatch(str) {
let node = this._root
for (let char of str) {
let nextNode = node.children[char]
if (nextNode === undefined) {
return null
}
if (nextNode.value !== null) {
return nextNode.value
}
node = nextNode
}
return null
}
}

View File

@@ -5,18 +5,18 @@ import {
EventDataTypes,
EventModel,
FunctionTypes,
QueueSortType,
Setting_LiveRequest,
SongRequestFrom,
SongRequestInfo,
SongRequestStatus,
SongsInfo,
QueueGiftFilterType,
QueueSortType,
SongsInfo
} from '@/api/api-models'
import { QueryGetAPI, QueryPostAPI, QueryPostAPIWithParams } from '@/api/query'
import SongPlayer from '@/components/SongPlayer.vue'
import DanmakuClient, { RoomAuthInfo } from '@/data/DanmakuClient'
import { RoomAuthInfo } from '@/data/DanmakuClient'
import { SONG_REQUEST_API_URL } from '@/data/constants'
import { useDanmakuClient } from '@/store/useDanmakuClient'
import {
Checkmark12Regular,
Delete24Filled,
@@ -54,7 +54,6 @@ import {
NPopconfirm,
NRadioButton,
NRadioGroup,
NSelect,
NSpace,
NSpin,
NSwitch,
@@ -66,7 +65,7 @@ import {
NTooltip,
NUl,
useMessage,
useNotification,
useNotification
} from 'naive-ui'
import { computed, h, onActivated, onDeactivated, onMounted, onUnmounted, ref } from 'vue'
import { useRoute } from 'vue-router'
@@ -105,6 +104,7 @@ const route = useRoute()
const accountInfo = useAccount()
const message = useMessage()
const notice = useNotification()
const client = await useDanmakuClient().initClient()
const isWarnMessageAutoClose = useStorage('SongRequest.Settings.WarnMessageAutoClose', false)
const volumn = useStorage('Settings.Volumn', 0.5)
@@ -129,9 +129,8 @@ const settings = computed({
const selectedSong = ref<SongsInfo>()
const props = defineProps<{
client: DanmakuClient
roomInfo: RoomAuthInfo
code: string | undefined
roomInfo?: RoomAuthInfo
code?: string | undefined
isOpenLive?: boolean
}>()
@@ -763,8 +762,8 @@ onMounted(() => {
if (accountInfo.value) {
settings.value = accountInfo.value.settings.songRequest
}
props.client.onEvent('danmaku', onGetDanmaku)
props.client.onEvent('sc', onGetSC)
client.onEvent('danmaku', onGetDanmaku)
client.onEvent('sc', onGetSC)
init()
})
onActivated(() => {
@@ -787,8 +786,8 @@ onDeactivated(() => {
dispose()
})
onUnmounted(() => {
props.client.offEvent('danmaku', onGetDanmaku)
props.client.offEvent('sc', onGetSC)
client.offEvent('danmaku', onGetDanmaku)
client.offEvent('sc', onGetSC)
dispose()
})
</script>

View File

@@ -1,9 +1,10 @@
<script setup lang="ts">
import { DownloadConfig, UploadConfig, useAccount } from '@/api/account'
import { DanmakuUserInfo, EventModel, FunctionTypes, SongFrom, SongsInfo } from '@/api/api-models'
import { DanmakuUserInfo, EventModel, SongFrom, SongsInfo } from '@/api/api-models'
import { QueryGetAPI, QueryPostAPI } from '@/api/query'
import DanmakuClient, { RoomAuthInfo } from '@/data/DanmakuClient'
import { RoomAuthInfo } from '@/data/DanmakuClient'
import { MUSIC_REQUEST_API_URL, SONG_API_URL } from '@/data/constants'
import { useDanmakuClient } from '@/store/useDanmakuClient'
import { MusicRequestSettings, useMusicRequestProvider } from '@/store/useMusicRequest'
import { useStorage } from '@vueuse/core'
import { List } from 'linqts'
@@ -40,9 +41,9 @@ import {
useMessage,
} from 'naive-ui'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { clearInterval, setInterval } from 'worker-timers'
import MusicRequestOBS from '../obs/MusicRequestOBS.vue'
import { useRoute } from 'vue-router'
type Music = {
id: number
@@ -64,11 +65,11 @@ const settings = computed(() => {
})
const cooldown = useStorage<{ [id: number]: number }>('Setting.MusicRequest.Cooldown', {})
const musicRquestStore = useMusicRequestProvider()
const client = await useDanmakuClient().initClient()
const props = defineProps<{
client: DanmakuClient
roomInfo: RoomAuthInfo
code: string | undefined
roomInfo?: RoomAuthInfo
code?: string | undefined
isOpenLive?: boolean
}>()
@@ -355,7 +356,7 @@ function updateWaiting() {
let timer: number
onMounted(async () => {
props.client.onEvent('danmaku', onGetEvent)
client.onEvent('danmaku', onGetEvent)
if (musicRquestStore.originMusics.length == 0) {
musicRquestStore.originMusics = await get()
}
@@ -373,7 +374,7 @@ onMounted(async () => {
timer = setInterval(updateWaiting, 2000)
})
onUnmounted(() => {
props.client.offEvent('danmaku', onGetEvent)
client.offEvent('danmaku', onGetEvent)
if (timer) {
clearInterval(timer)
}

View File

@@ -1,12 +1,11 @@
<script setup lang="ts">
import { useAccount } from '@/api/account'
import DanmakuClient, { RoomAuthInfo } from '@/data/DanmakuClient'
import { NAlert, NButton, NCard, NDivider, NSpace } from 'naive-ui'
import { useAccount } from '@/api/account';
import { RoomAuthInfo } from '@/data/DanmakuClient';
import { NAlert, NButton, NCard, NDivider, NSpace } from 'naive-ui';
const props = defineProps<{
client: DanmakuClient
roomInfo: RoomAuthInfo
code: string | undefined
roomInfo?: RoomAuthInfo
code?: string | undefined
}>()
const accountInfo = useAccount()

View File

@@ -2,8 +2,9 @@
import { useAccount } from '@/api/account'
import { OpenLiveLotteryType, OpenLiveLotteryUserInfo, UpdateLiveLotteryUsersModel } from '@/api/api-models'
import { QueryGetAPI, QueryPostAPI } from '@/api/query'
import DanmakuClient, { DanmakuInfo, GiftInfo, RoomAuthInfo } from '@/data/DanmakuClient'
import { DanmakuInfo, GiftInfo, RoomAuthInfo } from '@/data/DanmakuClient'
import { LOTTERY_API_URL } from '@/data/constants'
import { useDanmakuClient } from '@/store/useDanmakuClient'
import { Delete24Filled, Info24Filled } from '@vicons/fluent'
import { useLocalStorage, useStorage } from '@vueuse/core'
import { format } from 'date-fns'
@@ -83,6 +84,7 @@ const route = useRoute()
const message = useMessage()
const accountInfo = useAccount()
const notification = useNotification()
const client = useDanmakuClient()
const originUsers = ref<OpenLiveLotteryUserInfo[]>([])
const currentUsers = ref<OpenLiveLotteryUserInfo[]>([])
@@ -94,9 +96,8 @@ const showModal = ref(false)
const showOBSModal = ref(false)
const props = defineProps<{
client: DanmakuClient
roomInfo: RoomAuthInfo
code: string | undefined
roomInfo?: RoomAuthInfo
code?: string | undefined
}>()
async function getUsers() {
@@ -327,16 +328,16 @@ onMounted(async () => {
message.info('从历史记录中加载 ' + users.length + ' 位用户')
}
}
props.client?.on('danmaku', onDanmaku)
props.client?.on('gift', onGift)
client?.on('danmaku', onDanmaku)
client?.on('gift', onGift)
timer = setInterval(updateUsers, 1000 * 10)
})
onUnmounted(() => {
if (timer) {
clearInterval(timer)
}
props.client?.off('danmaku', onDanmaku)
props.client?.off('gift', onGift)
client?.off('danmaku', onDanmaku)
client?.off('gift', onGift)
})
</script>
@@ -488,7 +489,7 @@ onUnmounted(() => {
:loading="isLottering"
:disabled="isStartLottery || isLotteried"
data-umami-event="Open-Live Use Lottery"
:data-umami-event-uid="client?.roomAuthInfo?.value?.anchor_info?.uid"
:data-umami-event-uid="client?.authInfo?.anchor_info?.uid"
>
进行抽取
</NButton>

View File

@@ -14,16 +14,17 @@ import {
Setting_Queue,
} from '@/api/api-models'
import { QueryGetAPI, QueryPostAPI, QueryPostAPIWithParams } from '@/api/query'
import DanmakuClient, { RoomAuthInfo } from '@/data/DanmakuClient'
import { RoomAuthInfo } from '@/data/DanmakuClient'
import { QUEUE_API_URL } from '@/data/constants'
import { useDanmakuClient } from '@/store/useDanmakuClient'
import {
Checkmark12Regular,
ClipboardTextLtr24Filled,
Delete24Filled,
Dismiss16Filled,
Info24Filled,
PeopleQueue24Filled,
PresenceBlocked16Regular,
Info24Filled,
} from '@vicons/fluent'
import { ReloadCircleSharp } from '@vicons/ionicons5'
import { useStorage } from '@vueuse/core'
@@ -112,6 +113,7 @@ const route = useRoute()
const accountInfo = useAccount()
const message = useMessage()
const notice = useNotification()
const client = useDanmakuClient()
const isWarnMessageAutoClose = useStorage('Queue.Settings.WarnMessageAutoClose', false)
const isReverse = useStorage('Queue.Settings.Reverse', false)
@@ -138,9 +140,8 @@ const settings = computed({
})
const props = defineProps<{
client: DanmakuClient
roomInfo: RoomAuthInfo
code: string | undefined
roomInfo?: RoomAuthInfo
code?: string | undefined
isOpenLive?: boolean
}>()
@@ -730,8 +731,8 @@ onMounted(() => {
if (accountInfo.value) {
settings.value = accountInfo.value.settings.queue
}
props.client.onEvent('danmaku', onGetDanmaku)
props.client.onEvent('gift', onGetGift)
client.onEvent('danmaku', onGetDanmaku)
client.onEvent('gift', onGetGift)
init()
})
onActivated(() => {
@@ -754,8 +755,8 @@ onDeactivated(() => {
dispose()
})
onUnmounted(() => {
props.client.offEvent('danmaku', onGetDanmaku)
props.client.offEvent('gift', onGetGift)
client.offEvent('danmaku', onGetDanmaku)
client.offEvent('gift', onGetGift)
dispose()
})
</script>

View File

@@ -3,8 +3,9 @@ import { copyToClipboard } from '@/Utils'
import { useAccount } from '@/api/account'
import { EventDataTypes, EventModel } from '@/api/api-models'
import { QueryGetAPI, QueryPostAPI } from '@/api/query'
import DanmakuClient, { RoomAuthInfo } from '@/data/DanmakuClient'
import { RoomAuthInfo } from '@/data/DanmakuClient'
import { FETCH_API, VTSURU_API_URL } from '@/data/constants'
import { useDanmakuClient } from '@/store/useDanmakuClient'
import { Info24Filled, Mic24Filled } from '@vicons/fluent'
import { useStorage } from '@vueuse/core'
import EasySpeech from 'easy-speech'
@@ -44,9 +45,8 @@ import { useRoute } from 'vue-router'
import { clearInterval, setInterval } from 'worker-timers'
const props = defineProps<{
client: DanmakuClient
roomInfo: RoomAuthInfo
code: string | undefined
roomInfo?: RoomAuthInfo
code?: string | undefined
isOpenLive?: boolean
}>()
@@ -74,6 +74,7 @@ type SpeechInfo = {
const accountInfo = useAccount()
const message = useMessage()
const route = useRoute()
const client = useDanmakuClient()
const settings = useStorage<SpeechSettings>('Setting.Speech', {
speechInfo: {
volume: 1,
@@ -110,6 +111,8 @@ const speechSynthesisInfo = ref<{
}>()
const languageDisplayName = new Intl.DisplayNames(['zh'], { type: 'language' })
const voiceOptions = computed(() => {
const status = EasySpeech.status()
if (status.status != 'init: complete') return []
return new List(EasySpeech.voices())
.Select((v) => {
return {
@@ -556,22 +559,23 @@ function testAPI() {
let speechQueueTimer: number
onMounted(() => {
EasySpeech.init({ maxTimeout: 5000, interval: 250 })
speechSynthesisInfo.value = EasySpeech.detect()
speechQueueTimer = setInterval(() => {
speak()
}, 250)
props.client.onEvent('danmaku', onGetEvent)
props.client.onEvent('sc', onGetEvent)
props.client.onEvent('guard', onGetEvent)
props.client.onEvent('gift', onGetEvent)
client.onEvent('danmaku', onGetEvent)
client.onEvent('sc', onGetEvent)
client.onEvent('guard', onGetEvent)
client.onEvent('gift', onGetEvent)
})
onUnmounted(() => {
clearInterval(speechQueueTimer)
props.client.offEvent('danmaku', onGetEvent)
props.client.offEvent('sc', onGetEvent)
props.client.offEvent('guard', onGetEvent)
props.client.offEvent('gift', onGetEvent)
client.offEvent('danmaku', onGetEvent)
client.offEvent('sc', onGetEvent)
client.offEvent('guard', onGetEvent)
client.offEvent('gift', onGetEvent)
})
</script>
@@ -594,37 +598,24 @@ onUnmounted(() => {
</NAlert>
<NAlert type="info" closeable>
当在后台运行时请关闭浏览器的 页面休眠/内存节省功能. Chrome:
<NButton
tag="a"
type="info"
<NButton tag="a" type="info"
href="https://support.google.com/chrome/answer/12929150?hl=zh-Hans#zippy=%2C%E5%BC%80%E5%90%AF%E6%88%96%E5%85%B3%E9%97%AD%E7%9C%81%E5%86%85%E5%AD%98%E6%A8%A1%E5%BC%8F%2C%E8%AE%A9%E7%89%B9%E5%AE%9A%E7%BD%91%E7%AB%99%E4%BF%9D%E6%8C%81%E6%B4%BB%E5%8A%A8%E7%8A%B6%E6%80%81"
target="_blank"
text
>
target="_blank" text>
让特定网站保持活动状态
</NButton>
Edge:
<NButton
tag="a"
type="info"
<NButton tag="a" type="info"
href="https://support.microsoft.com/zh-cn/topic/%E4%BA%86%E8%A7%A3-microsoft-edge-%E4%B8%AD%E7%9A%84%E6%80%A7%E8%83%BD%E5%8A%9F%E8%83%BD-7b36f363-2119-448a-8de6-375cfd88ab25"
target="_blank"
text
>
target="_blank" text>
永远不想进入睡眠状态的网站
</NButton>
</NAlert>
</NSpace>
<br />
<NSpace align="center">
<NButton
@click="canSpeech ? stopSpeech() : startSpeech()"
:type="canSpeech ? 'error' : 'primary'"
data-umami-event="Use TTS"
:data-umami-event-uid="accountInfo?.id"
size="large"
>
<NButton @click="canSpeech ? stopSpeech() : startSpeech()" :type="canSpeech ? 'error' : 'primary'"
data-umami-event="Use TTS" :data-umami-event-uid="accountInfo?.id" size="large">
{{ canSpeech ? '停止监听' : '开始监听' }}
</NButton>
<NButton @click="uploadConfig" type="primary" secondary :disabled="!accountInfo" size="small">
@@ -652,12 +643,8 @@ onUnmounted(() => {
</NTooltip>
<NTooltip v-else>
<template #trigger>
<NButton
circle
:disabled="!isSpeaking"
@click="cancelSpeech"
:style="`animation: ${isSpeaking ? 'animated-border 2.5s infinite;' : ''}`"
>
<NButton circle :disabled="!isSpeaking" @click="cancelSpeech"
:style="`animation: ${isSpeaking ? 'animated-border 2.5s infinite;' : ''}`">
<template #icon>
<NIcon :component="Mic24Filled" :color="isSpeaking ? 'green' : 'gray'" />
</template>
@@ -665,7 +652,9 @@ onUnmounted(() => {
</template>
{{ isSpeaking ? '取消朗读' : '未朗读' }}
</NTooltip>
<NText depth="3"> 队列: {{ speakQueue.length }} <NDivider vertical /> 已读: {{ readedDanmaku }} </NText>
<NText depth="3"> 队列: {{ speakQueue.length }}
<NDivider vertical /> 已读: {{ readedDanmaku }}
</NText>
<NCollapse :default-expanded-names="['1']">
<NCollapseItem title="队列" name="1">
<NEmpty v-if="speakQueue.length == 0"> 暂无 </NEmpty>
@@ -676,19 +665,11 @@ onUnmounted(() => {
<NButton @click="speakQueue.splice(speakQueue.indexOf(item), 1)" type="error" secondary size="small">
取消
</NButton>
<NTag
v-if="item.data.type == EventDataTypes.Gift && item.combineCount"
type="info"
size="small"
style="animation: animated-border 2.5s infinite"
>
连续赠送中</NTag
>
<NTag
v-else-if="item.data.type == EventDataTypes.Gift && settings.combineGiftDelay"
type="success"
size="small"
>
<NTag v-if="item.data.type == EventDataTypes.Gift && item.combineCount" type="info" size="small"
style="animation: animated-border 2.5s infinite">
连续赠送中</NTag>
<NTag v-else-if="item.data.type == EventDataTypes.Gift && settings.combineGiftDelay" type="success"
size="small">
等待连续赠送检查
</NTag>
<span>
@@ -727,11 +708,8 @@ onUnmounted(() => {
</NDivider>
<Transition name="fade" mode="out-in">
<NSpace v-if="settings.voiceType == 'local'" vertical>
<NSelect
v-model:value="settings.speechInfo.voice"
:options="voiceOptions"
:fallback-option="() => ({ label: '未选择, 将使用默认语音', value: '' })"
/>
<NSelect v-model:value="settings.speechInfo.voice" :options="voiceOptions"
:fallback-option="() => ({ label: '未选择, 将使用默认语音', value: '' })" />
<span style="width: 100%">
<NText> 音量 </NText>
<NSlider style="min-width: 200px" v-model:value="settings.speechInfo.volume" :min="0" :max="1" :step="0.01" />
@@ -792,19 +770,13 @@ onUnmounted(() => {
</NSpace>
<br />
<NInputGroup>
<NSelect
v-model:value="settings.voiceAPISchemeType"
:options="[
{ label: 'https://', value: 'https' },
{ label: 'http://', value: 'http' },
]"
style="width: 110px"
/>
<NInput
v-model:value="settings.voiceAPI"
<NSelect v-model:value="settings.voiceAPISchemeType" :options="[
{ label: 'https://', value: 'https' },
{ label: 'http://', value: 'http' },
]" style="width: 110px" />
<NInput v-model:value="settings.voiceAPI"
placeholder="API 地址, 例如 xxx.com/voice/bert-vits2?text={{text}}&id=0 (前面不要带https://)"
:status="/^(?:https?:\/\/)/.test(settings.voiceAPI?.toLowerCase() ?? '') ? 'error' : 'success'"
/>
:status="/^(?:https?:\/\/)/.test(settings.voiceAPI?.toLowerCase() ?? '') ? 'error' : 'success'" />
<NButton @click="testAPI" type="info" :loading="isApiAudioLoading"> 测试 </NButton>
</NInputGroup>
<br /><br />
@@ -824,23 +796,12 @@ onUnmounted(() => {
</NAlert>
<span style="width: 100%">
<NText> 音量 </NText>
<NSlider
style="min-width: 200px"
v-model:value="settings.speechInfo.volume"
:min="0"
:max="1"
:step="0.01"
/>
<NSlider style="min-width: 200px" v-model:value="settings.speechInfo.volume" :min="0" :max="1"
:step="0.01" />
</span>
</NSpace>
<audio
ref="apiAudio"
:src="apiAudioSrc"
:volume="settings.speechInfo.volume"
@ended="cancelSpeech"
@canplay="isApiAudioLoading = false"
@error="onAPIError"
></audio>
<audio ref="apiAudio" :src="apiAudioSrc" :volume="settings.speechInfo.volume" @ended="cancelSpeech"
@canplay="isApiAudioLoading = false" @error="onAPIError"></audio>
</div>
</template>
</Transition>
@@ -856,13 +817,8 @@ onUnmounted(() => {
<NSpace vertical>
<NSpace>
支持的变量:
<NButton
size="tiny"
secondary
v-for="item in Object.values(templateConstants)"
:key="item.name"
@click="copyToClipboard(item.words)"
>
<NButton size="tiny" secondary v-for="item in Object.values(templateConstants)" :key="item.name"
@click="copyToClipboard(item.words)">
{{ item.words }} | {{ item.name }}
</NButton>
</NSpace>
@@ -889,14 +845,10 @@ onUnmounted(() => {
</NSpace>
<NDivider> 设置 </NDivider>
<NSpace align="center">
<NCheckbox
:checked="settings.combineGiftDelay != undefined"
@update:checked="
(checked: boolean) => {
settings.combineGiftDelay = checked ? 2 : undefined
}
"
>
<NCheckbox :checked="settings.combineGiftDelay != undefined" @update:checked="(checked: boolean) => {
settings.combineGiftDelay = checked ? 2 : undefined
}
">
是否启用礼物合并
<NTooltip>
<template #trigger>
@@ -909,14 +861,10 @@ onUnmounted(() => {
</NCheckbox>
<NInputGroup v-if="settings.combineGiftDelay" style="width: 200px">
<NInputGroupLabel> 送礼间隔 () </NInputGroupLabel>
<NInputNumber
v-model:value="settings.combineGiftDelay"
@update:value="
(value) => {
if (!value || value <= 0) settings.combineGiftDelay = undefined
}
"
/>
<NInputNumber v-model:value="settings.combineGiftDelay" @update:value="(value) => {
if (!value || value <= 0) settings.combineGiftDelay = undefined
}
" />
</NInputGroup>
<NCheckbox v-model:checked="settings.splitText">
启用句子拆分