mirror of
https://github.com/Megghy/vtsuru.live.git
synced 2025-12-07 02:46:55 +08:00
Compare commits
2 Commits
55d3b31146
...
dfc1a9d709
| Author | SHA1 | Date | |
|---|---|---|---|
| dfc1a9d709 | |||
| e81f09514f |
@@ -252,6 +252,10 @@ export interface Setting_Point {
|
||||
dailyFirstGiftPoints: number // 每日首次礼物积分(固定积分)
|
||||
useDailyFirstGiftPercent: boolean // 是否使用礼物价值比例计算
|
||||
dailyFirstGiftPercent: number // 每日首次礼物价值比例
|
||||
|
||||
// 仅开播时生效设置
|
||||
dailyFirstOnlyOnStreaming: boolean
|
||||
checkInOnlyOnStreaming: boolean
|
||||
}
|
||||
export interface Setting_QuestionDisplay {
|
||||
font?: string // Optional string, with a maximum length of 30 characters
|
||||
|
||||
@@ -65,6 +65,8 @@ const defaultPointSetting: Setting_Point = {
|
||||
dailyFirstGiftPoints: 10,
|
||||
useDailyFirstGiftPercent: false,
|
||||
dailyFirstGiftPercent: 0.1,
|
||||
dailyFirstOnlyOnStreaming: false,
|
||||
checkInOnlyOnStreaming: false,
|
||||
}
|
||||
const serverSetting = computed<Setting_Point>(() => {
|
||||
return (accountInfo.value?.settings?.point ?? defaultPointSetting)
|
||||
|
||||
@@ -3,6 +3,31 @@ import { NButton, NImage } from 'naive-ui'
|
||||
import UpdateNoteContainer from '@/components/UpdateNoteContainer.vue'
|
||||
|
||||
export const updateNotes: updateNoteType[] = [
|
||||
{
|
||||
ver: 8,
|
||||
date: '2025.10.16',
|
||||
items: [
|
||||
{
|
||||
type: 'new',
|
||||
title: '点播OBS组件新增简洁样式',
|
||||
content: [
|
||||
[
|
||||
'点播OBS组件新增无背景的简洁样式',
|
||||
() => h(NImage, { src: 'https://files.vtsuru.suki.club/updatelog/7c8eab68-43d1-4a93-b927-57ebcdda0e5e.png', width: 300 }),
|
||||
],
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'new',
|
||||
title: '积分增加每日首次互动(发送弹幕/礼物)给予积分的功能',
|
||||
content: [
|
||||
[
|
||||
'积分增加每日首次互动(发送弹幕/礼物)给予积分的功能',
|
||||
],
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
ver: 7,
|
||||
date: '2025.5.1',
|
||||
|
||||
@@ -58,6 +58,8 @@ const defaultSettingPoint: Setting_Point = {
|
||||
dailyFirstGiftPoints: 10,
|
||||
useDailyFirstGiftPercent: false,
|
||||
dailyFirstGiftPercent: 0.1,
|
||||
dailyFirstOnlyOnStreaming: false,
|
||||
checkInOnlyOnStreaming: false,
|
||||
}
|
||||
|
||||
// 响应式设置对象
|
||||
@@ -301,8 +303,17 @@ async function SaveComboSetting() {
|
||||
礼物
|
||||
</NCheckbox>
|
||||
</NCheckboxGroup>
|
||||
|
||||
</NFlex>
|
||||
|
||||
<NCheckbox
|
||||
v-model:checked="setting.checkInOnlyOnStreaming"
|
||||
:disabled="!canEdit"
|
||||
@update:checked="updateSettings"
|
||||
>
|
||||
仅开播时允许签到
|
||||
</NCheckbox>
|
||||
|
||||
<!-- 舰长设置区域 -->
|
||||
<template v-if="setting.allowType.includes(EventDataTypes.Guard)">
|
||||
<NDivider>上舰设置</NDivider>
|
||||
@@ -536,6 +547,19 @@ async function SaveComboSetting() {
|
||||
</NButton>
|
||||
</NInputGroup>
|
||||
</template>
|
||||
|
||||
<NFlex
|
||||
align="center"
|
||||
:gap="12"
|
||||
>
|
||||
<NCheckbox
|
||||
v-model:checked="setting.dailyFirstOnlyOnStreaming"
|
||||
:disabled="!canEdit"
|
||||
@update:checked="updateSettings"
|
||||
>
|
||||
仅开播时生效
|
||||
</NCheckbox>
|
||||
</NFlex>
|
||||
</NFlex>
|
||||
|
||||
<!-- 礼物设置区域 -->
|
||||
@@ -779,6 +803,7 @@ async function SaveComboSetting() {
|
||||
</NModal>
|
||||
</template>
|
||||
</NFlex>
|
||||
<NDivider />
|
||||
</NSpin>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -3,13 +3,14 @@ import { computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import ClassicRequestOBS from './live-request/ClassicRequestOBS.vue'
|
||||
import FreshRequestOBS from './live-request/FreshRequestOBS.vue'
|
||||
import MinimalRequestOBS from './live-request/MinimalRequestOBS.vue'
|
||||
import { useOBSNotification } from '@/store/useOBSNotification'
|
||||
|
||||
const props = defineProps<{
|
||||
id?: number
|
||||
active?: boolean
|
||||
visible?: boolean
|
||||
style?: 'classic' | 'fresh'
|
||||
style?: 'classic' | 'fresh' | 'minimal'
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
@@ -40,6 +41,13 @@ onMounted(() => {
|
||||
v-bind="$attrs"
|
||||
/>
|
||||
<FreshRequestOBS
|
||||
v-else-if="styleType === 'fresh'"
|
||||
:id="currentId"
|
||||
:active="active"
|
||||
:visible="visible"
|
||||
v-bind="$attrs"
|
||||
/>
|
||||
<MinimalRequestOBS
|
||||
v-else
|
||||
:id="currentId"
|
||||
:active="active"
|
||||
|
||||
@@ -129,7 +129,7 @@ onUnmounted(() => {
|
||||
v-else
|
||||
class="live-request-processing-empty"
|
||||
>
|
||||
暂无
|
||||
暂无处理中项目
|
||||
</div>
|
||||
<div class="live-request-processing-suffix" />
|
||||
</div>
|
||||
|
||||
@@ -145,7 +145,7 @@ onUnmounted(() => {
|
||||
v-else
|
||||
class="fresh-request-no-song"
|
||||
>
|
||||
当前暂无演唱
|
||||
当前暂无项目
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
373
src/views/obs/live-request/MinimalRequestOBS.vue
Normal file
373
src/views/obs/live-request/MinimalRequestOBS.vue
Normal file
@@ -0,0 +1,373 @@
|
||||
<script setup lang="ts">
|
||||
import { useElementSize } from '@vueuse/core'
|
||||
import { NEmpty } from 'naive-ui'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { SongRequestFrom } from '@/api/api-models'
|
||||
import { useLiveRequestData } from './useLiveRequestData'
|
||||
|
||||
const props = defineProps<{
|
||||
id?: number
|
||||
active?: boolean
|
||||
visible?: boolean
|
||||
speedMultiplier?: number
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
const currentId = computed(() => {
|
||||
return props.id ?? route.query.id
|
||||
})
|
||||
|
||||
const speedMultiplier = computed(() => {
|
||||
if (props.speedMultiplier !== undefined && props.speedMultiplier > 0) {
|
||||
return props.speedMultiplier
|
||||
}
|
||||
const speedParam = route.query.speed
|
||||
const speed = Number.parseFloat(speedParam?.toString() ?? '1')
|
||||
return isNaN(speed) || speed <= 0 ? 1 : speed
|
||||
})
|
||||
|
||||
const {
|
||||
songs,
|
||||
settings,
|
||||
singing,
|
||||
activeSongs,
|
||||
allowGuardTypes,
|
||||
key,
|
||||
update,
|
||||
initRTC,
|
||||
} = useLiveRequestData(currentId.value?.toString() ?? '')
|
||||
|
||||
const listContainerRef = ref()
|
||||
const listInnerRef = ref<HTMLElement | null>(null)
|
||||
const { height, width } = useElementSize(listContainerRef)
|
||||
const { height: innerListHeight } = useElementSize(listInnerRef)
|
||||
const itemMarginBottom = 6
|
||||
|
||||
const totalContentHeightWithLastMargin = computed(() => {
|
||||
const count = activeSongs.value.length
|
||||
if (count === 0 || innerListHeight.value <= 0) {
|
||||
return 0
|
||||
}
|
||||
return innerListHeight.value + itemMarginBottom
|
||||
})
|
||||
|
||||
const isMoreThanContainer = computed(() => 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 = activeSongs.value.length * 1
|
||||
const adjustedDuration = baseDuration / speedMultiplier.value
|
||||
return Math.max(adjustedDuration, 1)
|
||||
})
|
||||
const animationDurationCss = computed(() => `${animationDuration.value}s`)
|
||||
|
||||
onMounted(() => {
|
||||
update()
|
||||
initRTC()
|
||||
window.$mitt.on('onOBSComponentUpdate', () => {
|
||||
update()
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.$mitt.off('onOBSComponentUpdate')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="minimal-container"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<div
|
||||
class="minimal-now"
|
||||
:class="{ playing: singing }"
|
||||
>
|
||||
<span
|
||||
class="minimal-indicator"
|
||||
:class="{ 'is-playing': !!singing }"
|
||||
aria-label="now-playing-indicator"
|
||||
/>
|
||||
<div class="minimal-now-title">
|
||||
{{ singing ? singing.songName : '空闲' }}
|
||||
</div>
|
||||
<div
|
||||
v-if="singing && settings.showUserName"
|
||||
class="minimal-now-user"
|
||||
>
|
||||
<img
|
||||
v-if="singing.user?.face && singing.from !== SongRequestFrom.Manual"
|
||||
class="minimal-avatar"
|
||||
:src="singing.user.face"
|
||||
referrerpolicy="no-referrer"
|
||||
>
|
||||
<span class="minimal-user-name">{{ singing.from === SongRequestFrom.Manual ? '主播' : singing.user?.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref="listContainerRef"
|
||||
class="minimal-list-container"
|
||||
>
|
||||
<template v-if="activeSongs.length > 0">
|
||||
<div
|
||||
ref="listInnerRef"
|
||||
class="minimal-list-inner"
|
||||
:class="{ animating: isMoreThanContainer }"
|
||||
:style="`width: ${width}px; --item-parent-width: ${width}px`"
|
||||
>
|
||||
<TransitionGroup
|
||||
name="minimal-transition"
|
||||
tag="div"
|
||||
class="minimal-list"
|
||||
>
|
||||
<div
|
||||
v-for="(song, index) in activeSongs"
|
||||
:key="song.id"
|
||||
class="minimal-item"
|
||||
>
|
||||
<div
|
||||
class="minimal-index"
|
||||
:class="[`rank-${index + 1}`, { 'rank-top-3': index < 3 }]"
|
||||
>
|
||||
{{ index + 1 }}
|
||||
</div>
|
||||
<div class="minimal-content">
|
||||
<div
|
||||
class="minimal-name"
|
||||
:title="song.songName"
|
||||
>
|
||||
{{ song.songName || '未知歌曲' }}
|
||||
</div>
|
||||
<div class="minimal-meta">
|
||||
<span
|
||||
v-if="settings.showUserName"
|
||||
class="minimal-requester"
|
||||
>
|
||||
{{ song.from === SongRequestFrom.Manual ? '主播' : song.user?.name }}
|
||||
</span>
|
||||
<span
|
||||
v-if="settings.showFanMadelInfo && (song.user?.fans_medal_level ?? 0) > 0"
|
||||
class="minimal-medal"
|
||||
>
|
||||
{{ song.user?.fans_medal_name }} {{ song.user?.fans_medal_level }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</template>
|
||||
<NEmpty
|
||||
v-else
|
||||
class="minimal-empty"
|
||||
description="暂无人点歌"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.minimal-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
color: #fff;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.minimal-now {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 4px 6px 4px;
|
||||
}
|
||||
|
||||
.minimal-indicator {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: rgba(148, 163, 184, 0.9); /* idle gray */
|
||||
box-shadow: 0 0 6px rgba(0, 0, 0, 0.6);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.minimal-indicator.is-playing {
|
||||
background: #22c55e; /* green */
|
||||
box-shadow: 0 0 8px rgba(34, 197, 94, 0.9);
|
||||
animation: minimal-breathe 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes minimal-breathe {
|
||||
0%, 100% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.35); opacity: 0.85; }
|
||||
}
|
||||
|
||||
.minimal-now-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.8);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 75%;
|
||||
}
|
||||
|
||||
.minimal-badge {
|
||||
margin-left: 4px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
color: #eafff2;
|
||||
border: 1px solid rgba(34, 197, 94, 0.8);
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
text-shadow: 0 2px 6px rgba(0, 0, 0, 0.6);
|
||||
box-shadow: 0 0 10px rgba(34, 197, 94, 0.35);
|
||||
}
|
||||
|
||||
.minimal-now.playing .minimal-now-title {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.minimal-now-user {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
opacity: 0.9;
|
||||
text-shadow: 0 2px 6px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.minimal-avatar {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.minimal-list-container {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.minimal-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.minimal-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.minimal-index {
|
||||
min-width: 18px;
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
text-shadow: 0 2px 6px rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
|
||||
/* Top 3 rank highlight (minimal, no background panel) */
|
||||
.minimal-index.rank-top-3 {
|
||||
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.9));
|
||||
}
|
||||
.minimal-index.rank-1 {
|
||||
color: #fcd34d; /* gold */
|
||||
text-shadow:
|
||||
0 0 6px rgba(252, 211, 77, 0.9),
|
||||
0 0 14px rgba(252, 211, 77, 0.6),
|
||||
0 2px 6px rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
.minimal-index.rank-2 {
|
||||
color: #cbd5e1; /* silver */
|
||||
text-shadow:
|
||||
0 0 6px rgba(203, 213, 225, 0.9),
|
||||
0 0 14px rgba(203, 213, 225, 0.5),
|
||||
0 2px 6px rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
.minimal-index.rank-3 {
|
||||
color: #d97706; /* bronze */
|
||||
text-shadow:
|
||||
0 0 6px rgba(217, 119, 6, 0.9),
|
||||
0 0 14px rgba(217, 119, 6, 0.5),
|
||||
0 2px 6px rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
|
||||
.minimal-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.minimal-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
|
||||
.minimal-meta {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
opacity: 0.95;
|
||||
text-shadow: 0 2px 6px rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
|
||||
.minimal-requester,
|
||||
.minimal-medal {
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.minimal-empty {
|
||||
color: #fff;
|
||||
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
.minimal-transition-enter-active,
|
||||
.minimal-transition-leave-active {
|
||||
transition: opacity 0.25s ease, transform 0.25s ease;
|
||||
}
|
||||
.minimal-transition-enter-from,
|
||||
.minimal-transition-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
@keyframes vertical-ping-pong {
|
||||
0% { transform: translateY(0); }
|
||||
100% { transform: translateY(v-bind(animationTranslateYCss)); }
|
||||
}
|
||||
|
||||
.minimal-list-inner {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.minimal-list-inner.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;
|
||||
}
|
||||
|
||||
.minimal-list-inner.animating:hover {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
</style>
|
||||
@@ -60,7 +60,7 @@ const client = await useDanmakuClient().initOpenlive()
|
||||
|
||||
// OBS相关设置
|
||||
const showOBSModal = ref(false)
|
||||
const obsStyleType = ref<'classic' | 'fresh'>('classic')
|
||||
const obsStyleType = ref<'classic' | 'fresh' | 'minimal'>('classic')
|
||||
const obsScrollSpeedMultiplierRef = ref(1)
|
||||
const volumn = useStorage('Settings.Volumn', 0.5)
|
||||
|
||||
@@ -276,6 +276,16 @@ onUnmounted(() => {
|
||||
preset="card"
|
||||
style="width: 800px"
|
||||
>
|
||||
<template #header-extra>
|
||||
<NButton
|
||||
tag="a"
|
||||
type="primary"
|
||||
target="_blank"
|
||||
:href="`${CURRENT_HOST}obs/live-request?id=${accountInfo?.id ?? 0}&style=${obsStyleType}&speed=${obsScrollSpeedMultiplierRef}`"
|
||||
>
|
||||
浏览
|
||||
</NButton>
|
||||
</template>
|
||||
<NAlert
|
||||
title="这是什么? "
|
||||
type="info"
|
||||
@@ -296,6 +306,9 @@ onUnmounted(() => {
|
||||
<NRadioButton value="fresh">
|
||||
清新明亮风格
|
||||
</NRadioButton>
|
||||
<NRadioButton value="minimal">
|
||||
极简无背景
|
||||
</NRadioButton>
|
||||
</NSpace>
|
||||
</NRadioGroup>
|
||||
<NInputGroup style="width: 200px">
|
||||
|
||||
Reference in New Issue
Block a user