diff --git a/hunliji-api/hunliji-api.exe b/hunliji-api/hunliji-api.exe index 2dcd012..3a8bbe9 100644 Binary files a/hunliji-api/hunliji-api.exe and b/hunliji-api/hunliji-api.exe differ diff --git a/hunliji-api/main.go b/hunliji-api/main.go index bbe5810..497e26b 100644 --- a/hunliji-api/main.go +++ b/hunliji-api/main.go @@ -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}) }) diff --git a/vite-tailwindcss/src/api/wedding.js b/vite-tailwindcss/src/api/wedding.js index 96d3061..334f044 100644 --- a/vite-tailwindcss/src/api/wedding.js +++ b/vite-tailwindcss/src/api/wedding.js @@ -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) { diff --git a/vite-tailwindcss/src/components/DanmakuLayer.vue b/vite-tailwindcss/src/components/DanmakuLayer.vue index cd131ff..7de42e4 100644 --- a/vite-tailwindcss/src/components/DanmakuLayer.vue +++ b/vite-tailwindcss/src/components/DanmakuLayer.vue @@ -39,12 +39,29 @@ let pollTimer = null let uid = 0 /** @type {Map} */ 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; } diff --git a/vite-tailwindcss/src/components/LikeBurst.vue b/vite-tailwindcss/src/components/LikeBurst.vue index 8c1d62c..3a5fe67 100644 --- a/vite-tailwindcss/src/components/LikeBurst.vue +++ b/vite-tailwindcss/src/components/LikeBurst.vue @@ -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)); } } diff --git a/vite-tailwindcss/src/composables/useAudio.js b/vite-tailwindcss/src/composables/useAudio.js index 186988b..d0b95a2 100644 --- a/vite-tailwindcss/src/composables/useAudio.js +++ b/vite-tailwindcss/src/composables/useAudio.js @@ -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, + } } diff --git a/vite-tailwindcss/src/utils/parseLrc.js b/vite-tailwindcss/src/utils/parseLrc.js new file mode 100644 index 0000000..5ee624e --- /dev/null +++ b/vite-tailwindcss/src/utils/parseLrc.js @@ -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 +} diff --git a/vite-tailwindcss/src/views/admin/AdminEditor.vue b/vite-tailwindcss/src/views/admin/AdminEditor.vue index 357936c..c837ed4 100644 --- a/vite-tailwindcss/src/views/admin/AdminEditor.vue +++ b/vite-tailwindcss/src/views/admin/AdminEditor.vue @@ -188,6 +188,27 @@ +
+
+
封面
+ +
+
+
歌词 LRC
+
+ + {{ t.lrcUrl ? '更换歌词' : '上传歌词' }} + + 清除 +
+
{{ shortUrl(t.lrcUrl) }}
+
+
@@ -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' diff --git a/vite-tailwindcss/src/views/index/index.vue b/vite-tailwindcss/src/views/index/index.vue index a7a255a..fe57b0d 100644 --- a/vite-tailwindcss/src/views/index/index.vue +++ b/vite-tailwindcss/src/views/index/index.vue @@ -46,20 +46,12 @@
- +
-
-
-
- - -
-
- -
+
+
-
选择曲目
@@ -73,18 +65,14 @@
-
-
- -
- +
- @@ -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); } } + \ No newline at end of file