实现前端播放功能
This commit is contained in:
@@ -2,48 +2,48 @@
|
|||||||
<div class="player">
|
<div class="player">
|
||||||
<div class="player-info">
|
<div class="player-info">
|
||||||
<div class="track-info">
|
<div class="track-info">
|
||||||
<span class="track-title">{{ currentTrack.title || '未播放' }}</span>
|
<span class="track-title">{{ currentTrack.title }}</span>
|
||||||
<span class="track-artist">{{ currentTrack.artist || '-' }}</span>
|
<span class="track-artist">{{ currentTrack.artist }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="player-controls">
|
<div class="player-controls">
|
||||||
<button @click="seekBackward" class="control-btn">
|
<button @click="player.seekBackward" class="control-btn">
|
||||||
<SkipBack />
|
<SkipBack />
|
||||||
</button>
|
</button>
|
||||||
<button @click="togglePlay" class="play-btn">
|
<button @click="player.togglePlay" class="play-btn">
|
||||||
<Play v-if="!isPlaying" />
|
<Play v-if="!player.isPlaying" />
|
||||||
<Pause v-else />
|
<Pause v-else />
|
||||||
</button>
|
</button>
|
||||||
<button @click="seekForward" class="control-btn">
|
<button @click="player.seekForward" class="control-btn">
|
||||||
<SkipForward />
|
<SkipForward />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="player-progress">
|
<div class="player-progress">
|
||||||
<span class="time">{{ formatTime(currentTime) }}</span>
|
<span class="time">{{ player.formatTime(currentTime) }}</span>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min="0"
|
min="0"
|
||||||
max="100"
|
max="100"
|
||||||
:value="progress"
|
:value="progress"
|
||||||
@input="seekTo"
|
@input="onSeek"
|
||||||
class="progress-bar"
|
class="progress-bar"
|
||||||
/>
|
/>
|
||||||
<span class="time">{{ formatTime(duration) }}</span>
|
<span class="time">{{ player.formatTime(duration) }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="player-volume">
|
<div class="player-volume">
|
||||||
<button @click="toggleMute" class="volume-btn">
|
<button @click="player.toggleMute" class="volume-btn">
|
||||||
<Volume2 v-if="!isMuted && volume > 0" />
|
<Volume2 v-if="!player.isMuted && player.volume > 0" />
|
||||||
<VolumeX v-else />
|
<VolumeX v-else />
|
||||||
</button>
|
</button>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min="0"
|
min="0"
|
||||||
max="100"
|
max="100"
|
||||||
:value="isMuted ? 0 : volume"
|
:value="player.isMuted ? 0 : player.volume"
|
||||||
@input="setVolume"
|
@input="onVolumeChange"
|
||||||
class="volume-slider"
|
class="volume-slider"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -51,129 +51,24 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { Play, Pause, SkipBack, SkipForward, Volume2, VolumeX } from 'lucide-vue-next'
|
import { Play, Pause, SkipBack, SkipForward, Volume2, VolumeX } from 'lucide-vue-next'
|
||||||
|
import { usePlayerStore } from '@/stores/player'
|
||||||
|
|
||||||
const props = defineProps({
|
const player = usePlayerStore()
|
||||||
src: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
artist: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const audio = ref(null)
|
const currentTrack = computed(() => player.currentTrack)
|
||||||
const isPlaying = ref(false)
|
const currentTime = computed(() => player.currentTime)
|
||||||
const currentTime = ref(0)
|
const duration = computed(() => player.duration)
|
||||||
const duration = ref(0)
|
const progress = computed(() => player.progress)
|
||||||
const volume = ref(80)
|
|
||||||
const isMuted = ref(false)
|
|
||||||
|
|
||||||
const currentTrack = computed(() => ({
|
function onSeek(e) {
|
||||||
title: props.title,
|
player.seekTo(parseFloat(e.target.value))
|
||||||
artist: props.artist,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const progress = computed(() => {
|
|
||||||
if (duration.value === 0) return 0
|
|
||||||
return (currentTime.value / duration.value) * 100
|
|
||||||
})
|
|
||||||
|
|
||||||
const togglePlay = () => {
|
|
||||||
if (!audio.value) {
|
|
||||||
audio.value = new Audio(props.src)
|
|
||||||
setupAudioListeners()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isPlaying.value) {
|
|
||||||
audio.value.pause()
|
|
||||||
} else {
|
|
||||||
audio.value.play()
|
|
||||||
}
|
|
||||||
isPlaying.value = !isPlaying.value
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const setupAudioListeners = () => {
|
function onVolumeChange(e) {
|
||||||
if (!audio.value) return
|
player.setVolume(parseFloat(e.target.value))
|
||||||
|
|
||||||
audio.value.addEventListener('timeupdate', () => {
|
|
||||||
currentTime.value = audio.value.currentTime
|
|
||||||
})
|
|
||||||
|
|
||||||
audio.value.addEventListener('loadedmetadata', () => {
|
|
||||||
duration.value = audio.value.duration
|
|
||||||
})
|
|
||||||
|
|
||||||
audio.value.addEventListener('ended', () => {
|
|
||||||
isPlaying.value = false
|
|
||||||
currentTime.value = 0
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const seekTo = (e) => {
|
|
||||||
if (!audio.value) return
|
|
||||||
const time = (e.target.value / 100) * duration.value
|
|
||||||
audio.value.currentTime = time
|
|
||||||
}
|
|
||||||
|
|
||||||
const seekForward = () => {
|
|
||||||
if (!audio.value) return
|
|
||||||
audio.value.currentTime = Math.min(audio.value.currentTime + 10, duration.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
const seekBackward = () => {
|
|
||||||
if (!audio.value) return
|
|
||||||
audio.value.currentTime = Math.max(audio.value.currentTime - 10, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
const setVolume = (e) => {
|
|
||||||
const newVolume = e.target.value
|
|
||||||
volume.value = newVolume
|
|
||||||
if (audio.value) {
|
|
||||||
audio.value.volume = newVolume / 100
|
|
||||||
}
|
|
||||||
if (newVolume > 0 && isMuted.value) {
|
|
||||||
isMuted.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleMute = () => {
|
|
||||||
isMuted.value = !isMuted.value
|
|
||||||
if (audio.value) {
|
|
||||||
audio.value.volume = isMuted.value ? 0 : volume.value / 100
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatTime = (seconds) => {
|
|
||||||
if (!seconds || isNaN(seconds)) return '0:00'
|
|
||||||
const mins = Math.floor(seconds / 60)
|
|
||||||
const secs = Math.floor(seconds % 60)
|
|
||||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.src,
|
|
||||||
(newSrc) => {
|
|
||||||
if (audio.value) {
|
|
||||||
audio.value.src = newSrc
|
|
||||||
currentTime.value = 0
|
|
||||||
isPlaying.value = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
watch(volume, (newVolume) => {
|
|
||||||
if (audio.value && !isMuted.value) {
|
|
||||||
audio.value.volume = newVolume / 100
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -5,10 +5,15 @@ import { createPinia } from 'pinia'
|
|||||||
|
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
|
import { usePlayerStore } from './stores/player'
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
|
||||||
app.use(createPinia())
|
const pinia = createPinia()
|
||||||
|
app.use(pinia)
|
||||||
app.use(router)
|
app.use(router)
|
||||||
|
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
|
||||||
|
const playerStore = usePlayerStore()
|
||||||
|
playerStore.restorePlaybackState()
|
||||||
|
|||||||
@@ -1,11 +1,244 @@
|
|||||||
import { ref, computed } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
|
|
||||||
export const usePlayerStore = defineStore('counter', () => {
|
const STORAGE_KEY = 'butterfliu_player_state'
|
||||||
|
|
||||||
|
function saveState(state) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to save player state:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadState() {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (saved) {
|
||||||
|
return JSON.parse(saved)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load player state:', e)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePlayerStore = defineStore('player', () => {
|
||||||
|
const audio = ref(null)
|
||||||
const currentTime = ref(0)
|
const currentTime = ref(0)
|
||||||
const duration = ref(0)
|
const duration = ref(0)
|
||||||
const volume = ref(80)
|
const volume = ref(80)
|
||||||
const progress = computed(() => (currentTime.value / (duration.value + 1)) * 100)
|
const isMuted = ref(false)
|
||||||
|
const isPlaying = ref(false)
|
||||||
|
const song = ref(null)
|
||||||
|
const playlist = ref([])
|
||||||
|
|
||||||
return { currentTime, duration, progress, volume }
|
const progress = computed(() => {
|
||||||
|
if (duration.value === 0) return 0
|
||||||
|
return (currentTime.value / duration.value) * 100
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentTrack = computed(() => ({
|
||||||
|
title: song.value?.title || '未播放',
|
||||||
|
artist: song.value?.artist || '-',
|
||||||
|
}))
|
||||||
|
|
||||||
|
function setupAudioListeners() {
|
||||||
|
if (!audio.value) return
|
||||||
|
|
||||||
|
audio.value.addEventListener('timeupdate', () => {
|
||||||
|
currentTime.value = audio.value.currentTime
|
||||||
|
savePlaybackProgress()
|
||||||
|
})
|
||||||
|
|
||||||
|
audio.value.addEventListener('loadedmetadata', () => {
|
||||||
|
duration.value = audio.value.duration
|
||||||
|
})
|
||||||
|
|
||||||
|
audio.value.addEventListener('ended', () => {
|
||||||
|
isPlaying.value = false
|
||||||
|
currentTime.value = 0
|
||||||
|
savePlaybackProgress()
|
||||||
|
playNext()
|
||||||
|
})
|
||||||
|
|
||||||
|
audio.value.addEventListener('play', () => {
|
||||||
|
isPlaying.value = true
|
||||||
|
savePlaybackProgress()
|
||||||
|
})
|
||||||
|
|
||||||
|
audio.value.addEventListener('pause', () => {
|
||||||
|
isPlaying.value = false
|
||||||
|
savePlaybackProgress()
|
||||||
|
})
|
||||||
|
|
||||||
|
audio.value.volume = volume.value / 100
|
||||||
|
}
|
||||||
|
|
||||||
|
function savePlaybackProgress() {
|
||||||
|
if (!song.value) return
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
song: song.value,
|
||||||
|
currentTime: currentTime.value,
|
||||||
|
volume: volume.value,
|
||||||
|
isMuted: isMuted.value,
|
||||||
|
playlist: playlist.value,
|
||||||
|
}
|
||||||
|
saveState(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
function restorePlaybackState() {
|
||||||
|
const saved = loadState()
|
||||||
|
if (!saved || !saved.song) return
|
||||||
|
|
||||||
|
song.value = saved.song
|
||||||
|
volume.value = saved.volume ?? 80
|
||||||
|
isMuted.value = saved.isMuted ?? false
|
||||||
|
playlist.value = saved.playlist || []
|
||||||
|
|
||||||
|
const src = `/api/songs/${saved.song.id}/stream`
|
||||||
|
|
||||||
|
if (!audio.value) {
|
||||||
|
audio.value = new Audio(src)
|
||||||
|
setupAudioListeners()
|
||||||
|
} else {
|
||||||
|
audio.value.src = src
|
||||||
|
}
|
||||||
|
|
||||||
|
audio.value.volume = isMuted.value ? 0 : volume.value / 100
|
||||||
|
|
||||||
|
audio.value.addEventListener('loadedmetadata', () => {
|
||||||
|
duration.value = audio.value.duration
|
||||||
|
if (saved.currentTime > 0 && saved.currentTime < duration.value) {
|
||||||
|
audio.value.currentTime = saved.currentTime
|
||||||
|
currentTime.value = saved.currentTime
|
||||||
|
}
|
||||||
|
}, { once: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
function playSong(newSong, newPlaylist = null) {
|
||||||
|
if (newPlaylist) {
|
||||||
|
playlist.value = newPlaylist
|
||||||
|
}
|
||||||
|
|
||||||
|
song.value = newSong
|
||||||
|
const src = `/api/songs/${newSong.id}/stream`
|
||||||
|
|
||||||
|
if (!audio.value) {
|
||||||
|
audio.value = new Audio(src)
|
||||||
|
setupAudioListeners()
|
||||||
|
} else {
|
||||||
|
audio.value.src = src
|
||||||
|
}
|
||||||
|
|
||||||
|
currentTime.value = 0
|
||||||
|
audio.value.play()
|
||||||
|
savePlaybackProgress()
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePlay() {
|
||||||
|
if (!audio.value) return
|
||||||
|
|
||||||
|
if (isPlaying.value) {
|
||||||
|
audio.value.pause()
|
||||||
|
} else {
|
||||||
|
audio.value.play()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function seekTo(percent) {
|
||||||
|
if (!audio.value) return
|
||||||
|
const time = (percent / 100) * duration.value
|
||||||
|
audio.value.currentTime = time
|
||||||
|
savePlaybackProgress()
|
||||||
|
}
|
||||||
|
|
||||||
|
function seekForward(seconds = 10) {
|
||||||
|
if (!audio.value) return
|
||||||
|
audio.value.currentTime = Math.min(audio.value.currentTime + seconds, duration.value)
|
||||||
|
savePlaybackProgress()
|
||||||
|
}
|
||||||
|
|
||||||
|
function seekBackward(seconds = 10) {
|
||||||
|
if (!audio.value) return
|
||||||
|
audio.value.currentTime = Math.max(audio.value.currentTime - seconds, 0)
|
||||||
|
savePlaybackProgress()
|
||||||
|
}
|
||||||
|
|
||||||
|
function setVolume(newVolume) {
|
||||||
|
volume.value = newVolume
|
||||||
|
if (audio.value) {
|
||||||
|
audio.value.volume = isMuted.value ? 0 : newVolume / 100
|
||||||
|
}
|
||||||
|
if (newVolume > 0 && isMuted.value) {
|
||||||
|
isMuted.value = false
|
||||||
|
}
|
||||||
|
savePlaybackProgress()
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMute() {
|
||||||
|
isMuted.value = !isMuted.value
|
||||||
|
if (audio.value) {
|
||||||
|
audio.value.volume = isMuted.value ? 0 : volume.value / 100
|
||||||
|
}
|
||||||
|
savePlaybackProgress()
|
||||||
|
}
|
||||||
|
|
||||||
|
function playNext() {
|
||||||
|
if (playlist.value.length === 0) return
|
||||||
|
|
||||||
|
const currentIndex = playlist.value.findIndex(s => s.id === song.value?.id)
|
||||||
|
if (currentIndex === -1) return
|
||||||
|
|
||||||
|
const nextIndex = (currentIndex + 1) % playlist.value.length
|
||||||
|
playSong(playlist.value[nextIndex], playlist.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function playPrevious() {
|
||||||
|
if (playlist.value.length === 0) return
|
||||||
|
|
||||||
|
const currentIndex = playlist.value.findIndex(s => s.id === song.value?.id)
|
||||||
|
if (currentIndex === -1) return
|
||||||
|
|
||||||
|
const prevIndex = currentIndex === 0 ? playlist.value.length - 1 : currentIndex - 1
|
||||||
|
playSong(playlist.value[prevIndex], playlist.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(seconds) {
|
||||||
|
if (!seconds || isNaN(seconds)) return '0:00'
|
||||||
|
const mins = Math.floor(seconds / 60)
|
||||||
|
const secs = Math.floor(seconds % 60)
|
||||||
|
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(volume, (newVolume) => {
|
||||||
|
if (audio.value && !isMuted.value) {
|
||||||
|
audio.value.volume = newVolume / 100
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
audio,
|
||||||
|
currentTime,
|
||||||
|
duration,
|
||||||
|
volume,
|
||||||
|
isMuted,
|
||||||
|
isPlaying,
|
||||||
|
song,
|
||||||
|
playlist,
|
||||||
|
progress,
|
||||||
|
currentTrack,
|
||||||
|
playSong,
|
||||||
|
togglePlay,
|
||||||
|
seekTo,
|
||||||
|
seekForward,
|
||||||
|
seekBackward,
|
||||||
|
setVolume,
|
||||||
|
toggleMute,
|
||||||
|
playNext,
|
||||||
|
playPrevious,
|
||||||
|
formatTime,
|
||||||
|
restorePlaybackState,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<p>{{ songs.length }} 首歌曲</p>
|
<p>{{ songs.length }} 首歌曲</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="song-list">
|
<div class="song-list">
|
||||||
<div v-for="s in songs" :key="s.id" class="song-item">
|
<div v-for="s in songs" :key="s.id" class="song-item" @click="playSong(s)">
|
||||||
<span class="song-id">{{ s.id }}</span>
|
<span class="song-id">{{ s.id }}</span>
|
||||||
<div class="song-textbox">
|
<div class="song-textbox">
|
||||||
<span class="song-title">{{ s.title }}</span>
|
<span class="song-title">{{ s.title }}</span>
|
||||||
@@ -19,9 +19,11 @@
|
|||||||
import { ref, computed, onMounted, watch } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { useButterfliuStore } from '@/stores/butterfliu'
|
import { useButterfliuStore } from '@/stores/butterfliu'
|
||||||
|
import { usePlayerStore } from '@/stores/player'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const butterfliu = useButterfliuStore()
|
const butterfliu = useButterfliuStore()
|
||||||
|
const player = usePlayerStore()
|
||||||
const songs = ref([])
|
const songs = ref([])
|
||||||
|
|
||||||
const libraries = computed(() => butterfliu.libraries)
|
const libraries = computed(() => butterfliu.libraries)
|
||||||
@@ -39,6 +41,10 @@ async function loadSongs() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function playSong(song) {
|
||||||
|
player.playSong(song, songs.value)
|
||||||
|
}
|
||||||
|
|
||||||
watch(() => route.params.id, () => {
|
watch(() => route.params.id, () => {
|
||||||
loadSongs()
|
loadSongs()
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user