Initial commit: RepoDructor music player
- 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
This commit is contained in:
466
pages/index.vue
Normal file
466
pages/index.vue
Normal file
@@ -0,0 +1,466 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user