Files
RepoDructor/pages/index.vue
josedario87 2f90d92ad9 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
2025-08-03 17:14:05 -06:00

466 lines
14 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>