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

@@ -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
}
}