feat: 在QueueOBS组件中添加滚动速度控制功能

- 新增speedMultiplier属性以控制滚动速度
- 在OpenQueue组件中添加滚动速度输入框,允许用户设置速度并复制带速度的URL
- 优化了队列项的显示和动画效果
This commit is contained in:
2025-05-04 00:09:06 +08:00
parent 33d0c0c85f
commit aea5e825f6
2 changed files with 329 additions and 137 deletions

View File

@@ -8,20 +8,18 @@ import {
} from '@/api/api-models' } from '@/api/api-models'
import { QueryGetAPI } from '@/api/query' import { QueryGetAPI } from '@/api/query'
import { QUEUE_API_URL } from '@/data/constants' import { QUEUE_API_URL } from '@/data/constants'
import { MittType } from '@/mitt'
import { useWebRTC } from '@/store/useRTC' import { useWebRTC } from '@/store/useRTC'
import { useElementSize } from '@vueuse/core' import { useElementSize } from '@vueuse/core'
import { List } from 'linqts' import { List } from 'linqts'
import mitt from 'mitt'
import { NDivider, NEmpty, useMessage } from 'naive-ui' import { NDivider, NEmpty, useMessage } from 'naive-ui'
import { computed, onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { Vue3Marquee } from 'vue3-marquee'
const props = defineProps<{ const props = defineProps<{
id?: number, id?: number,
active?: boolean, active?: boolean,
visible?: boolean, visible?: boolean,
speedMultiplier?: number,
}>() }>()
const message = useMessage() const message = useMessage()
@@ -31,6 +29,15 @@ const currentId = computed(() => {
}) })
const rtc = await useWebRTC().Init('slave') const rtc = await useWebRTC().Init('slave')
const speedMultiplier = computed(() => {
if (props.speedMultiplier !== undefined && props.speedMultiplier > 0) {
return props.speedMultiplier
}
const speedParam = route.query.speed
const speed = parseFloat(speedParam?.toString() ?? '1')
return isNaN(speed) || speed <= 0 ? 1 : speed
})
const listContainerRef = ref() const listContainerRef = ref()
const footerRef = ref() const footerRef = ref()
const footerListRef = ref() const footerListRef = ref()
@@ -39,7 +46,17 @@ const footerSize = useElementSize(footerRef)
const footerListSize = useElementSize(footerListRef) const footerListSize = useElementSize(footerListRef)
const itemHeight = 40 const itemHeight = 40
const key = ref(Date.now()) const queueListInnerRef = ref<HTMLElement | null>(null)
const { height: innerListHeight } = useElementSize(queueListInnerRef)
const itemMarginBottom = 0
const totalContentHeightWithLastMargin = computed(() => {
const count = activeItems.value.length
if (count === 0 || innerListHeight.value <= 0) {
return 0
}
return innerListHeight.value + itemMarginBottom
})
const queue = ref<ResponseQueueModel[]>([]) const queue = ref<ResponseQueueModel[]>([])
const settings = ref<Setting_Queue>({} as Setting_Queue) const settings = ref<Setting_Queue>({} as Setting_Queue)
@@ -79,6 +96,9 @@ const activeItems = computed(() => {
list = list.OrderByDescending((q) => (q.status == QueueStatus.Progressing ? 1 : 0)) list = list.OrderByDescending((q) => (q.status == QueueStatus.Progressing ? 1 : 0))
return list.ToArray() return list.ToArray()
}) })
const itemNum = computed(() => {
return queue.value.length
})
async function get() { async function get() {
try { try {
@@ -94,9 +114,26 @@ async function get() {
} catch (err) { } } catch (err) { }
return {} as { queue: ResponseQueueModel[]; setting: Setting_Queue } return {} as { queue: ResponseQueueModel[]; setting: Setting_Queue }
} }
const isMoreThanContainer = computed(() => { const isMoreThanContainer = computed(() => {
return queue.value.length * itemHeight > height.value return totalContentHeightWithLastMargin.value > height.value
}) })
const animationTranslateY = computed(() => {
if (!isMoreThanContainer.value || height.value <= 0) {
return 0
}
return height.value - totalContentHeightWithLastMargin.value
})
const animationTranslateYCss = computed(() => `${animationTranslateY.value}px`)
const animationDuration = computed(() => {
const baseDuration = activeItems.value.length * 1
const adjustedDuration = baseDuration / speedMultiplier.value
return Math.max(adjustedDuration, 1)
})
const animationDurationCss = computed(() => `${animationDuration.value}s`)
const allowGuardTypes = computed(() => { const allowGuardTypes = computed(() => {
const types = [] const types = []
if (settings.value.needTidu) { if (settings.value.needTidu) {
@@ -174,13 +211,11 @@ onUnmounted(() => {
class="queue-content" class="queue-content"
> >
<template v-if="activeItems.length > 0"> <template v-if="activeItems.length > 0">
<Vue3Marquee <div
:key="key" ref="queueListInnerRef"
class="queue-list" class="queue-list"
vertical :class="{ animating: isMoreThanContainer }"
:pause="!isMoreThanContainer" :style="`width: ${width}px;`"
:duration="20"
:style="`height: ${height}px;width: ${width}px;`"
> >
<span <span
v-for="(item, index) in activeItems" v-for="(item, index) in activeItems"
@@ -198,31 +233,23 @@ onUnmounted(() => {
{{ index + 1 }} {{ index + 1 }}
</div> </div>
<div <div
v-if="settings.showFanMadelInfo" v-if="settings.showFanMadelInfo && (item.user?.fans_medal_level ?? 0) > 0"
class="queue-list-item-level" class="queue-list-item-level"
:has-level="(item.user?.fans_medal_level ?? 0) > 0" :has-level="(item.user?.fans_medal_level ?? 0) > 0"
> >
{{ `${item.user?.fans_medal_name} ${item.user?.fans_medal_level}` }} {{ `${item.user?.fans_medal_name || ''} ${item.user?.fans_medal_level || ''}` }}
</div> </div>
<div class="queue-list-item-user-name"> <div class="queue-list-item-user-name">
{{ item.user?.name }} {{ item.user?.name || '未知用户' }}
</div> </div>
<p <div
v-if="settings.showPayment" v-if="item.from == QueueFrom.Manual || ((item.giftPrice ?? 0) > 0 || settings.showPayment)"
class="queue-list-item-payment" class="queue-list-item-payment"
> >
{{ {{ item.from == QueueFrom.Manual ? '主播添加' : item.giftPrice == undefined ? '无' : '¥ ' + item.giftPrice }}
item.from == QueueFrom.Manual ? '主播添加' : item.giftPrice == undefined ? '无' : '¥ ' + item.giftPrice </div>
}}
</p>
</span> </span>
</div>
<NDivider
v-if="isMoreThanContainer"
class="queue-footer-divider"
style="margin: 10px 0 10px 0"
/>
</Vue3Marquee>
</template> </template>
<div <div
v-else v-else
@@ -239,64 +266,49 @@ onUnmounted(() => {
ref="footerRef" ref="footerRef"
class="queue-footer" class="queue-footer"
> >
<Vue3Marquee <div class="queue-footer-info">
:key="key" <div class="queue-footer-tags">
ref="footerListRef" <div
class="queue-footer-marquee" class="queue-footer-tag"
:pause="footerSize.width < footerListSize.width" type="keyword"
:duration="20"
> >
<span <span class="tag-label">关键词</span>
class="queue-tag" <span class="tag-value">{{ settings.keyword }}</span>
type="prefix"
>
<div class="queue-tag-key">关键词</div>
<div class="queue-tag-value">
{{ settings.keyword }}
</div> </div>
</span> <div
<span class="queue-footer-tag"
class="queue-tag" type="allow"
type="prefix"
> >
<div class="queue-tag-key">允许</div> <span class="tag-label">允许</span>
<div class="queue-tag-value"> <span class="tag-value">{{ settings.allowAllDanmaku ? '所有弹幕' : allowGuardTypes.length > 0 ? allowGuardTypes.join('/') : '无' }}</span>
{{ settings.allowAllDanmaku ? '所有弹幕' : allowGuardTypes.length > 0 ? allowGuardTypes.join(',') : '无' }}
</div> </div>
</span> <div
<span class="queue-footer-tag"
class="queue-tag"
type="gift" type="gift"
> >
<div class="queue-tag-key">通过礼物</div> <span class="tag-label">礼物</span>
<div class="queue-tag-value"> <span class="tag-value">{{ settings.allowGift ? '允许' : '不允许' }}</span>
{{ settings.allowGift ? '允许' : '不允许' }}
</div> </div>
</span> <div
<span class="queue-footer-tag"
class="queue-tag" type="price"
type="gift-price"
> >
<div class="queue-tag-key">最低价格</div> <span class="tag-label">最低价格</span>
<div class="queue-tag-value"> <span class="tag-value">{{ settings.minGiftPrice ? '> ¥' + settings.minGiftPrice : '任意' }}</span>
{{ settings.minGiftPrice ? '> ¥' + settings.minGiftPrice : '任意' }}
</div> </div>
</span> <div
<span class="queue-footer-tag"
class="queue-tag" type="gift-names"
type="gift-type"
> >
<div class="queue-tag-key">礼物名</div> <span class="tag-label">礼物名</span>
<div class="queue-tag-value"> <span class="tag-value">{{ settings.giftNames ? settings.giftNames.join(', ') : '无' }}</span>
{{ settings.giftNames ? settings.giftNames.join(', ') : '无' }}
</div> </div>
</span> <div
<span class="queue-footer-tag"
class="queue-tag" type="medal"
type="fan-madel"
> >
<div class="queue-tag-key">粉丝牌</div> <span class="tag-label">粉丝牌</span>
<div class="queue-tag-value"> <span class="tag-value">
{{ {{
settings.fanMedalMinLevel != undefined && !settings.allowAllDanmaku settings.fanMedalMinLevel != undefined && !settings.allowAllDanmaku
? settings.fanMedalMinLevel > 0 ? settings.fanMedalMinLevel > 0
@@ -304,9 +316,62 @@ onUnmounted(() => {
: '佩戴' : '佩戴'
: '无需' : '无需'
}} }}
</div>
</span> </span>
</Vue3Marquee> </div>
<!-- 重复标签组,实现无缝滚动 -->
<div
class="queue-footer-tag"
type="keyword"
>
<span class="tag-label">关键词</span>
<span class="tag-value">{{ settings.keyword }}</span>
</div>
<div
class="queue-footer-tag"
type="allow"
>
<span class="tag-label">允许</span>
<span class="tag-value">{{ settings.allowAllDanmaku ? '所有弹幕' : allowGuardTypes.length > 0 ? allowGuardTypes.join('/') : '无' }}</span>
</div>
<div
class="queue-footer-tag"
type="gift"
>
<span class="tag-label">礼物</span>
<span class="tag-value">{{ settings.allowGift ? '允许' : '不允许' }}</span>
</div>
<div
class="queue-footer-tag"
type="price"
>
<span class="tag-label">最低价格</span>
<span class="tag-value">{{ settings.minGiftPrice ? '> ¥' + settings.minGiftPrice : '任意' }}</span>
</div>
<div
v-if="settings.giftNames && settings.giftNames.length > 0"
class="queue-footer-tag"
type="gift-names"
>
<span class="tag-label">礼物名</span>
<span class="tag-value">{{ settings.giftNames ? settings.giftNames.join(', ') : '无' }}</span>
</div>
<div
class="queue-footer-tag"
type="medal"
>
<span class="tag-label">粉丝牌</span>
<span class="tag-value">
{{
settings.fanMedalMinLevel != undefined && !settings.allowAllDanmaku
? settings.fanMedalMinLevel > 0
? '> ' + settings.fanMedalMinLevel
: '佩戴'
: '无需'
}}
</span>
</div>
</div>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -383,11 +448,9 @@ onUnmounted(() => {
.queue-singing-avatar { .queue-singing-avatar {
height: 30px; height: 30px;
border-radius: 50%; border-radius: 50%;
/* 添加无限旋转动画 */
animation: rotate 20s linear infinite; animation: rotate 20s linear infinite;
} }
/* 网页点歌 */
.queue-singing-container[from='3'] .queue-singing-avatar { .queue-singing-container[from='3'] .queue-singing-avatar {
display: none; display: none;
} }
@@ -417,50 +480,62 @@ onUnmounted(() => {
.queue-content { .queue-content {
background-color: #0f0f0f4f; background-color: #0f0f0f4f;
margin: 10px; margin: 10px;
padding: 10px; padding: 8px;
height: 100%; height: 100%;
border-radius: 10px; border-radius: 10px;
overflow-x: hidden; overflow: hidden;
} }
.marquee { .queue-list {
justify-items: left; width: 100%;
overflow: hidden;
position: relative;
}
@keyframes vertical-ping-pong {
0% {
transform: translateY(0);
}
100% {
transform: translateY(v-bind(animationTranslateYCss));
}
}
.queue-list.animating {
animation-name: vertical-ping-pong;
animation-duration: v-bind(animationDurationCss);
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
animation-direction: alternate;
pointer-events: auto;
}
.queue-list.animating:hover {
animation-play-state: paused;
} }
.queue-list-item { .queue-list-item {
display: flex; display: flex;
width: 100%;
align-self: flex-start; align-self: flex-start;
position: relative; position: relative;
align-items: center; align-items: center;
justify-content: left; justify-content: left;
gap: 10px; gap: 5px;
padding: 4px 6px;
margin-bottom: 5px;
background-color: rgba(0, 0, 0, 0.2);
border-radius: 6px;
min-height: 36px;
} }
.queue-list-item-user-name { .queue-list-item-user-name {
font-size: 18px; font-size: 16px;
font-weight: bold; font-weight: bold;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
max-width: 80%; max-width: 50%;
} flex-grow: 1;
/* 手动添加 */
.queue-list-item[from='0'] .queue-list-item-payment {
font-style: italic;
font-weight: bold;
color: #d2d8d6;
font-size: 12px;
}
.queue-list-item[from='0'] .queue-list-item-avatar {
display: none;
}
/* 弹幕点歌 */
.queue-list-item[payment='0'] .queue-list-item-payment {
display: none;
} }
.queue-list-item-payment { .queue-list-item-payment {
@@ -469,8 +544,23 @@ onUnmounted(() => {
color: rgba(233, 165, 165, 0.993); color: rgba(233, 165, 165, 0.993);
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
margin-left: auto; margin-left: auto;
background-color: rgba(0, 0, 0, 0.2);
padding: 2px 6px;
border-radius: 4px;
min-width: 50px;
text-align: center;
}
.queue-list-item-level {
text-align: center;
height: 18px;
padding: 2px 6px;
min-width: 15px;
border-radius: 5px;
background-color: rgba(0, 0, 0, 0.3);
color: rgba(204, 204, 204, 0.993);
font-size: 12px;
} }
.queue-list-item-index { .queue-list-item-index {
@@ -518,36 +608,103 @@ onUnmounted(() => {
display: none; display: none;
} }
/* 底部信息区域样式优化 */
.queue-footer { .queue-footer {
margin: 0 5px 5px 5px; margin: 0 5px 5px 5px;
height: 60px; background-color: rgba(0, 0, 0, 0.25);
border-radius: 5px; border-radius: 8px;
background-color: #0f0f0f4f; padding: 8px 6px;
overflow: hidden;
height: auto;
min-height: 40px;
max-height: 60px;
display: flex; display: flex;
align-items: center; align-items: center;
} }
.queue-tag { .queue-footer-info {
display: flex; width: 100%;
margin: 5px 0 5px 5px; overflow: hidden;
height: 40px; position: relative;
border-radius: 3px; }
background-color: #0f0f0f4f;
padding: 4px; .queue-footer-tags {
padding-right: 6px; display: inline-flex;
display: flex; flex-wrap: nowrap;
gap: 8px;
padding: 2px;
white-space: nowrap;
animation: scrollTags 25s linear infinite;
padding-right: 16px; /* 确保最后一个标签有足够间距 */
}
@keyframes scrollTags {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-50%); /* 移动一半距离,因为我们复制了标签 */
}
}
.queue-footer-tags:hover {
animation-play-state: paused;
}
.queue-footer-tag {
display: inline-flex;
flex-direction: column; flex-direction: column;
justify-content: left; padding: 5px 8px;
border-radius: 6px;
background-color: rgba(255, 255, 255, 0.12);
min-width: max-content;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition: transform 0.2s ease, box-shadow 0.2s ease;
} }
.queue-tag-key { .queue-footer-tag[type="keyword"] {
font-style: italic; background: linear-gradient(135deg, rgba(59, 130, 246, 0.12), rgba(37, 99, 235, 0.18));
color: rgb(211, 211, 211); }
.queue-footer-tag[type="allow"] {
background: linear-gradient(135deg, rgba(16, 185, 129, 0.12), rgba(5, 150, 105, 0.18));
}
.queue-footer-tag[type="gift"] {
background: linear-gradient(135deg, rgba(244, 114, 182, 0.12), rgba(219, 39, 119, 0.18));
}
.queue-footer-tag[type="price"] {
background: linear-gradient(135deg, rgba(251, 191, 36, 0.12), rgba(245, 158, 11, 0.18));
}
.queue-footer-tag[type="gift-names"] {
background: linear-gradient(135deg, rgba(139, 92, 246, 0.12), rgba(124, 58, 237, 0.18));
}
.queue-footer-tag[type="medal"] {
background: linear-gradient(135deg, rgba(239, 68, 68, 0.12), rgba(220, 38, 38, 0.18));
}
.queue-footer-tag:hover {
transform: translateY(-1px);
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.15);
}
.tag-label {
font-size: 10px;
opacity: 0.8;
color: #e5e7eb;
font-weight: normal;
margin-bottom: 2px;
line-height: 1;
}
.tag-value {
font-size: 12px; font-size: 12px;
} font-weight: 600;
color: white;
.queue-tag-value { line-height: 1.2;
font-size: 14px;
} }
@keyframes animated-border { @keyframes animated-border {
@@ -559,4 +716,19 @@ onUnmounted(() => {
box-shadow: 0 0 0 6px rgba(255, 255, 255, 0); box-shadow: 0 0 0 6px rgba(255, 255, 255, 0);
} }
} }
.queue-list-item[from='0'] .queue-list-item-payment {
font-style: italic;
font-weight: bold;
color: #d2d8d6;
font-size: 12px;
}
.queue-list-item[from='0'] .queue-list-item-avatar {
display: none;
}
.queue-list-item[payment='0'] .queue-list-item-payment {
display: none;
}
</style> </style>

View File

@@ -125,6 +125,7 @@ const isReverse = useStorage('Queue.Settings.Reverse', false); // 本地存储
const isLoading = ref(false); // 加载状态 const isLoading = ref(false); // 加载状态
const showOBSModal = ref(false); // OBS 组件模态框显示状态 const showOBSModal = ref(false); // OBS 组件模态框显示状态
const obsScrollSpeed = ref(1.0); // OBS 组件滚动速度
const filterName = ref(''); // 历史记录筛选用户名 const filterName = ref(''); // 历史记录筛选用户名
const filterNameContains = ref(false); // 历史记录筛选是否包含 const filterNameContains = ref(false); // 历史记录筛选是否包含
@@ -1862,6 +1863,24 @@ function getIndexStyle(status: QueueStatus): CSSProperties {
复制 复制
</NButton> </NButton>
</NInputGroup> </NInputGroup>
<!-- 添加速度控制 -->
<NInputGroup style="margin-bottom: 15px;">
<NInputGroupLabel>滚动速度</NInputGroupLabel>
<NInputNumber
v-model:value="obsScrollSpeed"
:min="0.5"
:max="5"
:step="0.1"
placeholder="默认1.0"
/>
<NButton
type="primary"
ghost
@click="copyToClipboard(`${CURRENT_HOST}obs/queue?id=${accountInfo?.id}&speed=${obsScrollSpeed}`)"
>
复制带速度URL
</NButton>
</NInputGroup>
<NDivider> 预览 (尺寸可能与实际不同) </NDivider> <NDivider> 预览 (尺寸可能与实际不同) </NDivider>
<div <div
style="height: 450px; width: 280px; position: relative; margin: 0 auto; border: 1px dashed #ccc; overflow: hidden;" style="height: 450px; width: 280px; position: relative; margin: 0 auto; border: 1px dashed #ccc; overflow: hidden;"
@@ -1869,6 +1888,7 @@ function getIndexStyle(status: QueueStatus): CSSProperties {
<QueueOBS <QueueOBS
v-if="accountInfo?.id" v-if="accountInfo?.id"
:id="accountInfo.id" :id="accountInfo.id"
:speed-multiplier="obsScrollSpeed"
/> />
<NEmpty <NEmpty
v-else v-else