diff --git a/pmoapp/webapp/src/components/GenericMusicPlayer.vue b/pmoapp/webapp/src/components/GenericMusicPlayer.vue
index 8253c0b2..1f897694 100644
--- a/pmoapp/webapp/src/components/GenericMusicPlayer.vue
+++ b/pmoapp/webapp/src/components/GenericMusicPlayer.vue
@@ -185,7 +185,7 @@
@@ -251,6 +252,12 @@ const audioPlayer = ref(null)
const currentTime = ref(0)
const duration = ref(0)
+// Retry logic for fragile browser decoders
+const MAX_AUDIO_RETRIES = 3
+const RETRY_DELAY_MS = 800 // Réduit pour des transitions plus rapides
+const retryCount = ref(0)
+let retryTimer: ReturnType | null = null
+
// Metadata refresh via SSE
let metadataEventSource: EventSource | null = null
@@ -262,6 +269,7 @@ onMounted(async () => {
// Cleanup on unmount
onUnmounted(() => {
stopMetadataRefresh()
+ clearRetryTimer()
})
/**
@@ -374,11 +382,19 @@ function startMetadataRefresh() {
metadataEventSource.onmessage = (event) => {
try {
+ const previous = currentTrack.value
const metadata = JSON.parse(event.data)
if (currentTrack.value && metadata.id === currentTrack.value.id) {
currentTrack.value = metadata
console.log('Metadata updated via SSE:', metadata.title)
+ // Si le morceau change (live stream), remettre la progression à zéro
+ // IMPORTANT: Ne pas recharger l'URI car c'est un flux continu !
+ if (hasTrackChanged(previous, metadata)) {
+ console.log('Nouveau morceau dans le flux:', metadata.title)
+ resetProgressForNewTrack(metadata)
+ }
+
// Mettre à jour la durée si elle a changé
const newDuration = metadata.resources?.[0]?.duration
if (newDuration) {
@@ -422,8 +438,10 @@ async function playTrack(item: BrowseItem) {
return
}
+ resetAudioRetries()
audioError.value = null
currentTrack.value = item
+ const preferredUri = pickPreferredResourceUri(item)
// Initialise la durée depuis les métadonnées du morceau si disponible
currentTime.value = 0
@@ -435,22 +453,35 @@ async function playTrack(item: BrowseItem) {
}
try {
- // Résout l'URI du morceau
+ // Résout l'URI du morceau via l'API
const result = await resolveUri(selectedSource.value.id, item.id)
- currentUri.value = result.uri
+ let uri = result.uri
- // Attendre que le DOM soit mis à jour (création de l'élément audio)
- await nextTick()
-
- // Joue l'audio
- if (audioPlayer.value) {
- audioPlayer.value.src = result.uri
- await audioPlayer.value.play()
- isPlaying.value = true
-
- // Start metadata refresh
- startMetadataRefresh()
+ // Si resolveUri n'a pas retourné d'URI, utiliser notre URI préférée (basée sur le support navigateur)
+ if (!uri && preferredUri) {
+ uri = preferredUri
+ console.log('Utilisation de l\'URI préférée (auto-détectée):', uri)
}
+
+ // Si les deux sont disponibles, privilégier l'URI la mieux supportée
+ if (uri && preferredUri && uri !== preferredUri) {
+ // Vérifier si l'URI préférée est mieux supportée
+ const resolvedFormat = getAudioFormat(uri)
+ const preferredFormat = getAudioFormat(preferredUri)
+
+ if (preferredFormat && canPlayAudioType(preferredFormat)) {
+ if (!resolvedFormat || !canPlayAudioType(resolvedFormat)) {
+ console.log(`Remplacement de ${uri} par ${preferredUri} (meilleur support navigateur)`)
+ uri = preferredUri
+ }
+ }
+ }
+
+ if (!uri) {
+ throw new Error('Aucune URI audio disponible')
+ }
+
+ await startPlaybackFromUri(uri, true)
} catch (e: any) {
audioError.value = `Erreur lors de la lecture: ${e.message}`
console.error('Failed to play track:', e)
@@ -474,6 +505,7 @@ function stopPlayback() {
audioError.value = null
currentTime.value = 0
duration.value = 0
+ resetAudioRetries()
}
/**
@@ -481,6 +513,7 @@ function stopPlayback() {
*/
function handleAudioEnded() {
isPlaying.value = false
+ currentTime.value = 0
stopMetadataRefresh()
// On peut implémenter ici une logique de lecture automatique du prochain morceau
}
@@ -492,11 +525,20 @@ function handleAudioError() {
stopMetadataRefresh()
const audio = audioPlayer.value
- if (audio?.error) {
- audioError.value = `Erreur de lecture audio (code ${audio.error.code})`
- } else {
- audioError.value = 'Erreur de lecture audio inconnue'
+ const errorCode = audio?.error?.code
+ const baseMessage = errorCode
+ ? `Erreur de lecture audio (code ${errorCode})`
+ : 'Erreur de lecture audio inconnue'
+
+ if (selectedSource.value && currentTrack.value && retryCount.value < MAX_AUDIO_RETRIES) {
+ isPlaying.value = false
+ retryCount.value += 1
+ audioError.value = `${baseMessage} - tentative de reprise (${retryCount.value}/${MAX_AUDIO_RETRIES})`
+ scheduleRetryPlayback()
+ return
}
+
+ audioError.value = baseMessage
isPlaying.value = false
}
@@ -505,6 +547,7 @@ function handleAudioError() {
*/
function handleAudioPlay() {
isPlaying.value = true
+ audioError.value = null
startMetadataRefresh()
}
@@ -543,6 +586,13 @@ function handleDurationChange() {
}
}
+/**
+ * Gère l'événement canplay (audio prêt à être joué)
+ */
+function handleCanPlay() {
+ console.log('Audio ready to play (canplay event)')
+}
+
/**
* Formatte le temps en MM:SS
*/
@@ -575,6 +625,247 @@ function handleImageError(event: Event) {
const img = event.target as HTMLImageElement
img.style.display = 'none'
}
+
+/**
+ * Détecte un changement de morceau (utile pour les streams live)
+ */
+function hasTrackChanged(
+ previous: BrowseItem | null,
+ next: BrowseItem | null
+): boolean {
+ if (!previous || !next) return false
+ return (
+ previous.title !== next.title ||
+ previous.album !== next.album ||
+ (previous.artist || previous.creator) !== (next.artist || next.creator) ||
+ previous.album_art !== next.album_art
+ )
+}
+
+/**
+ * Remet la progression à zéro pour un nouveau morceau
+ */
+function resetProgressForNewTrack(track: BrowseItem | null) {
+ currentTime.value = 0
+ if (audioPlayer.value) {
+ audioPlayer.value.currentTime = 0
+ }
+ const d = track?.resources?.[0]?.duration
+ duration.value = d ? parseDuration(d) : 0
+}
+
+/**
+ * Détecte si un type MIME audio est supporté par le navigateur
+ */
+function canPlayAudioType(mimeType: string): boolean {
+ const audio = document.createElement('audio')
+ const support = audio.canPlayType(mimeType)
+ return support === 'probably' || support === 'maybe'
+}
+
+/**
+ * Extrait le type MIME audio depuis une URL
+ */
+function getAudioFormat(url: string): string | null {
+ const urlLower = url.toLowerCase()
+
+ // Détection par extension ou contenu de l'URL
+ if (urlLower.includes('flac') || urlLower.endsWith('.flac')) {
+ return 'audio/flac'
+ }
+ if (urlLower.includes('ogg') || urlLower.endsWith('.ogg') || urlLower.includes('vorbis')) {
+ return 'audio/ogg'
+ }
+ if (urlLower.includes('mp3') || urlLower.endsWith('.mp3') || urlLower.includes('mpeg')) {
+ return 'audio/mpeg'
+ }
+ if (urlLower.includes('wav') || urlLower.endsWith('.wav')) {
+ return 'audio/wav'
+ }
+ if (urlLower.includes('opus')) {
+ return 'audio/ogg' // Opus est généralement dans un container OGG
+ }
+
+ return null
+}
+
+/**
+ * Sélectionne l'URI préférée en tenant compte du support navigateur
+ */
+function pickPreferredResourceUri(item: BrowseItem): string | null {
+ const resources = item.resources || []
+ if (resources.length === 0) return null
+
+ // Ordre de préférence des formats (du meilleur au plus compatible)
+ const preferredFormats = [
+ { mime: 'audio/flac', keywords: ['flac'] },
+ { mime: 'audio/ogg', keywords: ['ogg', 'vorbis', 'opus'] },
+ { mime: 'audio/mpeg', keywords: ['mp3', 'mpeg'] },
+ { mime: 'audio/wav', keywords: ['wav'] },
+ { mime: 'audio/x-wav', keywords: ['wav'] },
+ ]
+
+ // Tester chaque format dans l'ordre de préférence
+ for (const format of preferredFormats) {
+ // Vérifier d'abord si le navigateur supporte ce format
+ if (!canPlayAudioType(format.mime)) {
+ console.log(`Format ${format.mime} non supporté par ce navigateur`)
+ continue
+ }
+
+ // Chercher une ressource correspondant à ce format
+ const matchingRes = resources.find((res) => {
+ if (!res.url || !res.protocol_info) return false
+ const protocolLower = res.protocol_info.toLowerCase()
+ return format.keywords.some((keyword) => protocolLower.includes(keyword))
+ })
+
+ if (matchingRes?.url) {
+ console.log(`Format sélectionné: ${format.mime}`, matchingRes.url)
+ return matchingRes.url
+ }
+ }
+
+ // Fallback: prendre n'importe quelle URL disponible
+ const anyRes = resources.find((res) => !!res.url)
+ if (anyRes?.url) {
+ console.log('Fallback: utilisation de la première URL disponible', anyRes.url)
+ }
+ return anyRes?.url || null
+}
+
+/**
+ * Ajoute un cache-buster pour forcer un nouveau stream côté navigateur
+ */
+function addCacheBuster(uri: string): string {
+ const separator = uri.includes('?') ? '&' : '?'
+ return `${uri}${separator}ts=${Date.now()}`
+}
+
+/**
+ * Nettoie le timer de retry
+ */
+function clearRetryTimer() {
+ if (retryTimer) {
+ clearTimeout(retryTimer)
+ retryTimer = null
+ }
+}
+
+/**
+ * Réinitialise les compteurs de retry
+ */
+function resetAudioRetries() {
+ retryCount.value = 0
+ clearRetryTimer()
+}
+
+/**
+ * Lance un retry différé pour laisser le temps au backend de reproposer le flux
+ */
+function scheduleRetryPlayback() {
+ clearRetryTimer()
+ retryTimer = setTimeout(() => {
+ retryTimer = null
+ void retryPlayback()
+ }, RETRY_DELAY_MS)
+}
+
+/**
+ * (Ré)initialise la source audio et démarre la lecture
+ */
+async function startPlaybackFromUri(uri: string, waitForDom = false) {
+ if (waitForDom) {
+ await nextTick()
+ }
+
+ const player = audioPlayer.value
+ if (!player) {
+ throw new Error('Audio player not ready')
+ }
+
+ const finalUri = addCacheBuster(uri)
+ currentUri.value = finalUri
+
+ player.pause()
+ player.src = finalUri
+ player.load()
+
+ // Attendre que le navigateur ait bufferisé suffisamment de données
+ // pour éviter les gaps audio entre les morceaux
+ await waitForSufficientBuffer(player)
+
+ await player.play()
+
+ isPlaying.value = true
+ audioError.value = null
+ resetAudioRetries()
+ startMetadataRefresh()
+}
+
+/**
+ * Attend que le player ait suffisamment bufferisé avant de lancer la lecture
+ */
+async function waitForSufficientBuffer(player: HTMLAudioElement): Promise {
+ return new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => {
+ cleanup()
+ console.warn('Buffer timeout - starting playback anyway')
+ resolve()
+ }, 5000) // Timeout de 5 secondes max
+
+ const cleanup = () => {
+ clearTimeout(timeout)
+ player.removeEventListener('canplay', onCanPlay)
+ player.removeEventListener('error', onError)
+ }
+
+ const onCanPlay = () => {
+ // Vérifier si on a au moins 1-2 secondes de buffer
+ if (player.buffered.length > 0) {
+ const bufferedEnd = player.buffered.end(0)
+ const currentTime = player.currentTime
+ const bufferedSeconds = bufferedEnd - currentTime
+
+ console.log(`Buffer disponible: ${bufferedSeconds.toFixed(2)}s`)
+
+ // Attendre au moins 0.5 secondes de buffer pour réduire les gaps
+ if (bufferedSeconds >= 0.5) {
+ cleanup()
+ resolve()
+ }
+ }
+ }
+
+ const onError = () => {
+ cleanup()
+ reject(new Error('Buffer error'))
+ }
+
+ player.addEventListener('canplay', onCanPlay)
+ player.addEventListener('error', onError)
+
+ // Check immédiatement au cas où c'est déjà prêt
+ if (player.readyState >= 3) {
+ onCanPlay()
+ }
+ })
+}
+
+/**
+ * Tente de relancer la lecture après une erreur (nouvelle résolution d'URI)
+ */
+async function retryPlayback() {
+ if (!selectedSource.value || !currentTrack.value) return
+
+ try {
+ const result = await resolveUri(selectedSource.value.id, currentTrack.value.id)
+ await startPlaybackFromUri(result.uri)
+ } catch (e: any) {
+ audioError.value = `Erreur lors de la reprise: ${e.message}`
+ isPlaying.value = false
+ }
+}