1. 播放器
This commit is contained in:
Binary file not shown.
@@ -700,8 +700,15 @@ func main() {
|
||||
})
|
||||
|
||||
api.GET("/danmaku", func(c *gin.Context) {
|
||||
q := db.Where("status = ?", "approved")
|
||||
if after := strings.TrimSpace(c.Query("after_id")); after != "" {
|
||||
var afterID uint64
|
||||
if _, err := fmt.Sscanf(after, "%d", &afterID); err == nil && afterID > 0 {
|
||||
q = q.Where("id > ?", afterID)
|
||||
}
|
||||
}
|
||||
var list []Danmaku
|
||||
db.Where("status = ?", "approved").Order("created_at asc").Find(&list)
|
||||
q.Order("id asc").Find(&list)
|
||||
c.JSON(200, gin.H{"code": 200, "data": list})
|
||||
})
|
||||
|
||||
|
||||
@@ -80,8 +80,8 @@ export function getRsvpList() {
|
||||
return service.get('/rsvp/list')
|
||||
}
|
||||
|
||||
export function getDanmaku() {
|
||||
return service.get('/danmaku')
|
||||
export function getDanmaku(params) {
|
||||
return service.get('/danmaku', { params })
|
||||
}
|
||||
|
||||
export function submitDanmaku(form) {
|
||||
|
||||
@@ -39,12 +39,29 @@ let pollTimer = null
|
||||
let uid = 0
|
||||
/** @type {Map<string, number>} */
|
||||
const lastShownAt = new Map()
|
||||
/** 本批已飘过的业务弹幕 id */
|
||||
const shownIds = new Set()
|
||||
|
||||
const itemKey = (item) => {
|
||||
if (item?.id != null) return String(item.id)
|
||||
return `${item?.name || ''}|${item?.content || ''}|${item?.created_at || ''}`
|
||||
}
|
||||
|
||||
const maxItemId = () => {
|
||||
let max = 0
|
||||
for (const it of items.value) {
|
||||
const id = Number(it?.id) || 0
|
||||
if (id > max) max = id
|
||||
}
|
||||
return max
|
||||
}
|
||||
|
||||
const isBatchDone = () => {
|
||||
const list = items.value
|
||||
if (!list.length) return true
|
||||
return list.every((it) => it?.id != null && shownIds.has(Number(it.id)))
|
||||
}
|
||||
|
||||
const isCooling = (key) => {
|
||||
const t = lastShownAt.get(key)
|
||||
return t != null && Date.now() - t < DEDUPE_MS
|
||||
@@ -66,10 +83,15 @@ const pickNextItem = () => {
|
||||
return null
|
||||
}
|
||||
|
||||
const markShown = (item) => {
|
||||
if (item?.id != null) shownIds.add(Number(item.id))
|
||||
}
|
||||
|
||||
const buildRow = (item, { force = false } = {}) => {
|
||||
const key = itemKey(item)
|
||||
if (!force && isCooling(key)) return null
|
||||
lastShownAt.set(key, Date.now())
|
||||
markShown(item)
|
||||
|
||||
const tint = resolveDanmakuTint(item.color)
|
||||
const track = Math.floor(Math.random() * TRACKS)
|
||||
@@ -77,7 +99,6 @@ const buildRow = (item, { force = false } = {}) => {
|
||||
const delay = force ? 0 : Math.random() * 0.4
|
||||
const id = ++uid
|
||||
|
||||
// 发送者刚插入的弹幕强制「刚刚」,避免服务端时间解析偏差
|
||||
const timeLabel = force
|
||||
? '刚刚'
|
||||
: formatRelativeTime(item.created_at || Date.now())
|
||||
@@ -98,16 +119,36 @@ const buildRow = (item, { force = false } = {}) => {
|
||||
}
|
||||
}
|
||||
|
||||
const mergeItems = (incoming) => {
|
||||
if (!incoming.length) return
|
||||
const map = new Map(items.value.map((it) => [it.id, it]))
|
||||
for (const it of incoming) {
|
||||
if (it?.id != null) map.set(it.id, it)
|
||||
}
|
||||
items.value = Array.from(map.values()).sort((a, b) => (a.id || 0) - (b.id || 0))
|
||||
}
|
||||
|
||||
const fetchList = async () => {
|
||||
if (!props.enabled) {
|
||||
items.value = []
|
||||
shownIds.clear()
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await getDanmaku()
|
||||
const next = Array.isArray(res.data) ? res.data : []
|
||||
items.value = next
|
||||
if (next.length && cursor >= next.length) cursor = cursor % next.length
|
||||
const done = isBatchDone()
|
||||
const maxId = maxItemId()
|
||||
if (!done && maxId > 0) {
|
||||
const res = await getDanmaku({ after_id: maxId })
|
||||
const next = Array.isArray(res.data) ? res.data : []
|
||||
mergeItems(next)
|
||||
} else {
|
||||
const res = await getDanmaku()
|
||||
const next = Array.isArray(res.data) ? res.data : []
|
||||
items.value = next
|
||||
shownIds.clear()
|
||||
cursor = 0
|
||||
}
|
||||
if (items.value.length && cursor >= items.value.length) cursor = cursor % items.value.length
|
||||
} catch {
|
||||
/* keep previous items on transient failure */
|
||||
}
|
||||
@@ -177,6 +218,7 @@ watch(
|
||||
rows.value = []
|
||||
items.value = []
|
||||
lastShownAt.clear()
|
||||
shownIds.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -195,6 +237,7 @@ watch(
|
||||
rows.value = []
|
||||
items.value = []
|
||||
lastShownAt.clear()
|
||||
shownIds.clear()
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -236,7 +279,6 @@ defineExpose({ refresh: fetchList, pushItem })
|
||||
animation-timing-function: linear;
|
||||
animation-fill-mode: forwards;
|
||||
backdrop-filter: blur(6px);
|
||||
/* 不裁切宽度:长祝福时末尾相对时间需完整露出并随整条飞过 */
|
||||
width: max-content;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
@@ -26,8 +26,8 @@ const burst = (count = 3) => {
|
||||
const id = ++uid
|
||||
const left = 10 + Math.random() * 200
|
||||
const drift = (Math.random() - 0.5) * 90
|
||||
const duration = 1.25 + Math.random() * 0.7
|
||||
const delay = i * 0.045
|
||||
const duration = 2.8 + Math.random() * 1.0
|
||||
const delay = i * 0.1
|
||||
const scale = 0.8 + Math.random() * 0.55
|
||||
hearts.value.push({
|
||||
id,
|
||||
@@ -82,7 +82,7 @@ defineExpose({ burst })
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate3d(var(--drift, 0px), -46vh, 0) scale(var(--scale, 1));
|
||||
transform: translate3d(var(--drift, 0px), -55vh, 0) scale(var(--scale, 1));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,52 +2,70 @@ import { ref, computed, onUnmounted } from 'vue'
|
||||
|
||||
/**
|
||||
* 背景音乐控制 composable(支持多曲目)
|
||||
* - tracks: [{ url, name }] 播放列表
|
||||
* - tracks: [{ url, name, coverUrl?, lrcUrl? }]
|
||||
* - activeUrl: 当前选中的曲目地址
|
||||
* - 处理浏览器自动播放限制:首次用户交互后再尝试播放
|
||||
*/
|
||||
export function useAudio() {
|
||||
const isPlaying = ref(false)
|
||||
const isReady = ref(false)
|
||||
const tracks = ref([]) // [{ url, name }]
|
||||
const activeUrl = ref('') // 当前选中曲目 url
|
||||
const tracks = ref([])
|
||||
const activeUrl = ref('')
|
||||
const currentTime = ref(0)
|
||||
const duration = ref(0)
|
||||
const volume = ref(0.5)
|
||||
let audio = null
|
||||
let unlockHandler = null
|
||||
|
||||
const currentName = computed(() => {
|
||||
const t = tracks.value.find((t) => t.url === activeUrl.value)
|
||||
return t?.name || '背景音乐'
|
||||
})
|
||||
const currentTrack = computed(() => tracks.value.find((t) => t.url === activeUrl.value) || null)
|
||||
const currentName = computed(() => currentTrack.value?.name || '背景音乐')
|
||||
const currentCover = computed(() => currentTrack.value?.coverUrl || '')
|
||||
const currentLrcUrl = computed(() => currentTrack.value?.lrcUrl || '')
|
||||
|
||||
const syncMeta = () => {
|
||||
if (!audio) return
|
||||
currentTime.value = audio.currentTime || 0
|
||||
duration.value = Number.isFinite(audio.duration) ? audio.duration : 0
|
||||
volume.value = audio.volume
|
||||
}
|
||||
|
||||
const ensureAudio = () => {
|
||||
if (audio) return audio
|
||||
audio = new Audio()
|
||||
audio.loop = true
|
||||
audio.volume = 0.5
|
||||
audio.volume = volume.value
|
||||
audio.preload = 'auto'
|
||||
audio.addEventListener('canplay', () => (isReady.value = true))
|
||||
audio.addEventListener('play', () => (isPlaying.value = true))
|
||||
audio.addEventListener('pause', () => (isPlaying.value = false))
|
||||
audio.addEventListener('canplay', () => {
|
||||
isReady.value = true
|
||||
syncMeta()
|
||||
})
|
||||
audio.addEventListener('loadedmetadata', syncMeta)
|
||||
audio.addEventListener('timeupdate', syncMeta)
|
||||
audio.addEventListener('play', () => { isPlaying.value = true })
|
||||
audio.addEventListener('pause', () => { isPlaying.value = false })
|
||||
return audio
|
||||
}
|
||||
|
||||
const playUrl = async (url) => {
|
||||
if (!url) return false
|
||||
const a = ensureAudio()
|
||||
if (a.src !== url) a.src = url
|
||||
if (a.src !== url) {
|
||||
a.src = url
|
||||
currentTime.value = 0
|
||||
duration.value = 0
|
||||
}
|
||||
activeUrl.value = url
|
||||
try {
|
||||
await a.play()
|
||||
isPlaying.value = true
|
||||
syncMeta()
|
||||
return true
|
||||
} catch (e) {
|
||||
// 被浏览器拦截,等待用户手势
|
||||
} catch {
|
||||
isPlaying.value = false
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 设置播放列表与默认选中项
|
||||
const setTracks = (list, active) => {
|
||||
tracks.value = Array.isArray(list) ? list.slice().filter((t) => t && t.url) : []
|
||||
const first = tracks.value[0]?.url || ''
|
||||
@@ -56,16 +74,18 @@ export function useAudio() {
|
||||
if (audio && isPlaying.value && chosen) playUrl(chosen)
|
||||
}
|
||||
|
||||
// 切换当前播放曲目(若正在播放则立即切换)
|
||||
const setActive = (url) => {
|
||||
if (!tracks.value.some((t) => t.url === url)) return
|
||||
if (isPlaying.value) playUrl(url)
|
||||
else activeUrl.value = url
|
||||
else {
|
||||
activeUrl.value = url
|
||||
const a = ensureAudio()
|
||||
if (a.src !== url) a.src = url
|
||||
}
|
||||
}
|
||||
|
||||
const play = () => playUrl(activeUrl.value)
|
||||
|
||||
// 尝试自动播放,返回是否成功(被浏览器拦截则返回 false,由调用方决定是否弹授权框)
|
||||
const attemptAutoplay = async () => {
|
||||
if (!activeUrl.value) return false
|
||||
return playUrl(activeUrl.value)
|
||||
@@ -80,7 +100,22 @@ export function useAudio() {
|
||||
else play()
|
||||
}
|
||||
|
||||
// 首次交互解锁自动播放
|
||||
const seek = (seconds) => {
|
||||
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 setVolume = (v) => {
|
||||
const next = Math.max(0, Math.min(1, Number(v) || 0))
|
||||
volume.value = next
|
||||
const a = ensureAudio()
|
||||
a.volume = next
|
||||
}
|
||||
|
||||
const armAutoplay = () => {
|
||||
const handler = async () => {
|
||||
const ok = await play()
|
||||
@@ -107,5 +142,26 @@ export function useAudio() {
|
||||
}
|
||||
})
|
||||
|
||||
return { isPlaying, isReady, tracks, activeUrl, currentName, setTracks, setActive, play, pause, toggle, attemptAutoplay, armAutoplay }
|
||||
return {
|
||||
isPlaying,
|
||||
isReady,
|
||||
tracks,
|
||||
activeUrl,
|
||||
currentName,
|
||||
currentTrack,
|
||||
currentCover,
|
||||
currentLrcUrl,
|
||||
currentTime,
|
||||
duration,
|
||||
volume,
|
||||
setTracks,
|
||||
setActive,
|
||||
play,
|
||||
pause,
|
||||
toggle,
|
||||
seek,
|
||||
setVolume,
|
||||
attemptAutoplay,
|
||||
armAutoplay,
|
||||
}
|
||||
}
|
||||
|
||||
47
vite-tailwindcss/src/utils/parseLrc.js
Normal file
47
vite-tailwindcss/src/utils/parseLrc.js
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Parse LRC text into [{ time, text }] sorted by time (seconds).
|
||||
*/
|
||||
export function parseLrc(raw) {
|
||||
if (!raw || typeof raw !== 'string') return []
|
||||
const lines = []
|
||||
const re = /\[(\d{1,2}):(\d{1,2})(?:\.(\d{1,3}))?\]/g
|
||||
for (const row of raw.split(/\r?\n/)) {
|
||||
const text = row.replace(re, '').trim()
|
||||
re.lastIndex = 0
|
||||
let m
|
||||
let matched = false
|
||||
while ((m = re.exec(row)) !== null) {
|
||||
matched = true
|
||||
const min = Number(m[1]) || 0
|
||||
const sec = Number(m[2]) || 0
|
||||
const frac = m[3] || '0'
|
||||
const ms = Number(frac.padEnd(3, '0').slice(0, 3)) || 0
|
||||
const time = min * 60 + sec + ms / 1000
|
||||
if (text) lines.push({ time, text })
|
||||
}
|
||||
if (!matched && text && !row.startsWith('[')) {
|
||||
/* skip bare meta */
|
||||
}
|
||||
}
|
||||
lines.sort((a, b) => a.time - b.time)
|
||||
return lines
|
||||
}
|
||||
|
||||
export function formatAudioTime(sec) {
|
||||
const s = Math.max(0, Math.floor(Number(sec) || 0))
|
||||
const m = Math.floor(s / 60)
|
||||
const r = s % 60
|
||||
return `${m}:${String(r).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/** Index of the active lyric line for currentTime */
|
||||
export function findLrcIndex(lines, currentTime) {
|
||||
if (!lines?.length) return -1
|
||||
const t = Number(currentTime) || 0
|
||||
let idx = -1
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
if (lines[i].time <= t) idx = i
|
||||
else break
|
||||
}
|
||||
return idx
|
||||
}
|
||||
@@ -188,6 +188,27 @@
|
||||
</a-button>
|
||||
<a-button size="small" danger @click="removeMusic(i)"><i class="fa-solid fa-trash"></i></a-button>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-4 mt-3 items-start">
|
||||
<div>
|
||||
<div class="text-[11px] text-[#8A8680] mb-1">封面</div>
|
||||
<image-field
|
||||
v-model="t.coverUrl"
|
||||
label="封面"
|
||||
@pick="openUpload(u => t.coverUrl = u)"
|
||||
:uploading="uploading"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 min-w-[180px]">
|
||||
<div class="text-[11px] text-[#8A8680] mb-1">歌词 LRC</div>
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<a-button size="small" @click="openLrcUpload(t)">
|
||||
<i class="fa-solid fa-file-lines mr-1"></i>{{ t.lrcUrl ? '更换歌词' : '上传歌词' }}
|
||||
</a-button>
|
||||
<a-button v-if="t.lrcUrl" size="small" danger type="link" @click="t.lrcUrl = ''">清除</a-button>
|
||||
</div>
|
||||
<div v-if="t.lrcUrl" class="text-[10px] text-[#8A8680] mt-1 truncate max-w-[280px]">{{ shortUrl(t.lrcUrl) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<audio v-if="t.url" :src="t.url" controls class="w-full mt-3" style="height: 36px;"></audio>
|
||||
</a-card>
|
||||
<a-button class="mt-1" @click="openMusicUpload">
|
||||
@@ -658,7 +679,7 @@ function openMusicUpload() {
|
||||
const name = file.name.replace(/\.[^.]+$/, '') || '背景音乐'
|
||||
const url = res.url
|
||||
if (!cfg.musicList) cfg.musicList = []
|
||||
cfg.musicList.push({ url, name })
|
||||
cfg.musicList.push({ url, name, coverUrl: '', lrcUrl: '' })
|
||||
if (!cfg.activeMusicUrl) cfg.activeMusicUrl = url
|
||||
message.success('音乐上传成功')
|
||||
} catch (err) {
|
||||
@@ -669,6 +690,29 @@ function openMusicUpload() {
|
||||
}
|
||||
input.click()
|
||||
}
|
||||
|
||||
function openLrcUpload(track) {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = '.lrc,text/plain'
|
||||
input.onchange = async (e) => {
|
||||
const file = e.target.files[0]
|
||||
if (!file) return
|
||||
uploading.value = true
|
||||
try {
|
||||
const res = await uploadImage(file)
|
||||
track.lrcUrl = res.url
|
||||
message.success('歌词上传成功')
|
||||
} catch {
|
||||
message.error('歌词上传失败')
|
||||
} finally {
|
||||
uploading.value = false
|
||||
e.target.value = ''
|
||||
}
|
||||
}
|
||||
input.click()
|
||||
}
|
||||
|
||||
function setActiveMusic(url) { cfg.activeMusicUrl = url }
|
||||
function removeMusic(i) {
|
||||
cfg.musicList.splice(i, 1)
|
||||
@@ -801,9 +845,16 @@ onMounted(async () => {
|
||||
const loaded = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
|
||||
// 兼容旧版单链接 musicUrl
|
||||
if (loaded.musicUrl && !(loaded.musicList && loaded.musicList.length)) {
|
||||
loaded.musicList = [{ url: loaded.musicUrl, name: '背景音乐' }]
|
||||
loaded.musicList = [{ url: loaded.musicUrl, name: '背景音乐', coverUrl: '', lrcUrl: '' }]
|
||||
loaded.activeMusicUrl = loaded.musicUrl
|
||||
}
|
||||
if (Array.isArray(loaded.musicList)) {
|
||||
loaded.musicList = loaded.musicList.map((t) => ({
|
||||
...t,
|
||||
coverUrl: t.coverUrl || '',
|
||||
lrcUrl: t.lrcUrl || '',
|
||||
}))
|
||||
}
|
||||
loaded.endImg = loaded.endImg || loaded.heroImg || cfg.endImg
|
||||
Object.assign(cfg, loaded)
|
||||
cfg.formalPageHeight = cfg.formalPageHeight || '100vh'
|
||||
|
||||
@@ -46,20 +46,12 @@
|
||||
<!-- 顶部滚动进度条 -->
|
||||
<div class="fixed top-0 left-0 h-[2px] bg-[#a88c6b] z-[55] transition-[width] duration-150" :style="{ width: progress * 100 + '%' }"></div>
|
||||
|
||||
<!-- 右上角控制台 -->
|
||||
<!-- 右上角:曲目列表 + 自动滚动 -->
|
||||
<div class="fixed top-6 right-5 z-50 flex flex-col items-end gap-4">
|
||||
<!-- 音乐控制 -->
|
||||
<div class="music-wrap">
|
||||
<div class="flex flex-col gap-3 items-end">
|
||||
<div @click="audio.toggle()" class="control-btn cursor-pointer" :class="{ 'rotate-spin': audio.isPlaying.value }">
|
||||
<img v-if="audio.isPlaying.value" src="https://api.iconify.design/lucide:music.svg?color=%23a88c6b" class="w-4 h-4" />
|
||||
<img v-else src="https://api.iconify.design/lucide:volume-x.svg?color=%23a88c6b" class="w-4 h-4" />
|
||||
</div>
|
||||
<div v-if="audio.tracks.value.length > 1" @click="showTrackList = !showTrackList" class="control-btn cursor-pointer">
|
||||
<img src="https://api.iconify.design/lucide:list-music.svg?color=%23a88c6b" class="w-4 h-4" />
|
||||
</div>
|
||||
<div v-if="audio.tracks.value.length > 1" @click="showTrackList = !showTrackList" class="control-btn cursor-pointer" title="曲目">
|
||||
<img src="https://api.iconify.design/lucide:list-music.svg?color=%23a88c6b" class="w-4 h-4" />
|
||||
</div>
|
||||
<!-- 曲目切换弹层 -->
|
||||
<div v-if="showTrackList && audio.tracks.value.length" class="track-pop">
|
||||
<div class="track-pop-title">选择曲目</div>
|
||||
<div class="track-pop-list">
|
||||
@@ -73,18 +65,14 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div @click="toggleAutoScroll" class="control-btn cursor-pointer transition-all duration-300"
|
||||
:class="isAutoScroll ? 'bg-[#a88c6b]/15 !border-[#a88c6b]/60 shadow-sm' : 'bg-white/60 shadow-sm border-[#a88c6b]/30'">
|
||||
<img v-if="isAutoScroll" src="https://api.iconify.design/lucide:pause.svg?color=%23a88c6b" class="w-4 h-4" />
|
||||
<img v-else src="https://api.iconify.design/lucide:play.svg?color=%23a88c6b" class="w-4 h-4 ml-0.5" />
|
||||
</div>
|
||||
<div @click="showTrackList = false; openNavigation()" class="control-btn cursor-pointer" title="导航">
|
||||
<img src="https://api.iconify.design/lucide:map-pinned.svg?color=%23a88c6b" class="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部互动区:点赞 / 填写 / 评论条 -->
|
||||
<!-- 底部互动区 -->
|
||||
<div
|
||||
class="action-dock"
|
||||
@touchstart.stop
|
||||
@@ -93,8 +81,8 @@
|
||||
@mousedown.stop
|
||||
@click.stop
|
||||
>
|
||||
<!-- 右下点赞邻域隐形遮罩:吞掉穿透到页面的触摸,避免误触预览/暂停 -->
|
||||
<div class="like-side-shield" aria-hidden="true" />
|
||||
<!-- 行1:点赞 -->
|
||||
<div class="action-dock-row">
|
||||
<div class="like-hit-zone">
|
||||
<button
|
||||
@@ -109,6 +97,17 @@
|
||||
<span class="like-count">{{ likeCount }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 行2:导航 + 回执 -->
|
||||
<div class="action-dock-row">
|
||||
<button
|
||||
type="button"
|
||||
class="action-pill"
|
||||
title="导航"
|
||||
@click="showTrackList = false; openNavigation()"
|
||||
>
|
||||
<img src="https://api.iconify.design/lucide:map-pinned.svg?color=%23a88c6b" class="action-pill-icon" alt="" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="action-pill action-rsvp"
|
||||
@@ -118,16 +117,103 @@
|
||||
<img src="https://api.iconify.design/lucide:clipboard-pen.svg?color=%23a88c6b" class="action-pill-icon" alt="" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
v-if="data.danmakuEnabled"
|
||||
type="button"
|
||||
class="action-comment"
|
||||
title="送祝福"
|
||||
@click="showTrackList = false; showBlessingDrawer = true"
|
||||
>
|
||||
<img src="https://api.iconify.design/lucide:message-circle-heart.svg?color=%23a88c6b" class="action-comment-icon" alt="" />
|
||||
<span>写一句祝福</span>
|
||||
</button>
|
||||
<!-- 行3:黑胶 + 评论 -->
|
||||
<div class="action-comment-row">
|
||||
<div class="vinyl-wrap" v-if="audio.tracks.value.length">
|
||||
<button
|
||||
type="button"
|
||||
class="vinyl-disc"
|
||||
:class="{ spinning: audio.isPlaying.value }"
|
||||
title="打开播放器"
|
||||
@click="openPlayerPanel"
|
||||
>
|
||||
<span class="vinyl-grooves" aria-hidden="true" />
|
||||
<span
|
||||
class="vinyl-label"
|
||||
:style="audio.currentCover.value ? { backgroundImage: `url(${audio.currentCover.value})` } : undefined"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="vinyl-play-fab"
|
||||
:title="audio.isPlaying.value ? '暂停' : '播放'"
|
||||
@click.stop="audio.toggle()"
|
||||
>
|
||||
<i :class="audio.isPlaying.value ? 'fa-solid fa-pause' : 'fa-solid fa-play'" />
|
||||
</button>
|
||||
<div v-if="showPlayerPanel" class="player-panel">
|
||||
<span class="player-panel-arrow" aria-hidden="true" />
|
||||
<div class="player-panel-head">
|
||||
<div class="player-panel-meta">
|
||||
<span
|
||||
v-if="audio.currentCover.value"
|
||||
class="player-panel-cover"
|
||||
:style="{ backgroundImage: `url(${audio.currentCover.value})` }"
|
||||
/>
|
||||
<span class="player-panel-title">{{ audio.currentName.value }}</span>
|
||||
</div>
|
||||
<button type="button" class="player-panel-close" @click="showPlayerPanel = false">✕</button>
|
||||
</div>
|
||||
<!-- 上:歌词 -->
|
||||
<div ref="lrcListRef" class="player-lrc-list">
|
||||
<template v-if="lrcLines.length">
|
||||
<p
|
||||
v-for="(line, i) in lrcLines"
|
||||
:key="`${line.time}-${i}`"
|
||||
class="player-lrc-line"
|
||||
:class="{ active: i === activeLrcIndex }"
|
||||
>{{ line.text }}</p>
|
||||
</template>
|
||||
<p v-else class="player-lrc-empty">暂无歌词</p>
|
||||
</div>
|
||||
<!-- 下:左播放,右音量+进度 -->
|
||||
<div class="player-panel-footer">
|
||||
<button type="button" class="player-play-btn" @click="audio.toggle()">
|
||||
<i :class="audio.isPlaying.value ? 'fa-solid fa-pause' : 'fa-solid fa-play'" />
|
||||
</button>
|
||||
<div class="player-panel-sliders">
|
||||
<div class="player-vol-row">
|
||||
<i class="fa-solid fa-volume-low" />
|
||||
<input
|
||||
type="range"
|
||||
class="player-range player-vol"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
:value="audio.volume.value"
|
||||
@input="onVolumeInput"
|
||||
/>
|
||||
</div>
|
||||
<div class="player-seek-wrap">
|
||||
<input
|
||||
type="range"
|
||||
class="player-range"
|
||||
min="0"
|
||||
:max="seekMax"
|
||||
step="0.1"
|
||||
:value="audio.currentTime.value"
|
||||
@input="onSeekInput"
|
||||
/>
|
||||
<div class="player-time">
|
||||
<span>{{ formatAudioTime(audio.currentTime.value) }}</span>
|
||||
<span>{{ formatAudioTime(audio.duration.value) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="data.danmakuEnabled"
|
||||
type="button"
|
||||
class="action-comment"
|
||||
title="送祝福"
|
||||
@click="showTrackList = false; showBlessingDrawer = true"
|
||||
>
|
||||
<img src="https://api.iconify.design/lucide:message-circle-heart.svg?color=%23a88c6b" class="action-comment-icon" alt="" />
|
||||
<span>写一句祝福</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LikeBurst ref="likeBurstRef" />
|
||||
@@ -486,6 +572,7 @@ import { toast } from '@/composables/useToast'
|
||||
import { pickMusicUrl, rememberMusicPick } from '@/utils/musicPick'
|
||||
import { getClientId } from '@/utils/clientId'
|
||||
import { DANMAKU_COLORS, DEFAULT_DANMAKU_COLOR, BLESSING_STYLES } from '@/utils/danmakuColors'
|
||||
import { parseLrc, formatAudioTime, findLrcIndex } from '@/utils/parseLrc'
|
||||
import CanvasEffects from '@/components/CanvasEffects.vue'
|
||||
import DanmakuLayer from '@/components/DanmakuLayer.vue'
|
||||
import LikeBurst from '@/components/LikeBurst.vue'
|
||||
@@ -512,7 +599,54 @@ const showIntro = ref(false), introOpening = ref(false), showTrackList = ref(fal
|
||||
const showOpenInBrowser = ref(false)
|
||||
const showBlessingDrawer = ref(false)
|
||||
const blessingSubmitted = ref(false)
|
||||
const showPlayerPanel = ref(false)
|
||||
const lrcLines = ref([])
|
||||
const lrcListRef = ref(null)
|
||||
const danmakuLayerRef = ref(null)
|
||||
|
||||
const seekMax = computed(() => Math.max(audio.duration.value || 0, audio.currentTime.value || 0, 1))
|
||||
const activeLrcIndex = computed(() => findLrcIndex(lrcLines.value, audio.currentTime.value))
|
||||
|
||||
const loadLrcForActive = async () => {
|
||||
const url = audio.currentLrcUrl.value
|
||||
if (!url) {
|
||||
lrcLines.value = []
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await fetch(url)
|
||||
const text = await res.text()
|
||||
lrcLines.value = parseLrc(text)
|
||||
} catch {
|
||||
lrcLines.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const openPlayerPanel = async () => {
|
||||
showPlayerPanel.value = true
|
||||
await loadLrcForActive()
|
||||
}
|
||||
|
||||
const onSeekInput = (e) => {
|
||||
audio.seek(Number(e.target.value))
|
||||
}
|
||||
const onVolumeInput = (e) => {
|
||||
audio.setVolume(Number(e.target.value))
|
||||
}
|
||||
|
||||
watch(
|
||||
() => audio.activeUrl.value,
|
||||
() => {
|
||||
if (showPlayerPanel.value) loadLrcForActive()
|
||||
}
|
||||
)
|
||||
|
||||
watch(activeLrcIndex, async (idx) => {
|
||||
if (idx < 0 || !lrcListRef.value) return
|
||||
await nextTick()
|
||||
const el = lrcListRef.value.querySelectorAll('.player-lrc-line')[idx]
|
||||
el?.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||
})
|
||||
const aiStyleOpen = ref('')
|
||||
const aiBlessingLoading = ref(false)
|
||||
const aiBlessingTarget = ref('')
|
||||
@@ -701,7 +835,7 @@ const clearBottomReturn = () => {
|
||||
}
|
||||
|
||||
const scheduleBottomReturn = (reset = false) => {
|
||||
if (!isAtBottom() || !isAutoScroll.value || previewVisible.value || showMapOptions.value || showOpenInBrowser.value || showBlessingDrawer.value) return
|
||||
if (!isAtBottom() || !isAutoScroll.value || previewVisible.value || showMapOptions.value || showOpenInBrowser.value || showBlessingDrawer.value || showPlayerPanel.value) return
|
||||
if (bottomReturnTimer && !reset) return
|
||||
clearBottomReturn()
|
||||
bottomReturnTimer = setTimeout(() => {
|
||||
@@ -745,6 +879,7 @@ const canAutoAdvance = () =>
|
||||
&& !isInteracting.value
|
||||
&& !isDrawerOpen.value
|
||||
&& !showBlessingDrawer.value
|
||||
&& !showPlayerPanel.value
|
||||
&& !previewVisible.value
|
||||
&& !showMapOptions.value
|
||||
&& !showOpenInBrowser.value
|
||||
@@ -1080,9 +1215,9 @@ watch(
|
||||
)
|
||||
watch(freeScrollSignature, restartAutoScroll)
|
||||
watch(
|
||||
() => [isDrawerOpen.value, showBlessingDrawer.value, previewVisible.value, showMapOptions.value, showOpenInBrowser.value],
|
||||
() => [isDrawerOpen.value, showBlessingDrawer.value, showPlayerPanel.value, previewVisible.value, showMapOptions.value, showOpenInBrowser.value],
|
||||
() => {
|
||||
if (isDrawerOpen.value || showBlessingDrawer.value || previewVisible.value || showMapOptions.value || showOpenInBrowser.value) {
|
||||
if (isDrawerOpen.value || showBlessingDrawer.value || showPlayerPanel.value || previewVisible.value || showMapOptions.value || showOpenInBrowser.value) {
|
||||
cancelAnimationFrame(animId)
|
||||
clearBottomReturn()
|
||||
}
|
||||
@@ -1140,8 +1275,8 @@ watch(
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: min(52vw, 220px);
|
||||
height: calc(132px + env(safe-area-inset-bottom, 0px));
|
||||
width: min(56vw, 240px);
|
||||
height: calc(200px + env(safe-area-inset-bottom, 0px));
|
||||
pointer-events: auto;
|
||||
z-index: 1;
|
||||
background: transparent;
|
||||
@@ -1199,11 +1334,21 @@ watch(
|
||||
line-height: 1; text-align: center; max-width: 100%;
|
||||
}
|
||||
.like-fab.liked .like-count { color: #f472b6; font-weight: 600; }
|
||||
.action-comment-row {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.action-comment-row > * { pointer-events: auto; }
|
||||
.action-comment {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
pointer-events: auto;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1223,6 +1368,211 @@ watch(
|
||||
}
|
||||
.action-comment:active { transform: scale(0.99); }
|
||||
.action-comment-icon { width: 18px; height: 18px; opacity: 0.9; }
|
||||
|
||||
.vinyl-wrap {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
}
|
||||
.vinyl-disc {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 999px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(60, 45, 30, 0.35);
|
||||
background:
|
||||
radial-gradient(circle at center, rgba(40, 30, 22, 0.2) 0 14%, transparent 15%),
|
||||
repeating-radial-gradient(circle at center, #2a2118 0 1px, #3a2f24 1px 3px);
|
||||
box-shadow: 0 6px 16px rgba(60, 45, 30, 0.22);
|
||||
}
|
||||
.vinyl-disc.spinning { animation: vinyl-spin 4.8s linear infinite; }
|
||||
.vinyl-grooves { position: absolute; inset: 0; opacity: 0.35; pointer-events: none; }
|
||||
.vinyl-label {
|
||||
position: absolute;
|
||||
inset: 28%;
|
||||
border-radius: 999px;
|
||||
background: #c4a484 center/cover no-repeat;
|
||||
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.25);
|
||||
}
|
||||
.vinyl-play-fab {
|
||||
position: absolute;
|
||||
right: -5px;
|
||||
bottom: -5px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
color: #fff;
|
||||
background: linear-gradient(145deg, #c4a484, #a88c6b);
|
||||
box-shadow: 0 3px 10px rgba(92, 74, 53, 0.28);
|
||||
z-index: 2;
|
||||
}
|
||||
.vinyl-play-fab .fa-play { margin-left: 1px; }
|
||||
.player-panel {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: calc(100% + 14px);
|
||||
width: min(82vw, 308px);
|
||||
padding: 14px 14px 12px;
|
||||
border-radius: 26px;
|
||||
background: rgba(255, 252, 247, 0.82);
|
||||
border: none;
|
||||
box-shadow:
|
||||
0 16px 40px rgba(92, 74, 53, 0.16),
|
||||
0 2px 0 rgba(255, 255, 255, 0.55) inset;
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
z-index: 20;
|
||||
}
|
||||
.player-panel-arrow {
|
||||
position: absolute;
|
||||
left: 18px;
|
||||
bottom: -7px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: rgba(255, 252, 247, 0.9);
|
||||
transform: rotate(45deg);
|
||||
border-radius: 3px;
|
||||
box-shadow: 2px 2px 6px rgba(92, 74, 53, 0.08);
|
||||
}
|
||||
.player-panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
.player-panel-title {
|
||||
font-size: 14px;
|
||||
color: #5c4a35;
|
||||
letter-spacing: 0.06em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 600;
|
||||
}
|
||||
.player-panel-close {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
color: #9a9084;
|
||||
font-size: 12px;
|
||||
background: rgba(168, 140, 107, 0.1);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.player-panel-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.player-play-btn {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(145deg, #c4a484, #a88c6b);
|
||||
color: #fff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
box-shadow: 0 6px 14px rgba(168, 140, 107, 0.35);
|
||||
font-size: 14px;
|
||||
}
|
||||
.player-play-btn .fa-play { margin-left: 2px; }
|
||||
.player-seek-wrap { flex: 1; min-width: 0; }
|
||||
.player-time {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 10px;
|
||||
color: #9a9084;
|
||||
margin-top: 4px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
.player-vol-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(168, 140, 107, 0.08);
|
||||
color: #a88c6b;
|
||||
font-size: 12px;
|
||||
}
|
||||
.player-range {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: rgba(168, 140, 107, 0.22);
|
||||
outline: none;
|
||||
}
|
||||
.player-range::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
border: 2px solid #a88c6b;
|
||||
box-shadow: 0 2px 6px rgba(92, 74, 53, 0.2);
|
||||
cursor: pointer;
|
||||
}
|
||||
.player-range::-moz-range-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
border: 2px solid #a88c6b;
|
||||
box-shadow: 0 2px 6px rgba(92, 74, 53, 0.2);
|
||||
cursor: pointer;
|
||||
}
|
||||
.player-vol { flex: 1; }
|
||||
.player-lrc-list {
|
||||
margin-top: 12px;
|
||||
max-height: 148px;
|
||||
overflow-y: auto;
|
||||
padding: 10px 8px;
|
||||
border-radius: 18px;
|
||||
background: rgba(168, 140, 107, 0.07);
|
||||
border: none;
|
||||
}
|
||||
.player-lrc-line {
|
||||
margin: 0;
|
||||
padding: 7px 8px;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
color: #9a9084;
|
||||
text-align: center;
|
||||
transition: color 0.2s, transform 0.2s, background 0.2s;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.player-lrc-line.active {
|
||||
color: #5c4a35;
|
||||
font-weight: 600;
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
.player-lrc-empty {
|
||||
margin: 0;
|
||||
padding: 18px 8px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #9a9084;
|
||||
}
|
||||
@keyframes vinyl-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.danmaku-color-row {
|
||||
display: flex; flex-wrap: wrap; gap: 10px; padding: 2px 2px 0;
|
||||
}
|
||||
@@ -2003,3 +2353,4 @@ watch(
|
||||
100% { opacity: 0; transform: translateY(-12px) scale(.94); filter: blur(1.4px); }
|
||||
}
|
||||
</style>
|
||||
| ||||