Files
pmomusic/pmoapp/webapp/src/components/GenericMusicPlayer.vue

1628 lines
40 KiB
Vue
Raw Normal View History

2025-11-26 12:20:28 +01:00
<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"
2025-11-26 18:30:30 +01:00
preload="auto"
2025-11-26 12:20:28 +01:00
@ended="handleAudioEnded"
@error="handleAudioError"
@play="handleAudioPlay"
@pause="handleAudioPause"
@timeupdate="handleTimeUpdate"
@loadedmetadata="handleLoadedMetadata"
@durationchange="handleDurationChange"
2025-11-26 18:30:30 +01:00
@canplay="handleCanPlay"
2025-11-27 20:27:52 +01:00
@waiting="handleAudioWaiting"
@stalled="handleAudioStalled"
@progress="handleAudioProgress"
2025-11-26 12:20:28 +01:00
></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)
2025-11-27 20:27:52 +01:00
const lastTimeUpdateTs = ref<number | null>(null)
const lastLoggedTimeupdate = ref<number>(0)
const lastReadyState = ref<number | null>(null)
const lastNetworkState = ref<number | null>(null)
2025-11-26 12:20:28 +01:00
2025-11-27 20:27:52 +01:00
// Retry timer cleanup (auto-reload désactivé pour les streams live)
2025-11-26 18:30:30 +01:00
let retryTimer: ReturnType<typeof setTimeout> | null = null
2025-11-27 20:27:52 +01:00
let stallMonitor: ReturnType<typeof setInterval> | null = null
let stateMonitor: ReturnType<typeof setInterval> | null = null
2025-11-26 18:30:30 +01:00
2025-11-26 12:20:28 +01:00
// Metadata refresh via SSE
let metadataEventSource: EventSource | null = null
// Load sources on mount
onMounted(async () => {
await loadSources()
2025-11-27 20:27:52 +01:00
// 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)
2025-11-26 12:20:28 +01:00
})
// Cleanup on unmount
onUnmounted(() => {
stopMetadataRefresh()
2025-11-26 18:30:30 +01:00
clearRetryTimer()
2025-11-27 20:27:52 +01:00
if (stallMonitor) {
clearInterval(stallMonitor)
stallMonitor = null
}
if (stateMonitor) {
clearInterval(stateMonitor)
stateMonitor = null
}
2025-11-26 12:20:28 +01:00
})
/**
* 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 {
2025-11-26 18:30:30 +01:00
const previous = currentTrack.value
2025-11-26 12:20:28 +01:00
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)
2025-11-26 18:30:30 +01:00
// 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)
}
2025-11-26 12:20:28 +01:00
// 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
}
2025-11-26 18:30:30 +01:00
resetAudioRetries()
2025-11-26 12:20:28 +01:00
audioError.value = null
currentTrack.value = item
2025-11-26 18:30:30 +01:00
const preferredUri = pickPreferredResourceUri(item)
2025-11-26 12:20:28 +01:00
// 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 {
2025-11-26 18:30:30 +01:00
// Résout l'URI du morceau via l'API
2025-11-26 12:20:28 +01:00
const result = await resolveUri(selectedSource.value.id, item.id)
2025-11-26 18:30:30 +01:00
let uri = result.uri
2025-11-26 12:20:28 +01:00
2025-11-26 18:30:30 +01:00
// 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)
2025-11-26 12:20:28 +01:00
2025-11-26 18:30:30 +01:00
if (preferredFormat && canPlayAudioType(preferredFormat)) {
if (!resolvedFormat || !canPlayAudioType(resolvedFormat)) {
console.log(`Remplacement de ${uri} par ${preferredUri} (meilleur support navigateur)`)
uri = preferredUri
}
}
}
2025-11-26 12:20:28 +01:00
2025-11-26 18:30:30 +01:00
if (!uri) {
throw new Error('Aucune URI audio disponible')
2025-11-26 12:20:28 +01:00
}
2025-11-26 18:30:30 +01:00
await startPlaybackFromUri(uri, true)
2025-11-26 12:20:28 +01:00
} 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
2025-11-26 18:30:30 +01:00
resetAudioRetries()
2025-11-26 12:20:28 +01:00
}
/**
* Gère la fin de la lecture
*/
function handleAudioEnded() {
isPlaying.value = false
2025-11-26 18:30:30 +01:00
currentTime.value = 0
2025-11-26 12:20:28 +01:00
stopMetadataRefresh()
// On peut implémenter ici une logique de lecture automatique du prochain morceau
2025-11-27 20:27:52 +01:00
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)')
}
2025-11-26 12:20:28 +01:00
}
/**
* Gère les erreurs audio
*/
function handleAudioError() {
stopMetadataRefresh()
const audio = audioPlayer.value
2025-11-26 18:30:30 +01:00
const errorCode = audio?.error?.code
const baseMessage = errorCode
? `Erreur de lecture audio (code ${errorCode})`
: 'Erreur de lecture audio inconnue'
2025-11-27 20:27:52 +01:00
// 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.
2025-11-26 18:30:30 +01:00
audioError.value = baseMessage
2025-11-26 12:20:28 +01:00
isPlaying.value = false
2025-11-27 20:27:52 +01:00
// 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,
})
}
2025-11-26 12:20:28 +01:00
}
/**
* Gère le démarrage de la lecture
*/
function handleAudioPlay() {
isPlaying.value = true
2025-11-26 18:30:30 +01:00
audioError.value = null
2025-11-26 12:20:28 +01:00
startMetadataRefresh()
}
/**
* Gère la pause
*/
function handleAudioPause() {
isPlaying.value = false
// Don't stop refresh on pause, user might resume
2025-11-27 20:27:52 +01:00
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,
})
}
2025-11-26 12:20:28 +01:00
}
/**
* Gère la mise à jour du temps de lecture
*/
function handleTimeUpdate() {
if (audioPlayer.value) {
currentTime.value = audioPlayer.value.currentTime
2025-11-27 20:27:52 +01:00
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,
})
}
2025-11-26 12:20:28 +01:00
}
}
/**
* 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
}
}
2025-11-26 18:30:30 +01:00
/**
* Gère l'événement canplay (audio prêt à être joué)
*/
function handleCanPlay() {
console.log('Audio ready to play (canplay event)')
}
2025-11-27 20:27:52 +01:00
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,
})
}
2025-11-26 12:20:28 +01:00
/**
* 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'
}
2025-11-26 18:30:30 +01:00
/**
* 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
}
}
2025-11-27 20:27:52 +01:00
// Réinitialise l'état de retry (timer uniquement)
2025-11-26 18:30:30 +01:00
function resetAudioRetries() {
clearRetryTimer()
}
/**
* ()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()
}
})
}
2025-11-26 12:20:28 +01:00
</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 {
2025-11-26 18:30:30 +01:00
display: none;
2025-11-26 12:20:28 +01:00
}
/* 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>