1. 音乐播放模式

This commit is contained in:
李琦
2026-08-04 12:54:33 +08:00
parent ef77bb4870
commit 33cea285c1
5 changed files with 252 additions and 31 deletions

View File

@@ -35,6 +35,7 @@ var configSectionKeys = map[string][]string{
},
"music": {
"musicList", "activeMusicUrl", "musicRandomEnabled",
"musicCacheEnabled", "musicCacheHours",
},
"photos": {
"photoPages",

View File

@@ -2,7 +2,7 @@ import { ref, computed, onUnmounted } from 'vue'
/**
* 背景音乐控制 composable支持多曲目
* - tracks: [{ url, name, coverUrl?, lrcUrl? }]
* - tracks: [{ url, name, coverUrl?, lrcUrl?, weight? }]
* - activeUrl: 当前选中的曲目地址
* - 处理浏览器自动播放限制:首次用户交互后再尝试播放
*/
@@ -14,8 +14,10 @@ export function useAudio() {
const currentTime = ref(0)
const duration = ref(0)
const volume = ref(0.5)
const loop = ref(true)
let audio = null
let unlockHandler = null
let endedHandler = null
const currentTrack = computed(() => tracks.value.find((t) => t.url === activeUrl.value) || null)
const currentName = computed(() => currentTrack.value?.name || '背景音乐')
@@ -32,7 +34,7 @@ export function useAudio() {
const ensureAudio = () => {
if (audio) return audio
audio = new Audio()
audio.loop = true
audio.loop = loop.value
audio.volume = volume.value
audio.preload = 'auto'
audio.addEventListener('canplay', () => {
@@ -43,16 +45,27 @@ export function useAudio() {
audio.addEventListener('timeupdate', syncMeta)
audio.addEventListener('play', () => { isPlaying.value = true })
audio.addEventListener('pause', () => { isPlaying.value = false })
audio.addEventListener('ended', () => {
if (audio?.loop) return
if (typeof endedHandler === 'function') endedHandler()
})
return audio
}
const playUrl = async (url) => {
if (!url) return false
const a = ensureAudio()
if (a.src !== url) {
const abs = (() => {
try { return new URL(url, window.location.href).href } catch { return url }
})()
if (a.src !== abs && a.src !== url) {
a.src = url
currentTime.value = 0
duration.value = 0
} else {
// 同曲重播(播完后再次抽中)需归零
try { a.currentTime = 0 } catch { /* ignore */ }
currentTime.value = 0
}
activeUrl.value = url
try {
@@ -80,7 +93,10 @@ export function useAudio() {
else {
activeUrl.value = url
const a = ensureAudio()
if (a.src !== url) a.src = url
const abs = (() => {
try { return new URL(url, window.location.href).href } catch { return url }
})()
if (a.src !== abs && a.src !== url) a.src = url
}
}
@@ -126,16 +142,29 @@ export function useAudio() {
const a = ensureAudio()
if (!Number.isFinite(seconds)) return
const d = Number.isFinite(a.duration) ? a.duration : duration.value
const next = Math.max(0, Math.min(d || seconds, seconds))
a.currentTime = next
currentTime.value = next
const nextSec = Math.max(0, Math.min(d || seconds, seconds))
a.currentTime = nextSec
currentTime.value = nextSec
}
const setVolume = (v) => {
const next = Math.max(0, Math.min(1, Number(v) || 0))
volume.value = next
const nextVol = Math.max(0, Math.min(1, Number(v) || 0))
volume.value = nextVol
const a = ensureAudio()
a.volume = next
a.volume = nextVol
}
const setLoop = (v) => {
loop.value = !!v
if (audio) audio.loop = loop.value
}
/** 注册播完回调(仅 loop=false 时触发);返回取消函数 */
const onEnded = (fn) => {
endedHandler = typeof fn === 'function' ? fn : null
return () => {
if (endedHandler === fn) endedHandler = null
}
}
const armAutoplay = () => {
@@ -158,6 +187,7 @@ export function useAudio() {
onUnmounted(() => {
pause()
removeUnlock()
endedHandler = null
if (audio) {
audio.src = ''
audio = null
@@ -176,6 +206,7 @@ export function useAudio() {
currentTime,
duration,
volume,
loop,
currentIndex,
setTracks,
setActive,
@@ -186,6 +217,9 @@ export function useAudio() {
toggle,
seek,
setVolume,
setLoop,
onEnded,
playUrl,
attemptAutoplay,
armAutoplay,
}

View File

@@ -1,5 +1,19 @@
const STORAGE_KEY = 'wedding_music_pick_v1'
const DAY_MS = 24 * 60 * 60 * 1000
const HOUR_MS = 60 * 60 * 1000
/** 随机权重:默认 1最小 1最大 1000 */
export function normalizeMusicWeight(w) {
const n = Number(w)
if (!Number.isFinite(n) || n <= 0) return 1
return Math.min(1000, Math.round(n))
}
/** 记忆时长(小时):默认 24范围 0.5720 */
export function normalizeMusicCacheHours(h) {
const n = Number(h)
if (!Number.isFinite(n) || n <= 0) return 24
return Math.min(720, Math.max(0.5, Math.round(n * 10) / 10))
}
function readCache() {
try {
@@ -20,11 +34,46 @@ export function rememberMusicPick(url) {
} catch { /* ignore quota */ }
}
export function clearMusicPickCache() {
try {
localStorage.removeItem(STORAGE_KEY)
} catch { /* ignore */ }
}
/**
* Resolve which track to play.
* When random is enabled, reuse local cache within 24h if still in list; otherwise pick randomly.
* 按 weight 加权随机选一首
* @param {{ excludeUrl?: string }} [opts] excludeUrl尽量避开当前曲仅一首时仍可播它
*/
export function pickMusicUrl(musicList, activeMusicUrl, musicRandomEnabled) {
export function pickWeightedMusicUrl(musicList, opts = {}) {
let list = Array.isArray(musicList) ? musicList.filter((t) => t && t.url) : []
if (!list.length) return ''
const excludeUrl = opts.excludeUrl || ''
if (excludeUrl && list.length > 1) {
const filtered = list.filter((t) => t.url !== excludeUrl)
if (filtered.length) list = filtered
}
let total = 0
const weights = list.map((t) => {
const w = normalizeMusicWeight(t.weight)
total += w
return w
})
if (total <= 0) return list[0].url
let r = Math.random() * total
for (let i = 0; i < list.length; i += 1) {
r -= weights[i]
if (r <= 0) return list[i].url
}
return list[list.length - 1].url
}
/**
* 解析首播曲目。
* options:
* - cacheEnabled: 是否启用本地记忆
* - cacheHours: 记忆时长(小时)
*/
export function pickMusicUrl(musicList, activeMusicUrl, musicRandomEnabled, options = {}) {
const list = Array.isArray(musicList) ? musicList.filter((t) => t && t.url) : []
if (!list.length) return ''
@@ -33,12 +82,18 @@ export function pickMusicUrl(musicList, activeMusicUrl, musicRandomEnabled) {
return list[0].url
}
const cache = readCache()
if (cache && Date.now() - cache.ts < DAY_MS && list.some((t) => t.url === cache.url)) {
return cache.url
const cacheEnabled = options.cacheEnabled !== false && options.cacheEnabled !== 0
const cacheHours = normalizeMusicCacheHours(options.cacheHours)
const cacheMs = cacheHours * HOUR_MS
if (cacheEnabled) {
const cache = readCache()
if (cache && Date.now() - cache.ts < cacheMs && list.some((t) => t.url === cache.url)) {
return cache.url
}
}
const picked = list[Math.floor(Math.random() * list.length)].url
rememberMusicPick(picked)
const picked = pickWeightedMusicUrl(list, { excludeUrl: options.excludeUrl })
if (cacheEnabled) rememberMusicPick(picked)
return picked
}

View File

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

View File

@@ -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) {