Files
RepoDructor/pages/index.vue
josedario87 d9795f6752
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 25s
fix: permitir reintentar canciones que fallaron previamente
El Set failedTracks marcaba canciones como fallidas permanentemente
durante toda la sesión, sin permitir reintentarlas. Esto causaba que
canciones que fallaban por razones temporales (ej: sesión expirada)
quedaran marcadas como error incluso después de resolver el problema.

Cambios implementados:
- Limpiar estado de error al inicio de playTrack() antes de reintentar
- Agregar watcher que limpia todos los errores cuando se restablece auth
- Agregar watcher que limpia errores cuando se cargan canciones exitosamente

Con esto, las canciones con error pueden reintentarse haciendo click
nuevamente, y todos los errores se limpian automáticamente cuando
la autenticación se restablece o se cargan canciones exitosamente.
2025-10-17 03:47:23 -06:00

521 lines
15 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) => {
// Limpiar el estado de error de esta canción si existe
// (permite reintentar canciones que fallaron previamente)
if (failedTracks.value.has(track.name)) {
console.log('[PlayTrack] Clearing previous error state for:', track.name)
failedTracks.value.delete(track.name)
}
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 })
// Limpiar errores cuando se restablece la autenticación
watch(() => musicStore.error, (newError, oldError) => {
// Si el error se limpia (de error a null), limpiar todos los errores de canciones
if (oldError && !newError && failedTracks.value.size > 0) {
console.log('[PlayTrack] Auth restored, clearing all failed tracks')
failedTracks.value.clear()
}
})
// Limpiar errores cuando se cargan canciones exitosamente
watch(() => musicStore.tracks.length, (newLength, oldLength) => {
// Si se cargan canciones exitosamente, limpiar errores
if (newLength > 0 && newLength !== oldLength && failedTracks.value.size > 0) {
console.log('[PlayTrack] Tracks loaded successfully, clearing failed tracks')
failedTracks.value.clear()
}
})
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>