Files
RepoDructor/pages/index.vue
josedario87 bf7413b45f Fix 403 error when loading music files
- Fix path traversal security check by using absolute paths
- Remove problematic fetch override that forced JSON headers on all API requests
- Add error tracking and visual indicators for failed tracks
- Correct music directory resolution for both relative and absolute paths

The main issue was the security validation comparing relative paths incorrectly,
causing legitimate music file requests to be rejected with 403 errors.
2025-08-10 01:28:16 -06:00

395 lines
11 KiB
Vue

<template>
<div>
<!-- Aurora Background -->
<AuroraBackground
:is-playing="isPlaying"
:current-track="currentTrack"
/>
<!-- Main Container -->
<MainContainer
:current-track="currentTrack"
:is-playing="isPlaying"
@shuffle-changed="handleShuffleChanged"
@repeat-changed="handleRepeatChanged"
>
<!-- Track List -->
<TrackList
:tracks="tracks"
:display-tracks="displayTracks"
:current-track="currentTrack"
:is-playing="isPlaying"
:loading="loading"
:loading-track="loadingTrack"
:failed-tracks="failedTracks"
@track-selected="handleTrackSelected"
/>
</MainContainer>
<!-- Music Controls -->
<MusicControls
:current-track="currentTrack"
:is-playing="isPlaying"
:current-time="currentTime"
:duration="duration"
:volume="volume"
@previous="previousTrack"
@next="nextTrack"
@toggle-play="togglePlay"
@seek="seekTo"
@volume-change="setVolume"
/>
<!-- Audio Element -->
<audio
ref="audioPlayer"
@loadedmetadata="onLoadedMetadata"
@timeupdate="onTimeUpdate"
@ended="onTrackEnded"
@canplay="onCanPlay"
@error="onAudioError"
></audio>
</div>
</template>
<script setup>
// Disable SSR for this page since it uses browser APIs and audio elements
definePageMeta({
ssr: false
})
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
import { useLocalStorage } from '@vueuse/core'
// Import components
import AuroraBackground from '~/components/AuroraBackground.client.vue'
import MainContainer from '~/components/MainContainer.client.vue'
import TrackList from '~/components/TrackList.client.vue'
import MusicControls from '~/components/MusicControls.client.vue'
// 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)
const loadingTrack = ref(null)
const failedTracks = ref(new Set()) // Track failed tracks
// Theme (handled by ThemeToggle component now)
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)
// Computed - removed progressPercent as it's not used in this component
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) => {
loadingTrack.value = track.name
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}`)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
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', () => {
loadingTrack.value = null
audioPlayer.value.play()
}, { once: true })
} catch (error) {
console.error('Failed to preload track:', error)
// Mark track as failed
failedTracks.value.add(track.name)
loadingTrack.value = null
// Try 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', () => {
loadingTrack.value = null
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 handleShuffleChanged = (shuffled) => {
isShuffled.value = shuffled
if (shuffled) {
generateShuffledIndices()
}
}
const handleRepeatChanged = (mode) => {
repeatMode.value = mode
}
const handleTrackSelected = ({ track, index }) => {
playTrack(track, index)
}
// Removed formatTime function as it's not used in this component - it's defined in child components where needed
const seekTo = (newTime) => {
if (!audioPlayer.value) return
audioPlayer.value.currentTime = newTime
}
const setVolume = (newVolume) => {
volume.value = Math.max(0, Math.min(1, newVolume))
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)
// Mark track as failed
if (currentTrack.value) {
failedTracks.value.add(currentTrack.value.name)
}
// Clear loading state
loadingTrack.value = null
// Try next track if current fails
setTimeout(() => {
nextTrack()
}, 1000) // Small delay before trying next track
}
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 (import.meta.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 (!import.meta.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 (import.meta.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 (import.meta.client) {
window.removeEventListener('beforeunload', cleanupAudio)
window.removeEventListener('pagehide', cleanupAudio)
window.removeEventListener('resize', handleViewportChange)
if (window.visualViewport) {
window.visualViewport.removeEventListener('resize', handleViewportChange)
}
}
})
</script>
<style scoped>
/* Page-specific styles */
</style>