- Nuxt 3 app with glassmorphism design - Music streaming from local files - Player controls with shuffle/repeat - PWA support - Responsive design for mobile - Configured for proxy deployment
466 lines
14 KiB
Vue
466 lines
14 KiB
Vue
<template>
|
||
<div class="music-player">
|
||
<!-- Aurora background -->
|
||
<div class="aurora-bg">
|
||
<div class="aurora-orb"></div>
|
||
<div class="aurora-orb"></div>
|
||
<div class="aurora-orb"></div>
|
||
<div class="aurora-orb"></div>
|
||
</div>
|
||
|
||
<!-- Header -->
|
||
<header class="glass-strong" style="padding: 20px; margin-bottom: 20px; display: flex; justify-content: space-between; align-items: center;">
|
||
<h1 style="font-size: 2rem; font-weight: 700; background: linear-gradient(45deg, var(--accent-primary), var(--accent-secondary)); -webkit-background-clip: text; -webkit-text-fill-color: transparent;">
|
||
🎵 RepoDructor
|
||
</h1>
|
||
|
||
<div style="display: flex; gap: 10px; align-items: center;">
|
||
<!-- Theme toggle -->
|
||
<button @click="toggleTheme" class="btn-icon" title="Toggle theme">
|
||
<span v-if="isDark">☀️</span>
|
||
<span v-else>🌙</span>
|
||
</button>
|
||
|
||
<!-- Shuffle -->
|
||
<button @click="toggleShuffle" class="btn-icon" :class="{ 'active': isShuffled }" title="Shuffle">
|
||
🔀
|
||
</button>
|
||
|
||
<!-- Repeat modes -->
|
||
<button @click="cycleRepeat" class="btn-icon" title="Repeat mode">
|
||
<span v-if="repeatMode === 'none'">🔁</span>
|
||
<span v-else-if="repeatMode === 'all'">🔂</span>
|
||
<span v-else>🔂</span>
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
<!-- Now Playing -->
|
||
<div v-if="currentTrack" class="glass" style="padding: 20px; margin-bottom: 20px; text-align: center;">
|
||
<h2 style="font-size: 1.5rem; margin-bottom: 10px;">Now Playing</h2>
|
||
<p style="font-size: 1.2rem; font-weight: 600; color: var(--accent-primary);">
|
||
{{ currentTrack.name }}
|
||
</p>
|
||
<p style="color: var(--text-secondary); margin-top: 5px;">
|
||
Duration: {{ formatTime(currentTrack.duration || 0) }}
|
||
</p>
|
||
</div>
|
||
|
||
<!-- Track List -->
|
||
<div class="track-list">
|
||
<h3 style="margin-bottom: 15px; font-size: 1.3rem;">Your Music Library</h3>
|
||
|
||
<div v-if="loading" style="text-align: center; padding: 40px;">
|
||
<p>Loading your music...</p>
|
||
</div>
|
||
|
||
<div v-else-if="tracks.length === 0" style="text-align: center; padding: 40px;">
|
||
<p>No music files found. Add some music files to the /music folder!</p>
|
||
</div>
|
||
|
||
<div v-else>
|
||
<div
|
||
v-for="(track, index) in displayTracks"
|
||
:key="track.name"
|
||
@click="playTrack(track, index)"
|
||
class="track-item"
|
||
:class="{ active: currentTrack?.name === track.name }"
|
||
style="display: flex; justify-content: space-between; align-items: center;"
|
||
>
|
||
<div>
|
||
<p style="font-weight: 500;">{{ track.name }}</p>
|
||
<p style="font-size: 0.9rem; color: var(--text-secondary);">
|
||
{{ formatTime(track.duration || 0) }}
|
||
</p>
|
||
</div>
|
||
|
||
<div style="display: flex; gap: 10px; align-items: center;">
|
||
<span v-if="currentTrack?.name === track.name && !isPlaying">⏸️</span>
|
||
<span v-else-if="currentTrack?.name === track.name && isPlaying">▶️</span>
|
||
<span v-else>🎵</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Player Controls -->
|
||
<div class="player-controls" v-if="currentTrack">
|
||
<!-- Previous -->
|
||
<button @click="previousTrack" class="btn-icon" title="Previous">
|
||
⏮️
|
||
</button>
|
||
|
||
<!-- Play/Pause -->
|
||
<button @click="togglePlay" class="btn-icon" style="width: 60px; height: 60px; font-size: 1.5rem;" title="Play/Pause">
|
||
<span v-if="isPlaying">⏸️</span>
|
||
<span v-else>▶️</span>
|
||
</button>
|
||
|
||
<!-- Next -->
|
||
<button @click="nextTrack" class="btn-icon" title="Next">
|
||
⏭️
|
||
</button>
|
||
|
||
<!-- Progress Bar -->
|
||
<div class="progress-bar" @click="seekTo" ref="progressBar">
|
||
<div class="progress-fill" :style="{ width: progressPercent + '%' }"></div>
|
||
</div>
|
||
|
||
<!-- Time -->
|
||
<div style="min-width: 100px; text-align: center; font-size: 0.9rem;">
|
||
{{ formatTime(currentTime) }} / {{ formatTime(duration) }}
|
||
</div>
|
||
|
||
<!-- Volume -->
|
||
<div class="volume-control">
|
||
<span>🔊</span>
|
||
<div class="volume-slider" @click="setVolume" ref="volumeSlider">
|
||
<div class="progress-fill" :style="{ width: volume * 100 + '%' }"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Audio Element -->
|
||
<audio
|
||
ref="audioPlayer"
|
||
@loadedmetadata="onLoadedMetadata"
|
||
@timeupdate="onTimeUpdate"
|
||
@ended="onTrackEnded"
|
||
@canplay="onCanPlay"
|
||
@error="onAudioError"
|
||
></audio>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
||
import { useLocalStorage } from '@vueuse/core'
|
||
|
||
// Reactive state
|
||
const tracks = ref([])
|
||
const currentTrack = ref(null)
|
||
const currentTrackIndex = ref(0)
|
||
const isPlaying = ref(false)
|
||
const currentTime = ref(0)
|
||
const duration = ref(0)
|
||
const volume = ref(0.7)
|
||
const loading = ref(true)
|
||
|
||
// Theme
|
||
const isDark = useLocalStorage('theme-dark', false)
|
||
|
||
// Playback modes
|
||
const isShuffled = useLocalStorage('shuffle', false)
|
||
const repeatMode = useLocalStorage('repeat', 'none') // 'none', 'all', 'one'
|
||
const shuffledIndices = ref([])
|
||
|
||
// Refs
|
||
const audioPlayer = ref(null)
|
||
const progressBar = ref(null)
|
||
const volumeSlider = ref(null)
|
||
|
||
// Computed
|
||
const progressPercent = computed(() => {
|
||
if (duration.value === 0) return 0
|
||
return (currentTime.value / duration.value) * 100
|
||
})
|
||
|
||
const displayTracks = computed(() => {
|
||
if (isShuffled.value && shuffledIndices.value.length > 0) {
|
||
return shuffledIndices.value.map(index => tracks.value[index])
|
||
}
|
||
return tracks.value
|
||
})
|
||
|
||
// Methods
|
||
const loadTracks = async () => {
|
||
try {
|
||
const response = await $fetch('/api/music')
|
||
tracks.value = response.tracks
|
||
if (tracks.value.length > 0) {
|
||
generateShuffledIndices()
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to load tracks:', error)
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
const generateShuffledIndices = () => {
|
||
shuffledIndices.value = Array.from({ length: tracks.value.length }, (_, i) => i)
|
||
// Fisher-Yates shuffle algorithm for truly random results
|
||
for (let i = shuffledIndices.value.length - 1; i > 0; i--) {
|
||
const j = Math.floor(Math.random() * (i + 1))
|
||
;[shuffledIndices.value[i], shuffledIndices.value[j]] = [shuffledIndices.value[j], shuffledIndices.value[i]]
|
||
}
|
||
}
|
||
|
||
const playTrack = async (track, index) => {
|
||
currentTrack.value = track
|
||
currentTrackIndex.value = isShuffled.value
|
||
? shuffledIndices.value.indexOf(tracks.value.indexOf(track))
|
||
: index
|
||
|
||
if (audioPlayer.value) {
|
||
// Clear previous audio data
|
||
if (audioPlayer.value.currentBlobUrl) {
|
||
URL.revokeObjectURL(audioPlayer.value.currentBlobUrl)
|
||
audioPlayer.value.currentBlobUrl = null
|
||
}
|
||
|
||
try {
|
||
// Fetch and preload entire song into memory
|
||
const encodedName = encodeURIComponent(track.name)
|
||
const response = await fetch(`/api/music/${encodedName}`)
|
||
const blob = await response.blob()
|
||
const audioUrl = URL.createObjectURL(blob)
|
||
|
||
// Store reference to revoke later
|
||
audioPlayer.value.currentBlobUrl = audioUrl
|
||
audioPlayer.value.src = audioUrl
|
||
console.log('Preloaded track into memory:', track.name)
|
||
audioPlayer.value.load()
|
||
|
||
// Auto-play when ready
|
||
audioPlayer.value.addEventListener('canplay', () => {
|
||
audioPlayer.value.play()
|
||
}, { once: true })
|
||
} catch (error) {
|
||
console.error('Failed to preload track:', error)
|
||
// Fallback to streaming
|
||
const encodedName = encodeURIComponent(track.name)
|
||
audioPlayer.value.src = `/api/music/${encodedName}`
|
||
audioPlayer.value.load()
|
||
|
||
// Auto-play when ready (fallback)
|
||
audioPlayer.value.addEventListener('canplay', () => {
|
||
audioPlayer.value.play()
|
||
}, { once: true })
|
||
}
|
||
}
|
||
}
|
||
|
||
const togglePlay = () => {
|
||
if (!audioPlayer.value || !currentTrack.value) return
|
||
|
||
if (isPlaying.value) {
|
||
audioPlayer.value.pause()
|
||
} else {
|
||
audioPlayer.value.play()
|
||
}
|
||
}
|
||
|
||
const nextTrack = () => {
|
||
if (tracks.value.length === 0) return
|
||
|
||
let nextIndex
|
||
const trackList = isShuffled.value ? shuffledIndices.value : Array.from({ length: tracks.value.length }, (_, i) => i)
|
||
|
||
if (repeatMode.value === 'one') {
|
||
// Repeat current track
|
||
nextIndex = currentTrackIndex.value
|
||
} else {
|
||
// Move to next track
|
||
nextIndex = (currentTrackIndex.value + 1) % trackList.length
|
||
|
||
if (nextIndex === 0 && repeatMode.value === 'none') {
|
||
// End of playlist and no repeat
|
||
isPlaying.value = false
|
||
return
|
||
}
|
||
}
|
||
|
||
const actualIndex = isShuffled.value ? trackList[nextIndex] : nextIndex
|
||
const track = tracks.value[actualIndex]
|
||
|
||
playTrack(track, actualIndex)
|
||
|
||
setTimeout(() => {
|
||
if (audioPlayer.value) {
|
||
audioPlayer.value.play()
|
||
}
|
||
}, 100)
|
||
}
|
||
|
||
const previousTrack = () => {
|
||
if (tracks.value.length === 0) return
|
||
|
||
const trackList = isShuffled.value ? shuffledIndices.value : Array.from({ length: tracks.value.length }, (_, i) => i)
|
||
const prevIndex = currentTrackIndex.value === 0 ? trackList.length - 1 : currentTrackIndex.value - 1
|
||
|
||
const actualIndex = isShuffled.value ? trackList[prevIndex] : prevIndex
|
||
const track = tracks.value[actualIndex]
|
||
|
||
playTrack(track, actualIndex)
|
||
|
||
setTimeout(() => {
|
||
if (audioPlayer.value) {
|
||
audioPlayer.value.play()
|
||
}
|
||
}, 100)
|
||
}
|
||
|
||
const toggleShuffle = () => {
|
||
isShuffled.value = !isShuffled.value
|
||
if (isShuffled.value) {
|
||
generateShuffledIndices()
|
||
}
|
||
}
|
||
|
||
const cycleRepeat = () => {
|
||
const modes = ['none', 'all', 'one']
|
||
const currentIndex = modes.indexOf(repeatMode.value)
|
||
repeatMode.value = modes[(currentIndex + 1) % modes.length]
|
||
}
|
||
|
||
const toggleTheme = () => {
|
||
isDark.value = !isDark.value
|
||
}
|
||
|
||
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')}`
|
||
}
|
||
|
||
const seekTo = (event) => {
|
||
if (!audioPlayer.value || !progressBar.value) return
|
||
|
||
const rect = progressBar.value.getBoundingClientRect()
|
||
const percent = (event.clientX - rect.left) / rect.width
|
||
const newTime = percent * duration.value
|
||
|
||
audioPlayer.value.currentTime = newTime
|
||
}
|
||
|
||
const setVolume = (event) => {
|
||
if (!volumeSlider.value) return
|
||
|
||
const rect = volumeSlider.value.getBoundingClientRect()
|
||
const percent = (event.clientX - rect.left) / rect.width
|
||
volume.value = Math.max(0, Math.min(1, percent))
|
||
|
||
if (audioPlayer.value) {
|
||
audioPlayer.value.volume = volume.value
|
||
}
|
||
}
|
||
|
||
// Audio event handlers
|
||
const onLoadedMetadata = () => {
|
||
if (audioPlayer.value) {
|
||
duration.value = audioPlayer.value.duration
|
||
audioPlayer.value.volume = volume.value
|
||
}
|
||
}
|
||
|
||
const onAudioError = (error) => {
|
||
console.error('Audio error:', error)
|
||
console.error('Failed to load:', currentTrack.value?.name)
|
||
// Try next track if current fails
|
||
nextTrack()
|
||
}
|
||
|
||
const onTimeUpdate = () => {
|
||
if (audioPlayer.value) {
|
||
currentTime.value = audioPlayer.value.currentTime
|
||
}
|
||
}
|
||
|
||
const onTrackEnded = () => {
|
||
isPlaying.value = false
|
||
nextTrack()
|
||
}
|
||
|
||
const onCanPlay = () => {
|
||
// Track is ready to play
|
||
}
|
||
|
||
// Watchers
|
||
watch(isDark, (newValue) => {
|
||
if (process.client) {
|
||
document.documentElement.setAttribute('data-theme', newValue ? 'dark' : 'light')
|
||
}
|
||
}, { immediate: true })
|
||
|
||
watch(() => audioPlayer.value, (newAudio) => {
|
||
if (newAudio) {
|
||
newAudio.addEventListener('play', () => { isPlaying.value = true })
|
||
newAudio.addEventListener('pause', () => { isPlaying.value = false })
|
||
}
|
||
})
|
||
|
||
// Cleanup function
|
||
const cleanupAudio = () => {
|
||
if (audioPlayer.value?.currentBlobUrl) {
|
||
URL.revokeObjectURL(audioPlayer.value.currentBlobUrl)
|
||
audioPlayer.value.currentBlobUrl = null
|
||
}
|
||
}
|
||
|
||
// Handle mobile browser UI bars
|
||
const handleViewportChange = () => {
|
||
if (!process.client) return
|
||
|
||
const vh = window.innerHeight
|
||
const dvh = window.visualViewport ? window.visualViewport.height : vh
|
||
const diff = vh - dvh
|
||
|
||
const playerControls = document.querySelector('.player-controls')
|
||
if (playerControls) {
|
||
if (diff > 50) {
|
||
// Browser bars are visible, move controls up
|
||
playerControls.style.setProperty('--browser-bar-height', `${diff}px`)
|
||
playerControls.classList.add('browser-bars-visible')
|
||
} else {
|
||
// Browser bars hidden, controls to bottom
|
||
playerControls.style.setProperty('--browser-bar-height', '0px')
|
||
playerControls.classList.remove('browser-bars-visible')
|
||
}
|
||
}
|
||
}
|
||
|
||
// Lifecycle
|
||
onMounted(() => {
|
||
loadTracks()
|
||
if (process.client) {
|
||
document.documentElement.setAttribute('data-theme', isDark.value ? 'dark' : 'light')
|
||
|
||
// Handle mobile browser UI changes
|
||
handleViewportChange()
|
||
window.addEventListener('resize', handleViewportChange)
|
||
if (window.visualViewport) {
|
||
window.visualViewport.addEventListener('resize', handleViewportChange)
|
||
}
|
||
|
||
// Cleanup when page unloads
|
||
window.addEventListener('beforeunload', cleanupAudio)
|
||
window.addEventListener('pagehide', cleanupAudio)
|
||
}
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
cleanupAudio()
|
||
if (process.client) {
|
||
window.removeEventListener('beforeunload', cleanupAudio)
|
||
window.removeEventListener('pagehide', cleanupAudio)
|
||
window.removeEventListener('resize', handleViewportChange)
|
||
if (window.visualViewport) {
|
||
window.visualViewport.removeEventListener('resize', handleViewportChange)
|
||
}
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.active {
|
||
background: linear-gradient(45deg, var(--accent-primary), var(--accent-secondary)) !important;
|
||
color: white !important;
|
||
}
|
||
|
||
.btn-icon.active {
|
||
background: var(--accent-primary) !important;
|
||
color: white !important;
|
||
}
|
||
</style> |