Files
RepoDructor/pages/index.vue
josedario87 b46d15145f
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 28s
fix: evitar error CORS al expirar sesión de Authentik
Cuando la sesión de Authentik expira, los fetch() a /api/music recibían
un redirect (302) a la página de login. Por defecto, fetch intenta seguir
el redirect pero falla por CORS porque Authentik no tiene el header
Access-Control-Allow-Origin.

La solución es usar redirect: 'error' en todos los fetch() a endpoints
protegidos, lo que convierte los redirects en errores que podemos capturar
y manejar apropiadamente. Esto coincide con la estrategia que ya usa
useAuth.ts.

Cambios:
- stores/music.ts: Agregar redirect: 'error' a fetchTracks() y cacheByName()
- pages/index.vue: Agregar redirect: 'error' a playTrack()
- Mejorar detección de errores de autenticación para incluir 'Failed to fetch'
  y errores de tipo TypeError relacionados con redirects
2025-10-17 03:40:12 -06:00

496 lines
14 KiB
Vue

<template>
<div>
<!-- Aurora Background -->
<AuroraBackground
:is-playing="isPlaying"
:current-track="currentTrack"
/>
<!-- Main Container -->
<MainContainer
:current-track="currentTrack"
:is-playing="isPlaying"
:filtered-count="filteredCount"
@shuffle-changed="handleShuffleChanged"
@repeat-changed="handleRepeatChanged"
@search="handleSearch"
>
<!-- 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 { useMusicStore } from '~/stores/music'
import { useToastStore } from '~/stores/toast'
// 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'
// Store
const musicStore = useMusicStore()
const toastStore = useToastStore()
// Reactive state
const tracks = computed(() => musicStore.tracks)
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([])
// Search state
const searchQuery = ref('')
// Error tracking
const consecutiveErrors = ref(0)
// Refs
const audioPlayer = ref(null)
// Computed - removed progressPercent as it's not used in this component
const displayTracks = computed(() => {
let tracksToDisplay = tracks.value
// Aplicar filtro de búsqueda
if (searchQuery.value.length > 0) {
tracksToDisplay = tracksToDisplay.filter(track =>
track.name.toLowerCase().includes(searchQuery.value)
)
}
// Aplicar shuffle si está activado
if (isShuffled.value && shuffledIndices.value.length > 0) {
const shuffledTracks = shuffledIndices.value
.map(index => tracks.value[index])
.filter(track =>
searchQuery.value.length === 0 ||
track.name.toLowerCase().includes(searchQuery.value)
)
return shuffledTracks
}
return tracksToDisplay
})
const filteredCount = computed(() => {
if (searchQuery.value.length === 0) return null
return displayTracks.value.length
})
// Methods
const loadTracks = async () => {
try {
await musicStore.fetchTracks()
if (tracks.value.length > 0) {
generateShuffledIndices()
loading.value = false
return
}
} catch (error) {
console.warn('Failed to load tracks from server, will fallback to cache:', error)
}
// Fallback to cached tracks when offline or server fails
const cached = await musicStore.loadCachedTracks()
if (cached.length > 0) {
// Use cached list locally by overwriting store tracks for session display
musicStore.tracks = cached
generateShuffledIndices()
}
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 {
// Try IndexedDB cached version first
const cachedBlob = await musicStore.getCachedBlob(track.name)
if (cachedBlob) {
const audioUrl = URL.createObjectURL(cachedBlob)
audioPlayer.value.currentBlobUrl = audioUrl
audioPlayer.value.src = audioUrl
audioPlayer.value.load()
audioPlayer.value.addEventListener('canplay', () => {
loadingTrack.value = null
audioPlayer.value.play()
}, { once: true })
return
}
// Fetch and preload entire song into memory
const encodedName = encodeURIComponent(track.name)
const response = await fetch(`/api/music/${encodedName}`, {
credentials: 'include',
redirect: 'error' // No seguir redirects de Authentik - convertir en error
})
// Check for authentication errors
if (!response.ok) {
const errorMsg = `HTTP ${response.status}`
// If auth error, propagate to music store for auth indicator to detect
if (response.status === 401 || response.status === 403) {
console.warn('[PlayTrack] Authentication error:', response.status)
musicStore.error = `${errorMsg}: Unauthorized - Please log in`
}
throw new Error(errorMsg)
}
const blob = await response.blob()
const audioUrl = URL.createObjectURL(blob)
// No auto-cache: solo reproducir, el usuario decide cachear desde menú
// 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
// Don't try fallback streaming if it's an auth error (it will fail too)
// Esto incluye errores de redirect de Authentik
if (error.message && (error.message.includes('401') ||
error.message.includes('403') ||
error.message.includes('Failed to fetch') ||
(error.name === 'TypeError' && error.message.includes('redirect')))) {
console.warn('[PlayTrack] Skipping fallback due to auth error:', error.message)
musicStore.error = error.message || 'Authentication error'
return
}
// 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 handleSearch = (query) => {
searchQuery.value = query
}
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
// Increment consecutive error counter
consecutiveErrors.value++
// Check if we've hit 2 consecutive errors
if (consecutiveErrors.value >= 2) {
// Stop trying and show error toast
toastStore.error('No se pudieron cargar 2 canciones consecutivas. Reproducción detenida.', 4000)
isPlaying.value = false
consecutiveErrors.value = 0 // Reset counter
console.warn('[AudioError] Stopped playback after 2 consecutive failures')
return
}
// Try next track if we haven't hit the limit
console.log(`[AudioError] Attempting next track (consecutive errors: ${consecutiveErrors.value})`)
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 - reset error counter
consecutiveErrors.value = 0
}
// 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()
// Load cached songs metadata
musicStore.loadCachedNames()
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>