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()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* (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()
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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>
|