1. 播放器歌词效果

This commit is contained in:
李琦
2026-08-01 18:13:02 +08:00
parent 33027cf61e
commit b4dc67fda1
2 changed files with 169 additions and 42 deletions

View File

@@ -84,6 +84,28 @@ export function useAudio() {
}
}
const currentIndex = computed(() => tracks.value.findIndex((t) => t.url === activeUrl.value))
const playAt = (index) => {
const list = tracks.value
if (!list.length) return
const i = ((index % list.length) + list.length) % list.length
const url = list[i]?.url
if (!url) return
if (isPlaying.value) playUrl(url)
else setActive(url)
}
const prev = () => {
const idx = currentIndex.value
playAt(idx < 0 ? 0 : idx - 1)
}
const next = () => {
const idx = currentIndex.value
playAt(idx < 0 ? 0 : idx + 1)
}
const play = () => playUrl(activeUrl.value)
const attemptAutoplay = async () => {
@@ -154,8 +176,11 @@ export function useAudio() {
currentTime,
duration,
volume,
currentIndex,
setTracks,
setActive,
prev,
next,
play,
pause,
toggle,

View File

@@ -46,25 +46,8 @@
<!-- 顶部滚动进度条 -->
<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 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">
<div v-for="t in audio.tracks.value" :key="t.url"
class="track-item" :class="{ active: t.url === audio.activeUrl.value }"
@click="selectTrack(t.url)">
<span class="eq" v-if="t.url === audio.activeUrl.value && audio.isPlaying.value"><i></i><i></i><i></i></span>
<span class="tname">{{ t.name }}</span>
<span class="tcheck" v-if="t.url === audio.activeUrl.value"></span>
</div>
</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" />
@@ -166,17 +149,52 @@
</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-transport">
<button type="button" class="player-skip-btn" title="上一首" :disabled="audio.tracks.value.length < 2" @click="playPrevTrack">
<i class="fa-solid fa-backward-step" />
</button>
<button type="button" class="player-play-btn" @click="audio.toggle()">
<i :class="audio.isPlaying.value ? 'fa-solid fa-pause' : 'fa-solid fa-play'" />
</button>
<button type="button" class="player-skip-btn" title="下一首" :disabled="audio.tracks.value.length < 2" @click="playNextTrack">
<i class="fa-solid fa-forward-step" />
</button>
</div>
<div class="player-panel-sliders">
<div class="player-vol-row">
<button type="button" class="player-vol-icon" title="音量" @click="toggleMute">
<div class="player-list-wrap">
<button
v-if="audio.tracks.value.length > 1"
type="button"
class="player-vol-icon"
title="曲目列表"
@click="showPanelTrackList = !showPanelTrackList; showVolumeSlider = false"
>
<i class="fa-solid fa-list-ul" />
</button>
<div v-if="showPanelTrackList && audio.tracks.value.length" class="player-track-pop">
<div class="player-track-pop-title">选择曲目</div>
<div class="player-track-pop-list">
<button
v-for="t in audio.tracks.value"
:key="t.url"
type="button"
class="player-track-item"
:class="{ active: t.url === audio.activeUrl.value }"
@click="selectTrack(t.url)"
>
<span class="tname">{{ t.name }}</span>
<span v-if="t.url === audio.activeUrl.value" class="tcheck"></span>
</button>
</div>
</div>
</div>
<button type="button" class="player-vol-icon" title="音量" @click="toggleVolumeSlider">
<i :class="volumeIconClass" />
</button>
<div class="player-vol-track">
<div v-if="showVolumeSlider" class="player-vol-track">
<input
type="range"
class="player-vol-range"
@@ -606,6 +624,8 @@ const showOpenInBrowser = ref(false)
const showBlessingDrawer = ref(false)
const blessingSubmitted = ref(false)
const showPlayerPanel = ref(false)
const showVolumeSlider = ref(false)
const showPanelTrackList = ref(false)
const lrcLines = ref([])
const lrcListRef = ref(null)
const danmakuLayerRef = ref(null)
@@ -622,14 +642,17 @@ const volumeIconClass = computed(() => {
if (v < 0.45) return 'fa-solid fa-volume-low'
return 'fa-solid fa-volume-high'
})
let lastVolumeBeforeMute = 0.5
const toggleMute = () => {
if ((audio.volume.value || 0) > 0.001) {
lastVolumeBeforeMute = audio.volume.value
audio.setVolume(0)
} else {
audio.setVolume(lastVolumeBeforeMute || 0.5)
}
const toggleVolumeSlider = () => {
showVolumeSlider.value = !showVolumeSlider.value
if (showVolumeSlider.value) showPanelTrackList.value = false
}
const playPrevTrack = () => {
audio.prev()
if (data.value.musicRandomEnabled && audio.activeUrl.value) rememberMusicPick(audio.activeUrl.value)
}
const playNextTrack = () => {
audio.next()
if (data.value.musicRandomEnabled && audio.activeUrl.value) rememberMusicPick(audio.activeUrl.value)
}
const activeLrcIndex = computed(() => findLrcIndex(lrcLines.value, audio.currentTime.value))
@@ -650,6 +673,8 @@ const loadLrcForActive = async () => {
const openPlayerPanel = async () => {
showPlayerPanel.value = true
showVolumeSlider.value = false
showPanelTrackList.value = false
await loadLrcForActive()
}
@@ -1024,6 +1049,7 @@ const openInvitation = () => {
}
const selectTrack = (url) => {
audio.setActive(url)
showPanelTrackList.value = false
showTrackList.value = false
if (data.value.musicRandomEnabled) rememberMusicPick(url)
}
@@ -1445,7 +1471,7 @@ watch(
width: min(82vw, 308px);
padding: 14px 14px 12px;
border-radius: 26px;
background: rgba(255, 252, 247, 0.82);
background: rgba(255, 252, 247, 0.39);
border: none;
box-shadow:
0 16px 40px rgba(92, 74, 53, 0.16),
@@ -1510,7 +1536,7 @@ watch(
flex: 0 0 auto;
}
.player-lrc-list {
max-height: 132px;
max-height: 250px;
overflow-y: auto;
padding: 10px 8px;
border-radius: 18px;
@@ -1520,9 +1546,10 @@ watch(
.player-lrc-line {
margin: 0;
padding: 7px 8px;
font-family: var(--font-calligraphy);
font-size: 12px;
line-height: 1.55;
color: #9a9084;
color: rgb(154, 144, 132);
text-align: center;
transition: color 0.2s, transform 0.2s, background 0.2s;
border-radius: 999px;
@@ -1530,7 +1557,7 @@ watch(
.player-lrc-line.active {
color: #5c4a35;
font-weight: 600;
background: rgba(255, 255, 255, 0.55);
background: rgba(255, 255, 255, 0.36);
transform: scale(1.02);
}
.player-lrc-empty {
@@ -1543,12 +1570,33 @@ watch(
.player-panel-footer {
display: flex;
align-items: center;
gap: 12px;
gap: 10px;
margin-top: 12px;
}
.player-transport {
display: flex;
align-items: center;
gap: 4px;
flex: 0 0 auto;
}
.player-skip-btn {
width: 30px;
height: 30px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
color: #a88c6b;
font-size: 12px;
background: rgba(168, 140, 107, 0.1);
}
.player-skip-btn:disabled {
opacity: 0.35;
pointer-events: none;
}
.player-play-btn {
width: 46px;
height: 46px;
width: 42px;
height: 42px;
border-radius: 999px;
background: linear-gradient(145deg, #c4a484, #a88c6b);
color: #fff;
@@ -1557,7 +1605,7 @@ watch(
justify-content: center;
flex: 0 0 auto;
box-shadow: 0 6px 14px rgba(168, 140, 107, 0.35);
font-size: 15px;
font-size: 14px;
}
.player-play-btn .fa-play { margin-left: 2px; }
.player-panel-sliders {
@@ -1570,8 +1618,13 @@ watch(
.player-vol-row {
display: flex;
align-items: center;
gap: 6px;
gap: 4px;
color: #a88c6b;
min-height: 28px;
}
.player-list-wrap {
position: relative;
flex: 0 0 auto;
}
.player-vol-icon {
width: 28px;
@@ -1583,8 +1636,57 @@ watch(
color: #a88c6b;
font-size: 12px;
flex: 0 0 auto;
background: transparent;
background: rgba(168, 140, 107, 0.1);
}
.player-track-pop {
position: absolute;
left: 0;
bottom: calc(100% + 8px);
width: min(70vw, 220px);
max-height: 180px;
padding: 10px 8px;
border-radius: 16px;
background: rgba(255, 252, 247, 0.96);
box-shadow: 0 12px 28px rgba(92, 74, 53, 0.16);
z-index: 30;
display: flex;
flex-direction: column;
}
.player-track-pop-title {
font-size: 11px;
color: #9a9084;
letter-spacing: 0.12em;
padding: 0 6px 6px;
}
.player-track-pop-list {
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 2px;
}
.player-track-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
text-align: left;
padding: 8px 10px;
border-radius: 12px;
font-size: 12px;
color: #5c4a35;
}
.player-track-item.active {
background: rgba(168, 140, 107, 0.14);
color: #a88c6b;
font-weight: 600;
}
.player-track-item .tname {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.player-track-item .tcheck { font-size: 11px; }
.player-vol-track {
flex: 1;
min-width: 0;