|
|
<template> |
|
|
<Transition name="slide-up"> |
|
|
<div |
|
|
v-if="currentSong" |
|
|
class="mini-player" |
|
|
:data-playing="isPlaying" |
|
|
@click="openFullPlayer" |
|
|
@touchstart="handleTouchStart" |
|
|
@touchmove="handleTouchMove" |
|
|
@touchend="handleTouchEnd" |
|
|
> |
|
|
<div class="mini-player-content"> |
|
|
|
|
|
<div class="cover-container"> |
|
|
<img |
|
|
:src="coverUrl" |
|
|
:alt="currentSong.name" |
|
|
class="cover-image" |
|
|
@error="handleImageError" |
|
|
/> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="song-info"> |
|
|
<div class="song-name">{{ currentSong.name }}</div> |
|
|
<div class="song-artist">{{ formatArtist(currentSong.artist) }}</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="play-controls"> |
|
|
|
|
|
<button |
|
|
class="control-btn prev-btn hidden-mobile" |
|
|
@click.stop="$emit('playPrevious')" |
|
|
> |
|
|
<i class="fas fa-step-backward"></i> |
|
|
</button> |
|
|
|
|
|
<button |
|
|
class="control-btn play-btn" |
|
|
@click.stop="togglePlay" |
|
|
:disabled="!audioSrc" |
|
|
> |
|
|
<i :class="isPlaying ? 'fas fa-pause' : 'fas fa-play'"></i> |
|
|
</button> |
|
|
|
|
|
|
|
|
<button |
|
|
class="control-btn next-btn hidden-mobile" |
|
|
@click.stop="$emit('playNext')" |
|
|
> |
|
|
<i class="fas fa-step-forward"></i> |
|
|
</button> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="volume-control hidden-mobile"> |
|
|
<button class="volume-btn" @click.stop="toggleMute"> |
|
|
<i :class="volumeIcon"></i> |
|
|
</button> |
|
|
<input |
|
|
type="range" |
|
|
min="0" |
|
|
max="100" |
|
|
:value="volume" |
|
|
@input="handleVolumeChange" |
|
|
@click.stop |
|
|
class="volume-slider" |
|
|
/> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="progress-background"> |
|
|
<div |
|
|
class="progress-fill" |
|
|
:style="{ width: `${progress}%` }" |
|
|
></div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</Transition> |
|
|
</template> |
|
|
|
|
|
<script setup> |
|
|
import { ref, computed, onMounted, watch } from 'vue' |
|
|
import { useRouter } from 'vue-router' |
|
|
import { usePlayerStore } from '@/stores/player' |
|
|
import { musicApi, utils } from '@/services/musicApi' |
|
|
|
|
|
const emit = defineEmits(['openFullPlayer', 'togglePlay', 'playNext', 'playPrevious']) |
|
|
|
|
|
const router = useRouter() |
|
|
|
|
|
const playerStore = usePlayerStore() |
|
|
const coverUrl = ref('') |
|
|
|
|
|
|
|
|
const isMuted = ref(false) |
|
|
const lastVolume = ref(80) |
|
|
|
|
|
|
|
|
const touchStartY = ref(0) |
|
|
const touchStartX = ref(0) |
|
|
const isSwiping = ref(false) |
|
|
|
|
|
|
|
|
const currentSong = computed(() => playerStore.currentSong) |
|
|
const isPlaying = computed(() => playerStore.isPlaying) |
|
|
const progress = computed(() => playerStore.progress) |
|
|
const audioSrc = computed(() => playerStore.audioSrc) |
|
|
const volume = computed(() => playerStore.volume || 80) |
|
|
|
|
|
|
|
|
const volumeIcon = computed(() => { |
|
|
if (isMuted.value || volume.value === 0) { |
|
|
return 'fas fa-volume-mute' |
|
|
} else if (volume.value < 30) { |
|
|
return 'fas fa-volume-off' |
|
|
} else if (volume.value < 70) { |
|
|
return 'fas fa-volume-down' |
|
|
} else { |
|
|
return 'fas fa-volume-up' |
|
|
} |
|
|
}) |
|
|
|
|
|
|
|
|
const formatArtist = (artist) => { |
|
|
return utils.formatArtist(artist) |
|
|
} |
|
|
|
|
|
|
|
|
const togglePlay = () => { |
|
|
|
|
|
|
|
|
emit('togglePlay') |
|
|
} |
|
|
|
|
|
|
|
|
const openFullPlayer = () => { |
|
|
|
|
|
if (currentSong.value) { |
|
|
router.push('/player') |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const toggleMute = () => { |
|
|
if (isMuted.value) { |
|
|
|
|
|
playerStore.setVolume(lastVolume.value) |
|
|
isMuted.value = false |
|
|
} else { |
|
|
|
|
|
lastVolume.value = volume.value |
|
|
playerStore.setVolume(0) |
|
|
isMuted.value = true |
|
|
} |
|
|
} |
|
|
|
|
|
const handleVolumeChange = (event) => { |
|
|
const newVolume = parseInt(event.target.value) |
|
|
playerStore.setVolume(newVolume) |
|
|
isMuted.value = newVolume === 0 |
|
|
} |
|
|
|
|
|
|
|
|
const loadCover = async () => { |
|
|
if (!currentSong.value) { |
|
|
coverUrl.value = getDefaultCover() |
|
|
return |
|
|
} |
|
|
|
|
|
try { |
|
|
const coverUrlResult = await playerStore.getAlbumCover(currentSong.value, 300) |
|
|
if (coverUrlResult) { |
|
|
coverUrl.value = coverUrlResult |
|
|
} else { |
|
|
coverUrl.value = getDefaultCover() |
|
|
} |
|
|
} catch (error) { |
|
|
console.error('加载封面失败:', error) |
|
|
coverUrl.value = getDefaultCover() |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const getDefaultCover = () => { |
|
|
return 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNjQiIGhlaWdodD0iNjQiIHZpZXdCb3g9IjAgMCA2NCA2NCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3Qgd2lkdGg9IjY0IiBoZWlnaHQ9IjY0IiBmaWxsPSJyZ2JhKDI1NSwyNTUsMjU1LDAuMSkiIHJ4PSI4Ii8+CjxwYXRoIGQ9Ik0zMiAyMEw0MCAzMkgzNlY0NEgyOFYzMkgyNEwzMiAyMFoiIGZpbGw9InJnYmEoMjU1LDI1NSwyNTUsMC4zKSIvPgo8L3N2Zz4K' |
|
|
} |
|
|
|
|
|
|
|
|
const handleImageError = () => { |
|
|
coverUrl.value = getDefaultCover() |
|
|
} |
|
|
|
|
|
|
|
|
const handleTouchStart = (e) => { |
|
|
touchStartY.value = e.touches[0].clientY |
|
|
touchStartX.value = e.touches[0].clientX |
|
|
isSwiping.value = false |
|
|
} |
|
|
|
|
|
const handleTouchMove = (e) => { |
|
|
const deltaY = e.touches[0].clientY - touchStartY.value |
|
|
const deltaX = e.touches[0].clientX - touchStartX.value |
|
|
|
|
|
|
|
|
if (Math.abs(deltaY) > Math.abs(deltaX) && deltaY < -30) { |
|
|
isSwiping.value = true |
|
|
e.preventDefault() |
|
|
} |
|
|
|
|
|
|
|
|
if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > 50) { |
|
|
isSwiping.value = true |
|
|
if (deltaX > 0) { |
|
|
|
|
|
emit('playPrevious') |
|
|
} else { |
|
|
|
|
|
emit('playNext') |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
const handleTouchEnd = (e) => { |
|
|
const deltaY = touchStartY.value - e.changedTouches[0].clientY |
|
|
|
|
|
if (deltaY > 50 && !isSwiping.value) { |
|
|
|
|
|
openFullPlayer() |
|
|
} |
|
|
|
|
|
touchStartY.value = 0 |
|
|
touchStartX.value = 0 |
|
|
isSwiping.value = false |
|
|
} |
|
|
|
|
|
|
|
|
watch(() => currentSong.value, (newSong) => { |
|
|
if (newSong) { |
|
|
loadCover() |
|
|
} else { |
|
|
coverUrl.value = getDefaultCover() |
|
|
} |
|
|
}, { immediate: true }) |
|
|
|
|
|
onMounted(() => { |
|
|
if (currentSong.value) { |
|
|
loadCover() |
|
|
} |
|
|
}) |
|
|
</script> |
|
|
|
|
|
<style scoped> |
|
|
.mini-player { |
|
|
position: fixed; |
|
|
bottom: var(--tabbar-height); |
|
|
left: 0; |
|
|
right: 0; |
|
|
height: var(--mini-player-height); |
|
|
background: var(--bg-card); |
|
|
backdrop-filter: blur(20px); |
|
|
border-top: 1px solid var(--border-strong); |
|
|
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.05); |
|
|
z-index: 999; |
|
|
cursor: pointer; |
|
|
transition: var(--transition-fast); |
|
|
} |
|
|
|
|
|
|
|
|
@supports (-webkit-touch-callout: none) { |
|
|
@media all and (display-mode: standalone) { |
|
|
.mini-player { |
|
|
bottom: calc(var(--tabbar-height) + 20px); |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
.mini-player:hover { |
|
|
background: rgba(255, 255, 255, 0.08); |
|
|
box-shadow: 0 -2px 15px rgba(0, 0, 0, 0.1); |
|
|
} |
|
|
|
|
|
.mini-player-content { |
|
|
display: flex; |
|
|
align-items: center; |
|
|
height: 100%; |
|
|
padding: 0 16px; |
|
|
position: relative; |
|
|
} |
|
|
|
|
|
.cover-container { |
|
|
width: 48px; |
|
|
height: 48px; |
|
|
margin-right: 12px; |
|
|
flex-shrink: 0; |
|
|
} |
|
|
|
|
|
.cover-image { |
|
|
width: 100%; |
|
|
height: 100%; |
|
|
border-radius: 8px; |
|
|
object-fit: cover; |
|
|
transition: var(--transition-fast); |
|
|
} |
|
|
|
|
|
.song-info { |
|
|
flex: 1; |
|
|
min-width: 0; |
|
|
margin-right: 12px; |
|
|
} |
|
|
|
|
|
.song-name { |
|
|
font-size: 14px; |
|
|
font-weight: 600; |
|
|
color: var(--text-primary); |
|
|
margin-bottom: 2px; |
|
|
white-space: nowrap; |
|
|
overflow: hidden; |
|
|
text-overflow: ellipsis; |
|
|
} |
|
|
|
|
|
.song-artist { |
|
|
font-size: 12px; |
|
|
color: var(--text-secondary); |
|
|
white-space: nowrap; |
|
|
overflow: hidden; |
|
|
text-overflow: ellipsis; |
|
|
} |
|
|
|
|
|
.play-controls { |
|
|
flex-shrink: 0; |
|
|
display: flex; |
|
|
align-items: center; |
|
|
gap: 8px; |
|
|
} |
|
|
|
|
|
.control-btn { |
|
|
width: 40px; |
|
|
height: 40px; |
|
|
border-radius: 50%; |
|
|
background: var(--bg-overlay); |
|
|
border: 1px solid var(--border-light); |
|
|
color: var(--text-primary); |
|
|
cursor: pointer; |
|
|
display: flex; |
|
|
align-items: center; |
|
|
justify-content: center; |
|
|
font-size: 16px; |
|
|
transition: var(--transition-fast); |
|
|
position: relative; |
|
|
backdrop-filter: blur(10px); |
|
|
} |
|
|
|
|
|
|
|
|
@media (min-width: 1024px) { |
|
|
.mini-player-content { |
|
|
padding: 0 20px; |
|
|
} |
|
|
|
|
|
.play-controls { |
|
|
gap: 4px; |
|
|
} |
|
|
|
|
|
.control-btn.prev-btn, |
|
|
.control-btn.next-btn { |
|
|
width: 32px; |
|
|
height: 32px; |
|
|
font-size: 12px; |
|
|
background: var(--overlay-lighter); |
|
|
} |
|
|
|
|
|
.control-btn.play-btn { |
|
|
width: 40px; |
|
|
height: 40px; |
|
|
font-size: 16px; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
.volume-control { |
|
|
display: flex; |
|
|
align-items: center; |
|
|
gap: 8px; |
|
|
flex-shrink: 0; |
|
|
} |
|
|
|
|
|
.volume-btn { |
|
|
width: 32px; |
|
|
height: 32px; |
|
|
border-radius: 50%; |
|
|
background: var(--overlay-lighter); |
|
|
border: none; |
|
|
color: var(--text-secondary); |
|
|
cursor: pointer; |
|
|
display: flex; |
|
|
align-items: center; |
|
|
justify-content: center; |
|
|
font-size: 12px; |
|
|
transition: var(--transition-fast); |
|
|
} |
|
|
|
|
|
.volume-btn:hover { |
|
|
background: var(--overlay-light); |
|
|
color: var(--text-primary); |
|
|
} |
|
|
|
|
|
.volume-slider { |
|
|
width: 80px; |
|
|
height: 4px; |
|
|
background: var(--border-light); |
|
|
border-radius: 2px; |
|
|
outline: none; |
|
|
appearance: none; |
|
|
cursor: pointer; |
|
|
transition: var(--transition-fast); |
|
|
} |
|
|
|
|
|
.volume-slider::-webkit-slider-thumb { |
|
|
appearance: none; |
|
|
width: 12px; |
|
|
height: 12px; |
|
|
border-radius: 50%; |
|
|
background: var(--primary-color); |
|
|
cursor: pointer; |
|
|
border: 2px solid white; |
|
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); |
|
|
transition: var(--transition-fast); |
|
|
} |
|
|
|
|
|
.volume-slider::-webkit-slider-thumb:hover { |
|
|
transform: scale(1.2); |
|
|
box-shadow: 0 0 8px var(--glow-color); |
|
|
} |
|
|
|
|
|
.volume-slider::-moz-range-thumb { |
|
|
width: 12px; |
|
|
height: 12px; |
|
|
border-radius: 50%; |
|
|
background: var(--primary-color); |
|
|
cursor: pointer; |
|
|
border: 2px solid white; |
|
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); |
|
|
transition: var(--transition-fast); |
|
|
} |
|
|
|
|
|
|
|
|
@media (min-width: 1024px) { |
|
|
.mini-player { |
|
|
display: none; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
.mini-player .control-btn { |
|
|
transition: all var(--transition-fast); |
|
|
} |
|
|
|
|
|
.mini-player[data-playing="true"] .control-btn { |
|
|
background: var(--primary-color); |
|
|
color: white; |
|
|
border-color: var(--primary-color); |
|
|
box-shadow: 0 0 20px var(--glow-color); |
|
|
animation: playing-pulse 2s ease-in-out infinite; |
|
|
} |
|
|
|
|
|
|
|
|
.mini-player[data-playing="false"] .control-btn { |
|
|
background: var(--bg-overlay); |
|
|
border: 1px solid var(--border-light); |
|
|
color: var(--primary-color); |
|
|
box-shadow: 0 2px 8px var(--shadow-color); |
|
|
} |
|
|
|
|
|
.mini-player[data-playing="false"] .control-btn:hover { |
|
|
background: var(--primary-color); |
|
|
color: white; |
|
|
transform: scale(1.05); |
|
|
box-shadow: 0 0 15px var(--glow-color); |
|
|
} |
|
|
|
|
|
@keyframes playing-pulse { |
|
|
0%, 100% { |
|
|
box-shadow: 0 0 20px var(--glow-color); |
|
|
} |
|
|
50% { |
|
|
box-shadow: 0 0 30px var(--glow-color), 0 0 40px var(--glow-color); |
|
|
} |
|
|
} |
|
|
|
|
|
.mini-player[data-playing="true"] .control-btn:hover:not(:disabled) { |
|
|
background: var(--primary-color-hover); |
|
|
transform: scale(1.1); |
|
|
box-shadow: 0 0 25px var(--glow-color), 0 0 35px var(--glow-color); |
|
|
} |
|
|
|
|
|
.control-btn:disabled { |
|
|
opacity: 0.5; |
|
|
cursor: not-allowed; |
|
|
} |
|
|
|
|
|
.progress-background { |
|
|
position: absolute; |
|
|
bottom: 0; |
|
|
left: 0; |
|
|
right: 0; |
|
|
height: 3px; |
|
|
background: rgba(255, 255, 255, 0.15); |
|
|
border-radius: 0 0 0 0; |
|
|
} |
|
|
|
|
|
.progress-fill { |
|
|
height: 100%; |
|
|
background: var(--primary-color); |
|
|
transition: width 0.1s ease; |
|
|
border-radius: 0 0 0 0; |
|
|
} |
|
|
|
|
|
|
|
|
.slide-up-enter-active, |
|
|
.slide-up-leave-active { |
|
|
transition: transform 0.3s ease; |
|
|
} |
|
|
|
|
|
.slide-up-enter-from, |
|
|
.slide-up-leave-to { |
|
|
transform: translateY(100%); |
|
|
} |
|
|
|
|
|
|
|
|
@media (max-width: 375px) { |
|
|
.mini-player-content { |
|
|
padding: 0 12px; |
|
|
} |
|
|
|
|
|
.cover-container { |
|
|
width: 44px; |
|
|
height: 44px; |
|
|
margin-right: 10px; |
|
|
} |
|
|
|
|
|
.control-btn { |
|
|
width: 36px; |
|
|
height: 36px; |
|
|
font-size: 14px; |
|
|
} |
|
|
} |
|
|
</style> |