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:
2025-08-03 17:14:05 -06:00
commit 2f90d92ad9
14 changed files with 16786 additions and 0 deletions

466
pages/index.vue Normal file
View 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>