- 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.
395 lines
11 KiB
Vue
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>
|
|
|