1. 播放器

This commit is contained in:
李琦
2026-08-01 17:59:07 +08:00
parent 366e3b329f
commit d824b9945e
9 changed files with 621 additions and 67 deletions

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,
}
}

View 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
}

View File

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

View File

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