1. 音乐播放模式
This commit is contained in:
@@ -523,11 +523,12 @@
|
||||
<div class="flex flex-wrap items-start justify-between gap-3 mb-5">
|
||||
<div>
|
||||
<div class="text-sm text-[#2c2c2c] tracking-wide">曲目列表</div>
|
||||
<p class="text-[11px] text-[#8A8680] mt-1 leading-relaxed max-w-md">
|
||||
访客首次交互后自动播放当前曲目。开启随机后,24 小时内本地缓存同一首。
|
||||
<p class="text-[11px] text-[#8A8680] mt-1 leading-relaxed max-w-xl">
|
||||
开启随机后按<strong>权重</strong>抽曲,播完自动再随机下一首(不单曲循环)。
|
||||
可开启「记忆曲目」让同一访客在设定时长内首次打开仍播同一首。
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex flex-wrap items-center gap-3 justify-end">
|
||||
<label class="inline-flex items-center gap-2 text-[12px] text-[#5c5348] cursor-pointer select-none">
|
||||
<a-switch v-model:checked="cfg.musicRandomEnabled" size="small" />
|
||||
随机播放
|
||||
@@ -540,6 +541,34 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="cfg.musicRandomEnabled"
|
||||
class="mb-4 flex flex-wrap items-center gap-x-4 gap-y-2 rounded-xl border border-[#e7e3db] bg-[#faf8f5] px-3 py-2.5"
|
||||
>
|
||||
<label class="inline-flex items-center gap-2 text-[12px] text-[#5c5348] cursor-pointer select-none">
|
||||
<a-switch v-model:checked="cfg.musicCacheEnabled" size="small" />
|
||||
记忆曲目
|
||||
</label>
|
||||
<div class="inline-flex items-center gap-2 text-[12px] text-[#5c5348]">
|
||||
<span :class="{ 'opacity-40': !cfg.musicCacheEnabled }">记忆时长</span>
|
||||
<a-input-number
|
||||
v-model:value="cfg.musicCacheHours"
|
||||
:min="0.5"
|
||||
:max="720"
|
||||
:step="0.5"
|
||||
size="small"
|
||||
class="!w-[96px]"
|
||||
:disabled="!cfg.musicCacheEnabled"
|
||||
/>
|
||||
<span :class="{ 'opacity-40': !cfg.musicCacheEnabled }" class="text-[#8A8680]">小时</span>
|
||||
</div>
|
||||
<span class="text-[10px] text-[#8A8680]">
|
||||
{{ cfg.musicCacheEnabled
|
||||
? `同一设备 ${cfg.musicCacheHours || 24} 小时内首次打开沿用上次抽中的歌;播完仍会换下一首。`
|
||||
: '关闭后每次打开页面都会重新按权重抽曲。' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="!cfg.musicList.length" class="admin-empty">
|
||||
<div class="admin-empty-icon"><i class="fa-solid fa-music"></i></div>
|
||||
<div class="text-[13px] text-[#5c5348]">尚未上传音乐</div>
|
||||
@@ -585,6 +614,26 @@
|
||||
>正在播放</span>
|
||||
</div>
|
||||
<div class="music-chips">
|
||||
<label
|
||||
class="music-chip music-chip--weight"
|
||||
:class="{ 'opacity-40': !cfg.musicRandomEnabled }"
|
||||
:title="cfg.musicRandomEnabled ? '随机权重,越大越容易抽到' : '开启随机播放后生效'"
|
||||
>
|
||||
<i class="fa-solid fa-scale-balanced"></i>
|
||||
<span>权重</span>
|
||||
<a-input-number
|
||||
v-model:value="t.weight"
|
||||
:min="1"
|
||||
:max="1000"
|
||||
:step="1"
|
||||
size="small"
|
||||
class="music-weight-input"
|
||||
:disabled="!cfg.musicRandomEnabled"
|
||||
/>
|
||||
<span v-if="cfg.musicRandomEnabled" class="music-weight-pct">
|
||||
{{ musicWeightPercent(t) }}%
|
||||
</span>
|
||||
</label>
|
||||
<button type="button" class="music-chip" @click="openLrcUpload(t)">
|
||||
<i class="fa-solid fa-file-lines"></i>
|
||||
{{ t.lrcUrl ? '已有歌词' : '上传歌词' }}
|
||||
@@ -1537,6 +1586,7 @@ import MediaPickerModal from '@/components/MediaPickerModal.vue'
|
||||
import HotelWelcomePosterAdmin from '@/views/admin/HotelWelcomePosterAdmin.vue'
|
||||
import { BORDER_STYLES, borderClass } from '@/composables/borderStyles'
|
||||
import { resolveDanmakuSwatch } from '@/utils/danmakuColors'
|
||||
import { normalizeMusicWeight, normalizeMusicCacheHours } from '@/utils/musicPick'
|
||||
import { GIFT_TYPES, GIFT_LABEL_MAP, emptyGiftCosts, DEFAULT_GIFT_COSTS } from '@/utils/giftTypes'
|
||||
import {
|
||||
getSlotResizeSpecs,
|
||||
@@ -1626,7 +1676,7 @@ const SECTION_KEYS = {
|
||||
'introExitEffect', 'scrollMode', 'freeScrollInterval', 'pageSwitchDuration', 'firstScreenDuration',
|
||||
'petalEnabled', 'bubbleEnabled', 'introEnabled', 'danmakuEnabled', 'danmakuShowTime', 'nineGridAnimate',
|
||||
],
|
||||
music: ['musicList', 'activeMusicUrl', 'musicRandomEnabled'],
|
||||
music: ['musicList', 'activeMusicUrl', 'musicRandomEnabled', 'musicCacheEnabled', 'musicCacheHours'],
|
||||
photos: ['photoPages'],
|
||||
schedule: ['schedule'],
|
||||
}
|
||||
@@ -1934,9 +1984,11 @@ function defaultConfig() {
|
||||
endImg: 'https://images.unsplash.com/photo-1519741497674-611481863552?auto=format&fit=crop&w=800&q=80',
|
||||
endImgResized: '',
|
||||
endImgResizedMeta: '',
|
||||
musicList: [{ url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3', name: '背景音乐 1' }],
|
||||
musicList: [{ url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3', name: '背景音乐 1', weight: 1 }],
|
||||
activeMusicUrl: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3',
|
||||
musicRandomEnabled: false,
|
||||
musicCacheEnabled: true,
|
||||
musicCacheHours: 24,
|
||||
danmakuEnabled: true,
|
||||
danmakuShowTime: true,
|
||||
nineGridAnimate: false,
|
||||
@@ -2423,7 +2475,7 @@ function openMusicUpload() {
|
||||
const name = file.name.replace(/\.[^.]+$/, '') || '背景音乐'
|
||||
const url = res.url
|
||||
if (!cfg.musicList) cfg.musicList = []
|
||||
cfg.musicList.push({ url, name, coverUrl: '', lrcUrl: '' })
|
||||
cfg.musicList.push({ url, name, coverUrl: '', lrcUrl: '', weight: 1 })
|
||||
if (!cfg.activeMusicUrl) cfg.activeMusicUrl = url
|
||||
message.success('音乐上传成功')
|
||||
} catch (err) {
|
||||
@@ -2464,6 +2516,13 @@ function removeMusic(i) {
|
||||
cfg.activeMusicUrl = cfg.musicList[0]?.url || ''
|
||||
}
|
||||
}
|
||||
|
||||
function musicWeightPercent(track) {
|
||||
const list = (cfg.musicList || []).filter((t) => t && t.url)
|
||||
const total = list.reduce((s, t) => s + normalizeMusicWeight(t.weight), 0)
|
||||
if (!total) return 0
|
||||
return Math.round((normalizeMusicWeight(track?.weight) / total) * 100)
|
||||
}
|
||||
function shortUrl(url) {
|
||||
if (!url) return ''
|
||||
const parts = url.split('/')
|
||||
@@ -2702,7 +2761,16 @@ function pickSectionData(section) {
|
||||
const keys = SECTION_KEYS[section] || []
|
||||
const data = {}
|
||||
for (const k of keys) data[k] = cfg[k]
|
||||
return JSON.parse(JSON.stringify(data))
|
||||
const cloned = JSON.parse(JSON.stringify(data))
|
||||
if (section === 'music' && Array.isArray(cloned.musicList)) {
|
||||
cloned.musicList = cloned.musicList.map((t) => ({
|
||||
...t,
|
||||
weight: normalizeMusicWeight(t?.weight),
|
||||
}))
|
||||
cloned.musicCacheEnabled = cloned.musicCacheEnabled !== false
|
||||
cloned.musicCacheHours = normalizeMusicCacheHours(cloned.musicCacheHours)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
function applySectionData(section, loaded) {
|
||||
@@ -2713,9 +2781,12 @@ function applySectionData(section, loaded) {
|
||||
...t,
|
||||
coverUrl: t.coverUrl || '',
|
||||
lrcUrl: t.lrcUrl || '',
|
||||
weight: normalizeMusicWeight(t.weight),
|
||||
}))
|
||||
}
|
||||
if (typeof loaded.musicRandomEnabled !== 'boolean') loaded.musicRandomEnabled = false
|
||||
if (typeof loaded.musicCacheEnabled !== 'boolean') loaded.musicCacheEnabled = true
|
||||
loaded.musicCacheHours = normalizeMusicCacheHours(loaded.musicCacheHours)
|
||||
}
|
||||
if (section === 'photos') {
|
||||
loaded.photoPages = (loaded.photoPages || []).map(ensurePageShape)
|
||||
@@ -3904,6 +3975,28 @@ onUnmounted(() => {
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
.music-chip--weight {
|
||||
cursor: default;
|
||||
gap: 6px;
|
||||
}
|
||||
.music-chip--weight:hover {
|
||||
border-color: rgba(168, 140, 107, 0.22);
|
||||
color: #7a6550;
|
||||
}
|
||||
.music-weight-input {
|
||||
width: 72px !important;
|
||||
}
|
||||
.music-weight-input :deep(.ant-input-number-input) {
|
||||
height: 22px;
|
||||
font-size: 11px;
|
||||
padding: 0 4px;
|
||||
text-align: center;
|
||||
}
|
||||
.music-weight-pct {
|
||||
font-size: 10px;
|
||||
color: #a88c6b;
|
||||
min-width: 2.2em;
|
||||
}
|
||||
.music-audio {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
|
||||
@@ -852,7 +852,7 @@ import { ref, reactive, computed, nextTick, onMounted, onUnmounted, watch } from
|
||||
import { getAlbumImages, getConfig, submitRsvp, submitDanmaku, generateAiBlessing, getLikeStatus, submitLike, getGiftStatus, submitGift } from '@/api/wedding'
|
||||
import { useAudio } from '@/composables/useAudio'
|
||||
import { toast } from '@/composables/useToast'
|
||||
import { pickMusicUrl, rememberMusicPick } from '@/utils/musicPick'
|
||||
import { pickMusicUrl, pickWeightedMusicUrl, rememberMusicPick } from '@/utils/musicPick'
|
||||
import { getClientId } from '@/utils/clientId'
|
||||
import { getGuestName, setGuestName } from '@/utils/guestName'
|
||||
import {
|
||||
@@ -878,6 +878,7 @@ import PhotoGallery from '@/components/PhotoGallery.vue'
|
||||
const DEFAULTS = {
|
||||
scrollMode: 'snap', petalEnabled: true, bubbleEnabled: true, introEnabled: true,
|
||||
musicList: [], activeMusicUrl: '', musicRandomEnabled: false,
|
||||
musicCacheEnabled: true, musicCacheHours: 24,
|
||||
danmakuEnabled: true, danmakuShowTime: true, nineGridAnimate: false,
|
||||
freeScrollInterval: 50, // 自由滚动速度:滚动间隔 ms(越小越快)
|
||||
pageSwitchDuration: 800, // 页面切换速度:翻页动画时长 ms
|
||||
@@ -925,13 +926,44 @@ const toggleVolumeSlider = () => {
|
||||
showVolumeSlider.value = !showVolumeSlider.value
|
||||
if (showVolumeSlider.value) showPanelTrackList.value = false
|
||||
}
|
||||
const musicCacheOptions = () => ({
|
||||
cacheEnabled: data.value.musicCacheEnabled !== false,
|
||||
cacheHours: data.value.musicCacheHours,
|
||||
})
|
||||
|
||||
const rememberIfCached = (url) => {
|
||||
if (data.value.musicRandomEnabled && data.value.musicCacheEnabled !== false && url) {
|
||||
rememberMusicPick(url)
|
||||
}
|
||||
}
|
||||
|
||||
/** 随机模式下播完/下一首:按权重再抽,尽量避开当前曲 */
|
||||
const playRandomTrack = async (excludeUrl) => {
|
||||
const url = pickWeightedMusicUrl(data.value.musicList, {
|
||||
excludeUrl: excludeUrl || audio.activeUrl.value,
|
||||
})
|
||||
if (!url) return
|
||||
rememberIfCached(url)
|
||||
await audio.playUrl(url)
|
||||
}
|
||||
|
||||
const syncMusicPlaybackMode = () => {
|
||||
const random = !!data.value.musicRandomEnabled
|
||||
// 随机:不单曲循环,播完再抽;非随机:单曲循环当前曲
|
||||
audio.setLoop(!random)
|
||||
}
|
||||
|
||||
const playPrevTrack = () => {
|
||||
audio.prev()
|
||||
if (data.value.musicRandomEnabled && audio.activeUrl.value) rememberMusicPick(audio.activeUrl.value)
|
||||
rememberIfCached(audio.activeUrl.value)
|
||||
}
|
||||
const playNextTrack = () => {
|
||||
if (data.value.musicRandomEnabled && (data.value.musicList || []).filter((t) => t?.url).length > 1) {
|
||||
playRandomTrack(audio.activeUrl.value)
|
||||
return
|
||||
}
|
||||
audio.next()
|
||||
if (data.value.musicRandomEnabled && audio.activeUrl.value) rememberMusicPick(audio.activeUrl.value)
|
||||
rememberIfCached(audio.activeUrl.value)
|
||||
}
|
||||
const activeLrcIndex = computed(() => findLrcIndex(lrcLines.value, audio.currentTime.value))
|
||||
|
||||
@@ -1836,7 +1868,7 @@ const selectTrack = (url) => {
|
||||
audio.setActive(url)
|
||||
showPanelTrackList.value = false
|
||||
showTrackList.value = false
|
||||
if (data.value.musicRandomEnabled) rememberMusicPick(url)
|
||||
if (data.value.musicRandomEnabled) rememberIfCached(url)
|
||||
}
|
||||
|
||||
const mapKeyword = computed(() => `${data.value.hotel || ''} ${data.value.address || ''}`.replace(/\s+/g, ' ').trim())
|
||||
@@ -2202,8 +2234,14 @@ onMounted(async () => {
|
||||
const pickedUrl = pickMusicUrl(
|
||||
data.value.musicList,
|
||||
data.value.activeMusicUrl,
|
||||
data.value.musicRandomEnabled
|
||||
data.value.musicRandomEnabled,
|
||||
musicCacheOptions(),
|
||||
)
|
||||
syncMusicPlaybackMode()
|
||||
audio.onEnded(() => {
|
||||
if (!data.value.musicRandomEnabled) return
|
||||
playRandomTrack(audio.activeUrl.value)
|
||||
})
|
||||
audio.setTracks(data.value.musicList, pickedUrl)
|
||||
let autoMusicOk = true
|
||||
if (data.value.musicList.length) {
|
||||
|
||||
Reference in New Issue
Block a user