1. 播放器
This commit is contained in:
Binary file not shown.
@@ -700,8 +700,15 @@ func main() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
api.GET("/danmaku", func(c *gin.Context) {
|
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
|
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})
|
c.JSON(200, gin.H{"code": 200, "data": list})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -80,8 +80,8 @@ export function getRsvpList() {
|
|||||||
return service.get('/rsvp/list')
|
return service.get('/rsvp/list')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDanmaku() {
|
export function getDanmaku(params) {
|
||||||
return service.get('/danmaku')
|
return service.get('/danmaku', { params })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function submitDanmaku(form) {
|
export function submitDanmaku(form) {
|
||||||
|
|||||||
@@ -39,12 +39,29 @@ let pollTimer = null
|
|||||||
let uid = 0
|
let uid = 0
|
||||||
/** @type {Map<string, number>} */
|
/** @type {Map<string, number>} */
|
||||||
const lastShownAt = new Map()
|
const lastShownAt = new Map()
|
||||||
|
/** 本批已飘过的业务弹幕 id */
|
||||||
|
const shownIds = new Set()
|
||||||
|
|
||||||
const itemKey = (item) => {
|
const itemKey = (item) => {
|
||||||
if (item?.id != null) return String(item.id)
|
if (item?.id != null) return String(item.id)
|
||||||
return `${item?.name || ''}|${item?.content || ''}|${item?.created_at || ''}`
|
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 isCooling = (key) => {
|
||||||
const t = lastShownAt.get(key)
|
const t = lastShownAt.get(key)
|
||||||
return t != null && Date.now() - t < DEDUPE_MS
|
return t != null && Date.now() - t < DEDUPE_MS
|
||||||
@@ -66,10 +83,15 @@ const pickNextItem = () => {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const markShown = (item) => {
|
||||||
|
if (item?.id != null) shownIds.add(Number(item.id))
|
||||||
|
}
|
||||||
|
|
||||||
const buildRow = (item, { force = false } = {}) => {
|
const buildRow = (item, { force = false } = {}) => {
|
||||||
const key = itemKey(item)
|
const key = itemKey(item)
|
||||||
if (!force && isCooling(key)) return null
|
if (!force && isCooling(key)) return null
|
||||||
lastShownAt.set(key, Date.now())
|
lastShownAt.set(key, Date.now())
|
||||||
|
markShown(item)
|
||||||
|
|
||||||
const tint = resolveDanmakuTint(item.color)
|
const tint = resolveDanmakuTint(item.color)
|
||||||
const track = Math.floor(Math.random() * TRACKS)
|
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 delay = force ? 0 : Math.random() * 0.4
|
||||||
const id = ++uid
|
const id = ++uid
|
||||||
|
|
||||||
// 发送者刚插入的弹幕强制「刚刚」,避免服务端时间解析偏差
|
|
||||||
const timeLabel = force
|
const timeLabel = force
|
||||||
? '刚刚'
|
? '刚刚'
|
||||||
: formatRelativeTime(item.created_at || Date.now())
|
: 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 () => {
|
const fetchList = async () => {
|
||||||
if (!props.enabled) {
|
if (!props.enabled) {
|
||||||
items.value = []
|
items.value = []
|
||||||
|
shownIds.clear()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
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 res = await getDanmaku()
|
||||||
const next = Array.isArray(res.data) ? res.data : []
|
const next = Array.isArray(res.data) ? res.data : []
|
||||||
items.value = next
|
items.value = next
|
||||||
if (next.length && cursor >= next.length) cursor = cursor % next.length
|
shownIds.clear()
|
||||||
|
cursor = 0
|
||||||
|
}
|
||||||
|
if (items.value.length && cursor >= items.value.length) cursor = cursor % items.value.length
|
||||||
} catch {
|
} catch {
|
||||||
/* keep previous items on transient failure */
|
/* keep previous items on transient failure */
|
||||||
}
|
}
|
||||||
@@ -177,6 +218,7 @@ watch(
|
|||||||
rows.value = []
|
rows.value = []
|
||||||
items.value = []
|
items.value = []
|
||||||
lastShownAt.clear()
|
lastShownAt.clear()
|
||||||
|
shownIds.clear()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -195,6 +237,7 @@ watch(
|
|||||||
rows.value = []
|
rows.value = []
|
||||||
items.value = []
|
items.value = []
|
||||||
lastShownAt.clear()
|
lastShownAt.clear()
|
||||||
|
shownIds.clear()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -236,7 +279,6 @@ defineExpose({ refresh: fetchList, pushItem })
|
|||||||
animation-timing-function: linear;
|
animation-timing-function: linear;
|
||||||
animation-fill-mode: forwards;
|
animation-fill-mode: forwards;
|
||||||
backdrop-filter: blur(6px);
|
backdrop-filter: blur(6px);
|
||||||
/* 不裁切宽度:长祝福时末尾相对时间需完整露出并随整条飞过 */
|
|
||||||
width: max-content;
|
width: max-content;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ const burst = (count = 3) => {
|
|||||||
const id = ++uid
|
const id = ++uid
|
||||||
const left = 10 + Math.random() * 200
|
const left = 10 + Math.random() * 200
|
||||||
const drift = (Math.random() - 0.5) * 90
|
const drift = (Math.random() - 0.5) * 90
|
||||||
const duration = 1.25 + Math.random() * 0.7
|
const duration = 2.8 + Math.random() * 1.0
|
||||||
const delay = i * 0.045
|
const delay = i * 0.1
|
||||||
const scale = 0.8 + Math.random() * 0.55
|
const scale = 0.8 + Math.random() * 0.55
|
||||||
hearts.value.push({
|
hearts.value.push({
|
||||||
id,
|
id,
|
||||||
@@ -82,7 +82,7 @@ defineExpose({ burst })
|
|||||||
}
|
}
|
||||||
100% {
|
100% {
|
||||||
opacity: 0;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -2,52 +2,70 @@ import { ref, computed, onUnmounted } from 'vue'
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 背景音乐控制 composable(支持多曲目)
|
* 背景音乐控制 composable(支持多曲目)
|
||||||
* - tracks: [{ url, name }] 播放列表
|
* - tracks: [{ url, name, coverUrl?, lrcUrl? }]
|
||||||
* - activeUrl: 当前选中的曲目地址
|
* - activeUrl: 当前选中的曲目地址
|
||||||
* - 处理浏览器自动播放限制:首次用户交互后再尝试播放
|
* - 处理浏览器自动播放限制:首次用户交互后再尝试播放
|
||||||
*/
|
*/
|
||||||
export function useAudio() {
|
export function useAudio() {
|
||||||
const isPlaying = ref(false)
|
const isPlaying = ref(false)
|
||||||
const isReady = ref(false)
|
const isReady = ref(false)
|
||||||
const tracks = ref([]) // [{ url, name }]
|
const tracks = ref([])
|
||||||
const activeUrl = ref('') // 当前选中曲目 url
|
const activeUrl = ref('')
|
||||||
|
const currentTime = ref(0)
|
||||||
|
const duration = ref(0)
|
||||||
|
const volume = ref(0.5)
|
||||||
let audio = null
|
let audio = null
|
||||||
let unlockHandler = null
|
let unlockHandler = null
|
||||||
|
|
||||||
const currentName = computed(() => {
|
const currentTrack = computed(() => tracks.value.find((t) => t.url === activeUrl.value) || null)
|
||||||
const t = tracks.value.find((t) => t.url === activeUrl.value)
|
const currentName = computed(() => currentTrack.value?.name || '背景音乐')
|
||||||
return t?.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 = () => {
|
const ensureAudio = () => {
|
||||||
if (audio) return audio
|
if (audio) return audio
|
||||||
audio = new Audio()
|
audio = new Audio()
|
||||||
audio.loop = true
|
audio.loop = true
|
||||||
audio.volume = 0.5
|
audio.volume = volume.value
|
||||||
audio.preload = 'auto'
|
audio.preload = 'auto'
|
||||||
audio.addEventListener('canplay', () => (isReady.value = true))
|
audio.addEventListener('canplay', () => {
|
||||||
audio.addEventListener('play', () => (isPlaying.value = true))
|
isReady.value = true
|
||||||
audio.addEventListener('pause', () => (isPlaying.value = false))
|
syncMeta()
|
||||||
|
})
|
||||||
|
audio.addEventListener('loadedmetadata', syncMeta)
|
||||||
|
audio.addEventListener('timeupdate', syncMeta)
|
||||||
|
audio.addEventListener('play', () => { isPlaying.value = true })
|
||||||
|
audio.addEventListener('pause', () => { isPlaying.value = false })
|
||||||
return audio
|
return audio
|
||||||
}
|
}
|
||||||
|
|
||||||
const playUrl = async (url) => {
|
const playUrl = async (url) => {
|
||||||
if (!url) return false
|
if (!url) return false
|
||||||
const a = ensureAudio()
|
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
|
activeUrl.value = url
|
||||||
try {
|
try {
|
||||||
await a.play()
|
await a.play()
|
||||||
isPlaying.value = true
|
isPlaying.value = true
|
||||||
|
syncMeta()
|
||||||
return true
|
return true
|
||||||
} catch (e) {
|
} catch {
|
||||||
// 被浏览器拦截,等待用户手势
|
|
||||||
isPlaying.value = false
|
isPlaying.value = false
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置播放列表与默认选中项
|
|
||||||
const setTracks = (list, active) => {
|
const setTracks = (list, active) => {
|
||||||
tracks.value = Array.isArray(list) ? list.slice().filter((t) => t && t.url) : []
|
tracks.value = Array.isArray(list) ? list.slice().filter((t) => t && t.url) : []
|
||||||
const first = tracks.value[0]?.url || ''
|
const first = tracks.value[0]?.url || ''
|
||||||
@@ -56,16 +74,18 @@ export function useAudio() {
|
|||||||
if (audio && isPlaying.value && chosen) playUrl(chosen)
|
if (audio && isPlaying.value && chosen) playUrl(chosen)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 切换当前播放曲目(若正在播放则立即切换)
|
|
||||||
const setActive = (url) => {
|
const setActive = (url) => {
|
||||||
if (!tracks.value.some((t) => t.url === url)) return
|
if (!tracks.value.some((t) => t.url === url)) return
|
||||||
if (isPlaying.value) playUrl(url)
|
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)
|
const play = () => playUrl(activeUrl.value)
|
||||||
|
|
||||||
// 尝试自动播放,返回是否成功(被浏览器拦截则返回 false,由调用方决定是否弹授权框)
|
|
||||||
const attemptAutoplay = async () => {
|
const attemptAutoplay = async () => {
|
||||||
if (!activeUrl.value) return false
|
if (!activeUrl.value) return false
|
||||||
return playUrl(activeUrl.value)
|
return playUrl(activeUrl.value)
|
||||||
@@ -80,7 +100,22 @@ export function useAudio() {
|
|||||||
else play()
|
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 armAutoplay = () => {
|
||||||
const handler = async () => {
|
const handler = async () => {
|
||||||
const ok = await play()
|
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>
|
||||||
<a-button size="small" danger @click="removeMusic(i)"><i class="fa-solid fa-trash"></i></a-button>
|
<a-button size="small" danger @click="removeMusic(i)"><i class="fa-solid fa-trash"></i></a-button>
|
||||||
</div>
|
</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>
|
<audio v-if="t.url" :src="t.url" controls class="w-full mt-3" style="height: 36px;"></audio>
|
||||||
</a-card>
|
</a-card>
|
||||||
<a-button class="mt-1" @click="openMusicUpload">
|
<a-button class="mt-1" @click="openMusicUpload">
|
||||||
@@ -658,7 +679,7 @@ function openMusicUpload() {
|
|||||||
const name = file.name.replace(/\.[^.]+$/, '') || '背景音乐'
|
const name = file.name.replace(/\.[^.]+$/, '') || '背景音乐'
|
||||||
const url = res.url
|
const url = res.url
|
||||||
if (!cfg.musicList) cfg.musicList = []
|
if (!cfg.musicList) cfg.musicList = []
|
||||||
cfg.musicList.push({ url, name })
|
cfg.musicList.push({ url, name, coverUrl: '', lrcUrl: '' })
|
||||||
if (!cfg.activeMusicUrl) cfg.activeMusicUrl = url
|
if (!cfg.activeMusicUrl) cfg.activeMusicUrl = url
|
||||||
message.success('音乐上传成功')
|
message.success('音乐上传成功')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -669,6 +690,29 @@ function openMusicUpload() {
|
|||||||
}
|
}
|
||||||
input.click()
|
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 setActiveMusic(url) { cfg.activeMusicUrl = url }
|
||||||
function removeMusic(i) {
|
function removeMusic(i) {
|
||||||
cfg.musicList.splice(i, 1)
|
cfg.musicList.splice(i, 1)
|
||||||
@@ -801,9 +845,16 @@ onMounted(async () => {
|
|||||||
const loaded = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
|
const loaded = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
|
||||||
// 兼容旧版单链接 musicUrl
|
// 兼容旧版单链接 musicUrl
|
||||||
if (loaded.musicUrl && !(loaded.musicList && loaded.musicList.length)) {
|
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
|
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
|
loaded.endImg = loaded.endImg || loaded.heroImg || cfg.endImg
|
||||||
Object.assign(cfg, loaded)
|
Object.assign(cfg, loaded)
|
||||||
cfg.formalPageHeight = cfg.formalPageHeight || '100vh'
|
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-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="fixed top-6 right-5 z-50 flex flex-col items-end gap-4">
|
||||||
<!-- 音乐控制 -->
|
|
||||||
<div class="music-wrap">
|
<div class="music-wrap">
|
||||||
<div class="flex flex-col gap-3 items-end">
|
<div v-if="audio.tracks.value.length > 1" @click="showTrackList = !showTrackList" class="control-btn cursor-pointer" title="曲目">
|
||||||
<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" />
|
<img src="https://api.iconify.design/lucide:list-music.svg?color=%23a88c6b" class="w-4 h-4" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<!-- 曲目切换弹层 -->
|
|
||||||
<div v-if="showTrackList && audio.tracks.value.length" class="track-pop">
|
<div v-if="showTrackList && audio.tracks.value.length" class="track-pop">
|
||||||
<div class="track-pop-title">选择曲目</div>
|
<div class="track-pop-title">选择曲目</div>
|
||||||
<div class="track-pop-list">
|
<div class="track-pop-list">
|
||||||
@@ -73,18 +65,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div @click="toggleAutoScroll" class="control-btn cursor-pointer transition-all duration-300"
|
<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'">
|
: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-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" />
|
<img v-else src="https://api.iconify.design/lucide:play.svg?color=%23a88c6b" class="w-4 h-4 ml-0.5" />
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<!-- 底部互动区:点赞 / 填写 / 评论条 -->
|
<!-- 底部互动区 -->
|
||||||
<div
|
<div
|
||||||
class="action-dock"
|
class="action-dock"
|
||||||
@touchstart.stop
|
@touchstart.stop
|
||||||
@@ -93,8 +81,8 @@
|
|||||||
@mousedown.stop
|
@mousedown.stop
|
||||||
@click.stop
|
@click.stop
|
||||||
>
|
>
|
||||||
<!-- 右下点赞邻域隐形遮罩:吞掉穿透到页面的触摸,避免误触预览/暂停 -->
|
|
||||||
<div class="like-side-shield" aria-hidden="true" />
|
<div class="like-side-shield" aria-hidden="true" />
|
||||||
|
<!-- 行1:点赞 -->
|
||||||
<div class="action-dock-row">
|
<div class="action-dock-row">
|
||||||
<div class="like-hit-zone">
|
<div class="like-hit-zone">
|
||||||
<button
|
<button
|
||||||
@@ -109,6 +97,17 @@
|
|||||||
<span class="like-count">{{ likeCount }}</span>
|
<span class="like-count">{{ likeCount }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="action-pill action-rsvp"
|
class="action-pill action-rsvp"
|
||||||
@@ -118,6 +117,92 @@
|
|||||||
<img src="https://api.iconify.design/lucide:clipboard-pen.svg?color=%23a88c6b" class="action-pill-icon" alt="" />
|
<img src="https://api.iconify.design/lucide:clipboard-pen.svg?color=%23a88c6b" class="action-pill-icon" alt="" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 行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
|
<button
|
||||||
v-if="data.danmakuEnabled"
|
v-if="data.danmakuEnabled"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -129,6 +214,7 @@
|
|||||||
<span>写一句祝福</span>
|
<span>写一句祝福</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<LikeBurst ref="likeBurstRef" />
|
<LikeBurst ref="likeBurstRef" />
|
||||||
|
|
||||||
@@ -486,6 +572,7 @@ import { toast } from '@/composables/useToast'
|
|||||||
import { pickMusicUrl, rememberMusicPick } from '@/utils/musicPick'
|
import { pickMusicUrl, rememberMusicPick } from '@/utils/musicPick'
|
||||||
import { getClientId } from '@/utils/clientId'
|
import { getClientId } from '@/utils/clientId'
|
||||||
import { DANMAKU_COLORS, DEFAULT_DANMAKU_COLOR, BLESSING_STYLES } from '@/utils/danmakuColors'
|
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 CanvasEffects from '@/components/CanvasEffects.vue'
|
||||||
import DanmakuLayer from '@/components/DanmakuLayer.vue'
|
import DanmakuLayer from '@/components/DanmakuLayer.vue'
|
||||||
import LikeBurst from '@/components/LikeBurst.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 showOpenInBrowser = ref(false)
|
||||||
const showBlessingDrawer = ref(false)
|
const showBlessingDrawer = ref(false)
|
||||||
const blessingSubmitted = ref(false)
|
const blessingSubmitted = ref(false)
|
||||||
|
const showPlayerPanel = ref(false)
|
||||||
|
const lrcLines = ref([])
|
||||||
|
const lrcListRef = ref(null)
|
||||||
const danmakuLayerRef = 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 aiStyleOpen = ref('')
|
||||||
const aiBlessingLoading = ref(false)
|
const aiBlessingLoading = ref(false)
|
||||||
const aiBlessingTarget = ref('')
|
const aiBlessingTarget = ref('')
|
||||||
@@ -701,7 +835,7 @@ const clearBottomReturn = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const scheduleBottomReturn = (reset = false) => {
|
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
|
if (bottomReturnTimer && !reset) return
|
||||||
clearBottomReturn()
|
clearBottomReturn()
|
||||||
bottomReturnTimer = setTimeout(() => {
|
bottomReturnTimer = setTimeout(() => {
|
||||||
@@ -745,6 +879,7 @@ const canAutoAdvance = () =>
|
|||||||
&& !isInteracting.value
|
&& !isInteracting.value
|
||||||
&& !isDrawerOpen.value
|
&& !isDrawerOpen.value
|
||||||
&& !showBlessingDrawer.value
|
&& !showBlessingDrawer.value
|
||||||
|
&& !showPlayerPanel.value
|
||||||
&& !previewVisible.value
|
&& !previewVisible.value
|
||||||
&& !showMapOptions.value
|
&& !showMapOptions.value
|
||||||
&& !showOpenInBrowser.value
|
&& !showOpenInBrowser.value
|
||||||
@@ -1080,9 +1215,9 @@ watch(
|
|||||||
)
|
)
|
||||||
watch(freeScrollSignature, restartAutoScroll)
|
watch(freeScrollSignature, restartAutoScroll)
|
||||||
watch(
|
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)
|
cancelAnimationFrame(animId)
|
||||||
clearBottomReturn()
|
clearBottomReturn()
|
||||||
}
|
}
|
||||||
@@ -1140,8 +1275,8 @@ watch(
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: min(52vw, 220px);
|
width: min(56vw, 240px);
|
||||||
height: calc(132px + env(safe-area-inset-bottom, 0px));
|
height: calc(200px + env(safe-area-inset-bottom, 0px));
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -1199,11 +1334,21 @@ watch(
|
|||||||
line-height: 1; text-align: center; max-width: 100%;
|
line-height: 1; text-align: center; max-width: 100%;
|
||||||
}
|
}
|
||||||
.like-fab.liked .like-count { color: #f472b6; font-weight: 600; }
|
.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 {
|
.action-comment {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
width: 100%;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
min-height: 48px;
|
min-height: 48px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1223,6 +1368,211 @@ watch(
|
|||||||
}
|
}
|
||||||
.action-comment:active { transform: scale(0.99); }
|
.action-comment:active { transform: scale(0.99); }
|
||||||
.action-comment-icon { width: 18px; height: 18px; opacity: 0.9; }
|
.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 {
|
.danmaku-color-row {
|
||||||
display: flex; flex-wrap: wrap; gap: 10px; padding: 2px 2px 0;
|
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); }
|
100% { opacity: 0; transform: translateY(-12px) scale(.94); filter: blur(1.4px); }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
| ||||||