Files
pmomusic/pmoapp/webapp/src/components/GenericMusicPlayer.vue
2025-12-06 12:48:21 +01:00

1628 lines
40 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="music-player">
<div class="player-header">
<h1>🎵 PMO Music Player</h1>
<p class="subtitle">Lecteur générique basé sur l'API pmosource</p>
</div>
<!-- Error Message -->
<div v-if="error" class="error-banner">
❌ {{ error }}
</div>
<!-- Source Selection -->
<div class="sources-section">
<h2>📻 Sources Musicales</h2>
<div v-if="sourcesLoading" class="loading-message">
Chargement des sources...
</div>
<div v-else-if="sources.length === 0" class="empty-message">
Aucune source musicale disponible
</div>
<div v-else class="sources-grid">
<div
v-for="source in sources"
:key="source.id"
:class="['source-card', { active: selectedSource?.id === source.id }]"
@click="selectSource(source)"
>
<div class="source-image">
<img :src="getSourceImageUrl(source.id)" :alt="source.name" />
</div>
<div class="source-info">
<div class="source-name">{{ source.name }}</div>
<div class="source-capabilities">
<span v-if="source.supports_fifo" class="capability-badge">FIFO</span>
<span v-if="source.capabilities.supports_search" class="capability-badge">Search</span>
<span v-if="source.capabilities.supports_favorites" class="capability-badge">Favorites</span>
</div>
</div>
</div>
</div>
</div>
<!-- Browser Section (si une source est sélectionnée) -->
<div v-if="selectedSource" class="browser-section">
<div class="browser-header">
<h2>📂 {{ selectedSource.name }}</h2>
<button @click="refreshBrowser" :disabled="browserLoading" class="btn-refresh">
<span v-if="browserLoading">⏳</span>
<span v-else>🔄</span>
Actualiser
</button>
</div>
<!-- Breadcrumb Navigation -->
<div class="breadcrumb">
<button
v-for="(crumb, index) in breadcrumbs"
:key="crumb.id"
@click="navigateToContainer(crumb)"
:class="['breadcrumb-item', { active: index === breadcrumbs.length - 1 }]"
>
{{ crumb.title }}
</button>
</div>
<!-- Browser Loading -->
<div v-if="browserLoading" class="loading-message">
Chargement...
</div>
<!-- Containers List -->
<div v-if="browseResult && browseResult.containers.length > 0" class="containers-section">
<h3>📁 Dossiers</h3>
<div class="containers-list">
<div
v-for="container in browseResult.containers"
:key="container.id"
class="container-item"
@click="navigateIntoContainer(container)"
>
<div class="container-icon">📁</div>
<div class="container-info">
<div class="container-title">{{ container.title }}</div>
<div class="container-meta">
<span v-if="container.child_count">{{ container.child_count }} éléments</span>
</div>
</div>
</div>
</div>
</div>
<!-- Items List -->
<div v-if="browseResult && browseResult.items.length > 0" class="items-section">
<h3>🎵 Morceaux</h3>
<div class="items-list">
<div
v-for="(item, index) in browseResult.items"
:key="item.id"
:class="['item-card', {
active: currentTrack?.id === item.id,
playing: isPlaying && currentTrack?.id === item.id
}]"
>
<div class="item-number">{{ index + 1 }}</div>
<div class="item-cover">
<img
v-if="item.album_art"
:src="item.album_art"
:alt="item.title"
@error="handleImageError"
/>
<div v-else class="item-cover-placeholder">🎵</div>
</div>
<div class="item-info">
<div class="item-title">{{ item.title }}</div>
<div class="item-artist">{{ item.artist || item.creator || 'Artiste inconnu' }}</div>
<div class="item-album">{{ item.album || '' }}</div>
</div>
<div class="item-actions">
<button
@click="playTrack(item)"
:class="['btn-play-item', { active: isPlaying && currentTrack?.id === item.id }]"
>
<span v-if="isPlaying && currentTrack?.id === item.id">⏸️</span>
<span v-else>▶️</span>
</button>
</div>
</div>
</div>
</div>
<!-- Empty State -->
<div v-if="browseResult && browseResult.total === 0" class="empty-message">
Ce container est vide
</div>
</div>
<!-- Now Playing Section -->
<div v-if="currentTrack || audioError" class="now-playing-section">
<div class="now-playing-header">
<h2>🎧 Lecture en cours</h2>
<button v-if="isPlaying" @click="stopPlayback" class="btn-stop">
⏹️ Arrêter
</button>
</div>
<div v-if="audioError" class="audio-error">
❌ {{ audioError }}
</div>
<div v-if="currentTrack" class="now-playing-content">
<div class="now-playing-cover">
<img
v-if="currentTrack.album_art"
:src="currentTrack.album_art"
:alt="currentTrack.title"
@error="handleImageError"
/>
<div v-else class="now-playing-cover-placeholder">🎵</div>
</div>
<div class="now-playing-info">
<div class="now-playing-title">{{ currentTrack.title }}</div>
<div class="now-playing-artist">{{ currentTrack.artist || currentTrack.creator || 'Artiste inconnu' }}</div>
<div class="now-playing-album">{{ currentTrack.album || '' }}</div>
<div class="now-playing-source">Source: {{ selectedSource?.name }}</div>
</div>
</div>
<!-- Progress Bar -->
<div v-if="currentTrack" class="progress-container">
<div class="progress-time">{{ formatTime(currentTime) }}</div>
<div class="progress-bar">
<div
class="progress-fill"
:style="{ width: duration > 0 ? (currentTime / duration * 100) + '%' : '0%' }"
></div>
</div>
<div class="progress-time">
{{ duration > 0 ? formatTime(duration) : formatTime(parseDuration(currentTrack.resources[0]?.duration)) }}
</div>
</div>
<!-- Audio Player -->
<div class="audio-player-container">
<audio
ref="audioPlayer"
preload="auto"
@ended="handleAudioEnded"
@error="handleAudioError"
@play="handleAudioPlay"
@pause="handleAudioPause"
@timeupdate="handleTimeUpdate"
@loadedmetadata="handleLoadedMetadata"
@durationchange="handleDurationChange"
@canplay="handleCanPlay"
@waiting="handleAudioWaiting"
@stalled="handleAudioStalled"
@progress="handleAudioProgress"
></audio>
</div>
</div>
<!-- Debug Info -->
<div v-if="currentTrack" class="debug-info">
<details>
<summary>🔧 Informations de debug</summary>
<div class="debug-content">
<div><strong>Source ID:</strong> {{ selectedSource?.id }}</div>
<div><strong>Object ID:</strong> {{ currentTrack.id }}</div>
<div><strong>Current URI:</strong> {{ currentUri }}</div>
<div><strong>Track Class:</strong> {{ currentTrack.class }}</div>
</div>
</details>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import {
listSources,
browseSource,
resolveUri,
getSourceImageUrl,
type SourceInfo,
type BrowseResponse,
type BrowseItem,
type BrowseContainer,
} from '../services/pmosource'
// State
const sources = ref<SourceInfo[]>([])
const sourcesLoading = ref(false)
const selectedSource = ref<SourceInfo | null>(null)
const browseResult = ref<BrowseResponse | null>(null)
const browserLoading = ref(false)
const error = ref<string | null>(null)
// Breadcrumb navigation
interface Breadcrumb {
id: string
title: string
}
const breadcrumbs = ref<Breadcrumb[]>([])
const currentObjectId = ref<string | null>(null)
// Audio player state
const currentTrack = ref<BrowseItem | null>(null)
const currentUri = ref<string | null>(null)
const isPlaying = ref(false)
const audioError = ref<string | null>(null)
const audioPlayer = ref<HTMLAudioElement | null>(null)
const currentTime = ref(0)
const duration = ref(0)
const lastTimeUpdateTs = ref<number | null>(null)
const lastLoggedTimeupdate = ref<number>(0)
const lastReadyState = ref<number | null>(null)
const lastNetworkState = ref<number | null>(null)
// Retry timer cleanup (auto-reload désactivé pour les streams live)
let retryTimer: ReturnType<typeof setTimeout> | null = null
let stallMonitor: ReturnType<typeof setInterval> | null = null
let stateMonitor: ReturnType<typeof setInterval> | null = null
// Metadata refresh via SSE
let metadataEventSource: EventSource | null = null
// Load sources on mount
onMounted(async () => {
await loadSources()
// Monitor pour détecter une éventuelle stagnation du currentTime
stallMonitor = setInterval(() => {
const p = audioPlayer.value
if (!p || !isPlaying.value) return
if (!lastTimeUpdateTs.value) return
const now = Date.now()
// Si aucun timeupdate depuis >5s et lecture en cours, logguer l'état du buffer
if (now - lastTimeUpdateTs.value > 5000) {
const buffered: string[] = []
for (let i = 0; i < p.buffered.length; i++) {
buffered.push(`${p.buffered.start(i).toFixed(3)}-${p.buffered.end(i).toFixed(3)}`)
}
console.debug('[PlayerDebug] stall-detected', {
ts: now,
currentTime: p.currentTime.toFixed(3),
duration: isFinite(p.duration) ? p.duration.toFixed(3) : 'NaN',
readyState: p.readyState,
networkState: p.networkState,
paused: p.paused,
buffered,
})
// Eviter le spam : reset le marqueur pour n'émettre qu'une fois par stagnation
lastTimeUpdateTs.value = null
}
}, 2000)
// Monitor périodique des changements de readyState/networkState
stateMonitor = setInterval(() => {
const p = audioPlayer.value
if (!p) return
const rs = p.readyState
const ns = p.networkState
if (rs !== lastReadyState.value || ns !== lastNetworkState.value) {
const buffered: string[] = []
for (let i = 0; i < p.buffered.length; i++) {
buffered.push(`${p.buffered.start(i).toFixed(3)}-${p.buffered.end(i).toFixed(3)}`)
}
console.debug('[PlayerDebug] state-change', {
ts: Date.now(),
currentTime: p.currentTime.toFixed(3),
readyState: rs,
networkState: ns,
paused: p.paused,
buffered,
})
lastReadyState.value = rs
lastNetworkState.value = ns
}
}, 1000)
})
// Cleanup on unmount
onUnmounted(() => {
stopMetadataRefresh()
clearRetryTimer()
if (stallMonitor) {
clearInterval(stallMonitor)
stallMonitor = null
}
if (stateMonitor) {
clearInterval(stateMonitor)
stateMonitor = null
}
})
/**
* Charge la liste des sources disponibles
*/
async function loadSources() {
sourcesLoading.value = true
error.value = null
try {
const result = await listSources()
sources.value = result.sources
} catch (e: any) {
error.value = `Erreur lors du chargement des sources: ${e.message}`
console.error('Failed to load sources:', e)
} finally {
sourcesLoading.value = false
}
}
/**
* Sélectionne une source et charge son contenu racine
*/
async function selectSource(source: SourceInfo) {
selectedSource.value = source
currentObjectId.value = null
breadcrumbs.value = [{ id: source.id, title: source.name }]
await loadBrowseResult()
}
/**
* Charge le contenu d'un container
*/
async function loadBrowseResult() {
if (!selectedSource.value) return
browserLoading.value = true
error.value = null
try {
browseResult.value = await browseSource(
selectedSource.value.id,
currentObjectId.value || undefined
)
} catch (e: any) {
error.value = `Erreur lors de la navigation: ${e.message}`
console.error('Failed to browse:', e)
} finally {
browserLoading.value = false
}
}
/**
* Navigue dans un container enfant
*/
async function navigateIntoContainer(container: BrowseContainer) {
currentObjectId.value = container.id
breadcrumbs.value.push({
id: container.id,
title: container.title,
})
await loadBrowseResult()
}
/**
* Navigue vers un container du breadcrumb
*/
async function navigateToContainer(crumb: Breadcrumb) {
// Find the index of this crumb
const index = breadcrumbs.value.findIndex((c) => c.id === crumb.id)
if (index === -1) return
// Truncate breadcrumbs to this point
breadcrumbs.value = breadcrumbs.value.slice(0, index + 1)
// Set current object ID (root if it's the first breadcrumb)
if (index === 0) {
currentObjectId.value = null
} else {
currentObjectId.value = crumb.id
}
await loadBrowseResult()
}
/**
* Actualise le contenu courant
*/
async function refreshBrowser() {
await loadBrowseResult()
}
/**
* Démarre le stream de métadonnées en temps réel via SSE
*/
function startMetadataRefresh() {
if (!selectedSource.value || !currentTrack.value) return
// Stop any existing connection
stopMetadataRefresh()
// Build SSE endpoint URL
const params = new URLSearchParams({ object_id: currentTrack.value.id })
const sseUrl = `/api/sources/${selectedSource.value.id}/item/stream?${params.toString()}`
console.log('Connecting to SSE:', sseUrl)
// Create EventSource connection
metadataEventSource = new EventSource(sseUrl)
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) {
const parsedDuration = parseDuration(newDuration)
if (parsedDuration > 0) {
duration.value = parsedDuration
}
}
}
} catch (e: any) {
console.error('Failed to parse SSE metadata:', e)
}
}
metadataEventSource.onerror = (error) => {
console.error('SSE connection error:', error)
// Don't show error to user, SSE will auto-reconnect
}
}
/**
* Arrête le stream de métadonnées
*/
function stopMetadataRefresh() {
if (metadataEventSource) {
metadataEventSource.close()
metadataEventSource = null
console.log('SSE connection closed')
}
}
/**
* Joue un morceau
*/
async function playTrack(item: BrowseItem) {
if (!selectedSource.value) return
// Si c'est le même morceau en cours de lecture, toggle play/pause
if (currentTrack.value?.id === item.id && isPlaying.value) {
audioPlayer.value?.pause()
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
const trackDuration = item.resources[0]?.duration
if (trackDuration) {
duration.value = parseDuration(trackDuration)
} else {
duration.value = 0
}
try {
// Résout l'URI du morceau via l'API
const result = await resolveUri(selectedSource.value.id, item.id)
let uri = result.uri
// 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)
}
}
/**
* Arrête la lecture
*/
function stopPlayback() {
stopMetadataRefresh()
if (audioPlayer.value) {
audioPlayer.value.pause()
audioPlayer.value.currentTime = 0
audioPlayer.value.src = ''
}
isPlaying.value = false
currentTrack.value = null
currentUri.value = null
audioError.value = null
currentTime.value = 0
duration.value = 0
resetAudioRetries()
}
/**
* Gère la fin de la lecture
*/
function handleAudioEnded() {
isPlaying.value = false
currentTime.value = 0
stopMetadataRefresh()
// On peut implémenter ici une logique de lecture automatique du prochain morceau
const p = audioPlayer.value
if (p) {
console.debug('[PlayerDebug] ended', {
ts: Date.now(),
currentTime: p.currentTime.toFixed(3),
duration: isFinite(p.duration) ? p.duration.toFixed(3) : 'NaN',
readyState: p.readyState,
networkState: p.networkState,
})
} else {
console.debug('[PlayerDebug] ended (no player)')
}
}
/**
* Gère les erreurs audio
*/
function handleAudioError() {
stopMetadataRefresh()
const audio = audioPlayer.value
const errorCode = audio?.error?.code
const baseMessage = errorCode
? `Erreur de lecture audio (code ${errorCode})`
: 'Erreur de lecture audio inconnue'
// Pour les streams live (Radio Paradise), ne pas recharger le flux : cela remet la progression à 0
// et crée un lag perceptible. On se contente d'afficher l'erreur et on tente un play() direct si possible.
audioError.value = baseMessage
isPlaying.value = false
// Tenter une reprise douce sans reload si le player a déjà des données bufferisées
const player = audioPlayer.value
if (player && !player.paused) {
// Rien à faire, il lit encore ou se relancera tout seul
return
}
if (player && player.readyState >= 2) {
console.debug('[PlayerDebug] error', {
ts: Date.now(),
message: baseMessage,
code: audio?.error?.code,
currentTime: player.currentTime.toFixed(3),
duration: isFinite(player.duration) ? player.duration.toFixed(3) : 'NaN',
readyState: player.readyState,
networkState: player.networkState,
buffered: (() => {
const arr: string[] = []
for (let i = 0; i < player.buffered.length; i++) {
arr.push(`${player.buffered.start(i).toFixed(3)}-${player.buffered.end(i).toFixed(3)}`)
}
return arr
})(),
})
void player.play().catch(() => {
// Si play échoue (user gesture requis ou decode error), on laisse l'erreur affichée
})
} else {
console.debug('[PlayerDebug] error (no retry)', {
ts: Date.now(),
message: baseMessage,
code: audio?.error?.code,
readyState: player?.readyState,
networkState: player?.networkState,
})
}
}
/**
* Gère le démarrage de la lecture
*/
function handleAudioPlay() {
isPlaying.value = true
audioError.value = null
startMetadataRefresh()
}
/**
* Gère la pause
*/
function handleAudioPause() {
isPlaying.value = false
// Don't stop refresh on pause, user might resume
const p = audioPlayer.value
if (p) {
const buffered: string[] = []
for (let i = 0; i < p.buffered.length; i++) {
buffered.push(`${p.buffered.start(i).toFixed(3)}-${p.buffered.end(i).toFixed(3)}`)
}
console.debug('[PlayerDebug] pause', {
ts: Date.now(),
currentTime: p.currentTime.toFixed(3),
readyState: p.readyState,
networkState: p.networkState,
paused: p.paused,
buffered,
})
}
}
/**
* Gère la mise à jour du temps de lecture
*/
function handleTimeUpdate() {
if (audioPlayer.value) {
currentTime.value = audioPlayer.value.currentTime
lastTimeUpdateTs.value = Date.now()
// Log périodiquement l'état du buffer pour diagnostiquer les coupures
const logEvery = 2 // secondes (plus serré pour capturer les transitions)
if (Math.floor(currentTime.value) % logEvery === 0 && Math.floor(currentTime.value) !== lastLoggedTimeupdate.value) {
const p = audioPlayer.value
const buffered: string[] = []
for (let i = 0; i < p.buffered.length; i++) {
buffered.push(`${p.buffered.start(i).toFixed(3)}-${p.buffered.end(i).toFixed(3)}`)
}
console.debug('[PlayerDebug] timeupdate', {
ts: Date.now(),
currentTime: p.currentTime.toFixed(3),
duration: isFinite(p.duration) ? p.duration.toFixed(3) : 'NaN',
readyState: p.readyState,
networkState: p.networkState,
paused: p.paused,
buffered,
})
lastLoggedTimeupdate.value = Math.floor(currentTime.value)
}
// Si le temps recule (changement de piste) logguer explicitement l'état
if (currentTime.value < lastLoggedTimeupdate.value) {
const p = audioPlayer.value
const buffered: string[] = []
for (let i = 0; i < p.buffered.length; i++) {
buffered.push(`${p.buffered.start(i).toFixed(3)}-${p.buffered.end(i).toFixed(3)}`)
}
console.debug('[PlayerDebug] time-jump', {
ts: Date.now(),
currentTime: p.currentTime.toFixed(3),
duration: isFinite(p.duration) ? p.duration.toFixed(3) : 'NaN',
readyState: p.readyState,
networkState: p.networkState,
paused: p.paused,
buffered,
})
}
}
}
/**
* Gère le chargement des métadonnées audio
*/
function handleLoadedMetadata() {
if (audioPlayer.value && !isNaN(audioPlayer.value.duration)) {
duration.value = audioPlayer.value.duration
}
}
/**
* Gère le changement de durée
*/
function handleDurationChange() {
if (audioPlayer.value && !isNaN(audioPlayer.value.duration)) {
duration.value = audioPlayer.value.duration
}
}
/**
* Gère l'événement canplay (audio prêt à être joué)
*/
function handleCanPlay() {
console.log('Audio ready to play (canplay event)')
}
function handleAudioWaiting() {
const p = audioPlayer.value
if (!p) return
const buffered: string[] = []
for (let i = 0; i < p.buffered.length; i++) {
buffered.push(`${p.buffered.start(i).toFixed(3)}-${p.buffered.end(i).toFixed(3)}`)
}
console.debug('[PlayerDebug] waiting', {
ts: Date.now(),
currentTime: p.currentTime.toFixed(3),
duration: isFinite(p.duration) ? p.duration.toFixed(3) : 'NaN',
readyState: p.readyState,
networkState: p.networkState,
paused: p.paused,
buffered,
})
}
function handleAudioStalled() {
const p = audioPlayer.value
if (!p) return
const buffered: string[] = []
for (let i = 0; i < p.buffered.length; i++) {
buffered.push(`${p.buffered.start(i).toFixed(3)}-${p.buffered.end(i).toFixed(3)}`)
}
console.debug('[PlayerDebug] stalled', {
ts: Date.now(),
currentTime: p.currentTime.toFixed(3),
duration: isFinite(p.duration) ? p.duration.toFixed(3) : 'NaN',
readyState: p.readyState,
networkState: p.networkState,
paused: p.paused,
buffered,
})
}
function handleAudioProgress() {
const p = audioPlayer.value
if (!p) return
const buffered: string[] = []
for (let i = 0; i < p.buffered.length; i++) {
buffered.push(`${p.buffered.start(i).toFixed(3)}-${p.buffered.end(i).toFixed(3)}`)
}
console.debug('[PlayerDebug] progress', {
ts: Date.now(),
currentTime: p.currentTime.toFixed(3),
duration: isFinite(p.duration) ? p.duration.toFixed(3) : 'NaN',
readyState: p.readyState,
networkState: p.networkState,
paused: p.paused,
buffered,
})
}
/**
* Formatte le temps en MM:SS
*/
function formatTime(seconds: number): string {
if (!isFinite(seconds)) return '--:--'
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins}:${secs.toString().padStart(2, '0')}`
}
/**
* Parse une durée au format UPnP (H:MM:SS ou H:MM:SS.mmm) en secondes
*/
function parseDuration(durationStr: string | null | undefined): number {
if (!durationStr) return 0
const parts = durationStr.split(':')
if (parts.length === 3) {
const hours = parseInt(parts[0] || '0', 10) || 0
const minutes = parseInt(parts[1] || '0', 10) || 0
const seconds = parseFloat(parts[2] || '0') || 0
return hours * 3600 + minutes * 60 + seconds
}
return 0
}
/**
* Gère les erreurs de chargement d'image
*/
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 l'état de retry (timer uniquement)
function resetAudioRetries() {
clearRetryTimer()
}
/**
* (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<void> {
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()
}
})
}
</script>
<style scoped>
.music-player {
padding: 20px;
max-width: 1400px;
margin: 0 auto;
}
.player-header {
text-align: center;
margin-bottom: 40px;
}
.player-header h1 {
margin: 0;
color: #00d4ff;
font-size: 2.5rem;
}
.subtitle {
color: #9aa0a6;
font-size: 1rem;
margin-top: 8px;
}
.error-banner {
background: rgba(255, 68, 68, 0.15);
border: 1px solid rgba(255, 68, 68, 0.4);
color: #ff6b6b;
padding: 16px;
border-radius: 8px;
margin-bottom: 20px;
}
/* Sources Section */
.sources-section {
background: #1a1a2e;
border-radius: 12px;
padding: 24px;
margin-bottom: 30px;
border: 1px solid rgba(0, 212, 255, 0.3);
}
.sources-section h2 {
margin-top: 0;
color: #00d4ff;
}
.loading-message {
color: #9aa0a6;
text-align: center;
padding: 20px;
}
.empty-message {
color: #9aa0a6;
text-align: center;
padding: 20px;
border: 1px dashed #444;
border-radius: 8px;
}
.sources-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 16px;
}
.source-card {
background: #2a2a3e;
border-radius: 8px;
padding: 16px;
cursor: pointer;
transition: all 0.3s;
border: 2px solid transparent;
display: flex;
align-items: center;
gap: 16px;
}
.source-card:hover {
background: #333350;
transform: translateY(-2px);
}
.source-card.active {
border-color: #00d4ff;
background: rgba(0, 212, 255, 0.1);
}
.source-image img {
width: 80px;
height: 80px;
object-fit: cover;
border-radius: 8px;
}
.source-info {
flex: 1;
}
.source-name {
font-weight: bold;
color: #fff;
font-size: 1.1rem;
margin-bottom: 8px;
}
.source-capabilities {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.capability-badge {
background: rgba(0, 212, 255, 0.2);
color: #00d4ff;
padding: 2px 8px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
}
/* Browser Section */
.browser-section {
background: #1a1a2e;
border-radius: 12px;
padding: 24px;
margin-bottom: 30px;
border: 1px solid rgba(0, 212, 255, 0.3);
}
.browser-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.browser-header h2 {
margin: 0;
color: #00d4ff;
}
.btn-refresh {
background: rgba(0, 212, 255, 0.15);
border: 1px solid rgba(0, 212, 255, 0.4);
color: #00d4ff;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 8px;
}
.btn-refresh:hover:not(:disabled) {
background: rgba(0, 212, 255, 0.25);
}
.btn-refresh:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Breadcrumb */
.breadcrumb {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid rgba(0, 212, 255, 0.2);
}
.breadcrumb-item {
background: rgba(0, 212, 255, 0.1);
border: 1px solid rgba(0, 212, 255, 0.3);
color: #00d4ff;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
.breadcrumb-item:hover {
background: rgba(0, 212, 255, 0.2);
}
.breadcrumb-item.active {
background: rgba(0, 212, 255, 0.25);
font-weight: bold;
}
.breadcrumb-item:not(:last-child)::after {
content: '';
margin-left: 8px;
color: #666;
}
/* Containers */
.containers-section {
margin-bottom: 30px;
}
.containers-section h3 {
color: #00d4ff;
margin-bottom: 12px;
}
.containers-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
}
.container-item {
background: rgba(0, 212, 255, 0.05);
border: 1px solid rgba(0, 212, 255, 0.2);
border-radius: 8px;
padding: 14px;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 12px;
}
.container-item:hover {
background: rgba(0, 212, 255, 0.1);
border-color: rgba(0, 212, 255, 0.4);
}
.container-icon {
font-size: 2rem;
}
.container-info {
flex: 1;
}
.container-title {
font-weight: 600;
color: #fff;
margin-bottom: 4px;
}
.container-meta {
font-size: 0.85rem;
color: #9aa0a6;
}
/* Items */
.items-section {
margin-bottom: 30px;
}
.items-section h3 {
color: #00d4ff;
margin-bottom: 12px;
}
.items-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.item-card {
background: #2a2a3e;
border-radius: 8px;
padding: 12px;
display: flex;
align-items: center;
gap: 12px;
transition: all 0.2s;
border: 2px solid transparent;
}
.item-card:hover {
background: #333350;
}
.item-card.active {
border-color: rgba(0, 212, 255, 0.5);
}
.item-card.playing {
border-color: #00d4ff;
background: rgba(0, 212, 255, 0.1);
}
.item-number {
font-weight: bold;
color: #666;
min-width: 30px;
text-align: center;
}
.item-cover {
width: 60px;
height: 60px;
flex-shrink: 0;
}
.item-cover img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 6px;
}
.item-cover-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 212, 255, 0.1);
border-radius: 6px;
font-size: 1.5rem;
}
.item-info {
flex: 1;
min-width: 0;
}
.item-title {
font-weight: 600;
color: #fff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 4px;
}
.item-artist {
color: #00d4ff;
font-size: 0.9rem;
margin-bottom: 2px;
}
.item-album {
color: #9aa0a6;
font-size: 0.85rem;
}
.item-actions {
display: flex;
gap: 8px;
}
.btn-play-item {
background: rgba(46, 204, 113, 0.2);
border: 1px solid rgba(46, 204, 113, 0.4);
color: #2ecc71;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 1.1rem;
transition: all 0.2s;
min-width: 50px;
}
.btn-play-item:hover {
background: rgba(46, 204, 113, 0.3);
}
.btn-play-item.active {
background: rgba(46, 204, 113, 0.4);
border-color: #2ecc71;
}
/* Now Playing */
.now-playing-section {
background: linear-gradient(135deg, #1a1a2e 0%, #0f0f1e 100%);
border-radius: 12px;
padding: 24px;
margin-bottom: 30px;
border: 2px solid rgba(0, 212, 255, 0.4);
position: sticky;
bottom: 20px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}
.now-playing-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.now-playing-header h2 {
margin: 0;
color: #00d4ff;
}
.btn-stop {
background: rgba(231, 76, 60, 0.2);
border: 1px solid rgba(231, 76, 60, 0.4);
color: #e74c3c;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
transition: all 0.2s;
}
.btn-stop:hover {
background: rgba(231, 76, 60, 0.3);
}
.audio-error {
background: rgba(255, 68, 68, 0.15);
border: 1px solid rgba(255, 68, 68, 0.4);
color: #ff6b6b;
padding: 12px;
border-radius: 6px;
margin-bottom: 16px;
}
.now-playing-content {
display: flex;
gap: 20px;
align-items: center;
margin-bottom: 16px;
}
.now-playing-cover {
width: 120px;
height: 120px;
flex-shrink: 0;
}
.now-playing-cover img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 8px;
border: 2px solid rgba(0, 212, 255, 0.3);
}
.now-playing-cover-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 212, 255, 0.1);
border-radius: 8px;
font-size: 3rem;
border: 2px solid rgba(0, 212, 255, 0.3);
}
.now-playing-info {
flex: 1;
}
.now-playing-title {
font-size: 1.5rem;
font-weight: bold;
color: #fff;
margin-bottom: 8px;
}
.now-playing-artist {
font-size: 1.2rem;
color: #00d4ff;
margin-bottom: 4px;
}
.now-playing-album {
font-size: 1rem;
color: #9aa0a6;
margin-bottom: 8px;
}
.now-playing-source {
font-size: 0.85rem;
color: #666;
}
/* Progress Bar */
.progress-container {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 0;
width: 100%;
}
.progress-time {
font-size: 0.85rem;
color: #9aa0a6;
font-variant-numeric: tabular-nums;
min-width: 45px;
text-align: center;
}
.progress-bar {
flex: 1;
height: 6px;
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
overflow: hidden;
position: relative;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #00d4ff, #00a8cc);
border-radius: 3px;
transition: width 0.1s linear;
box-shadow: 0 0 10px rgba(0, 212, 255, 0.5);
}
.audio-player-container {
display: none;
}
/* Debug Info */
.debug-info {
background: rgba(0, 0, 0, 0.3);
border-radius: 8px;
padding: 16px;
border: 1px solid #333;
}
.debug-info summary {
cursor: pointer;
color: #9aa0a6;
font-weight: 600;
margin-bottom: 12px;
}
.debug-content {
margin-top: 12px;
display: flex;
flex-direction: column;
gap: 8px;
font-family: 'Courier New', monospace;
font-size: 0.85rem;
color: #9aa0a6;
}
.debug-content strong {
color: #00d4ff;
}
/* Responsive */
@media (max-width: 768px) {
.sources-grid {
grid-template-columns: 1fr;
}
.containers-list {
grid-template-columns: 1fr;
}
.now-playing-content {
flex-direction: column;
text-align: center;
}
.now-playing-cover {
width: 150px;
height: 150px;
}
}
</style>