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

2833 lines
66 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="radio-paradise-explorer">
<div class="header">
<div class="title-block">
<h2>Radio Paradise Explorer</h2>
<div class="status-bar">
<span v-if="lastUpdated">Updated {{ formatTimestamp(lastUpdated) }}</span>
<span v-if="nowPlaying">Current block #{{ nowPlaying.event }}</span>
</div>
</div>
<div class="controls">
<button @click="refreshNowPlaying" :disabled="loading" class="btn-primary">
<span v-if="loading"></span>
<span v-else>🔄</span>
Refresh
</button>
<button
@click="togglePlayback"
class="btn-play"
>
{{ isPlaying ? '⏹️ Stop' : '▶️ Play' }}
</button>
<select v-model="selectedChannel" @change="changeChannel" class="channel-select">
<option v-for="channel in channels" :key="channel.id" :value="channel.id">
{{ channel.name }}
</option>
</select>
</div>
</div>
<!-- Stream Format & Mode Selection -->
<div class="stream-controls-section">
<h3>🎚 Stream Configuration</h3>
<div class="stream-controls-grid">
<div class="control-group">
<label>Stream Format</label>
<div class="format-chips">
<button
v-for="format in streamFormats"
:key="format.id"
:class="['format-chip', { active: selectedFormat === format.id }]"
@click="selectedFormat = format.id"
>
{{ format.name }}
</button>
</div>
<div class="format-description">{{ currentFormatDescription }}</div>
</div>
<div class="control-group">
<label>Playback Mode</label>
<div class="mode-chips">
<button
:class="['mode-chip', { active: playbackMode === 'live' }]"
@click="playbackMode = 'live'"
>
📡 Live
</button>
<button
:class="['mode-chip', { active: playbackMode === 'historic' }]"
@click="playbackMode = 'historic'"
>
🕰 Historic
</button>
</div>
</div>
<div v-if="playbackMode === 'historic'" class="control-group">
<label>Client ID (for history tracking)</label>
<input
v-model="historicClientId"
type="text"
placeholder="Enter unique client ID"
class="client-id-input"
/>
</div>
</div>
</div>
<!-- Stream Diagnostics -->
<div class="stream-diagnostics-section">
<div class="section-header">
<h3>🔧 Stream Diagnostics</h3>
<button class="btn-tertiary" @click="copyStreamUrl">
📋 Copy Stream URL
</button>
</div>
<div class="diagnostics-grid">
<div class="diagnostic-card">
<div class="diagnostic-label">Current Stream URL</div>
<div class="diagnostic-value">
<code>{{ currentStreamUrl }}</code>
</div>
</div>
<div class="diagnostic-card">
<div class="diagnostic-label">Format</div>
<div class="diagnostic-value">{{ selectedFormat.toUpperCase() }}</div>
</div>
<div class="diagnostic-card">
<div class="diagnostic-label">Mode</div>
<div class="diagnostic-value">{{ playbackMode === 'live' ? 'Live Stream' : 'Historical Playback' }}</div>
</div>
<div class="diagnostic-card">
<div class="diagnostic-label">Channel Slug</div>
<div class="diagnostic-value">{{ currentChannelSlug }}</div>
</div>
</div>
<div class="stream-urls-list">
<div class="url-item" v-for="format in streamFormats" :key="`url-${format.id}`">
<strong>{{ format.name }}:</strong>
<a :href="getStreamUrlFor(format.id)" target="_blank" class="stream-link">
{{ getStreamUrlFor(format.id) }}
</a>
</div>
</div>
</div>
<!-- Playlist SSE Events -->
<div class="playlist-events-section">
<div class="section-header">
<h3>📡 Playlist SSE (Radio Paradise)</h3>
<div class="section-actions">
<span class="status-chip" :class="{ connected: playlistSseConnected }">
{{ playlistSseConnected ? 'Connected' : 'Disconnected' }}
</span>
<button class="btn-tertiary" @click="restartPlaylistSse" :disabled="playlistSseConnecting">
{{ playlistSseConnecting ? 'Connecting…' : 'Restart' }}
</button>
</div>
</div>
<div class="section-meta">
<span>Watching playlists:</span>
<code v-for="pid in watchedPlaylistIds" :key="pid">{{ pid }}</code>
</div>
<div v-if="playlistSseError" class="error-message inline-error">
{{ playlistSseError }}
</div>
<div v-if="!playlistEvents.length" class="empty-placeholder">
Waiting for playlist events
</div>
<div v-else class="events-list">
<div v-for="(event, idx) in playlistEvents" :key="idx" class="event-item">
<div class="event-header">
<span class="event-kind">{{ event.kind }}</span>
<span class="event-time">{{ event.timestamp ? formatTimestamp(new Date(event.timestamp)) : '—' }}</span>
<span class="event-source" v-if="event.source_client">from {{ event.source_client }}</span>
</div>
<div class="event-body">
<div class="event-row">
<span class="label">Playlist</span>
<code>{{ event.playlist_id }}</code>
</div>
<div class="event-row" v-if="event.cache_pk">
<span class="label">Cache PK</span>
<code>{{ event.cache_pk }}</code>
</div>
<div class="event-row" v-if="event.qualifier">
<span class="label">Qualifier</span>
<code>{{ event.qualifier }}</code>
</div>
<div class="event-row">
<span class="label">Received</span>
<span class="value">{{ formatTimestamp(event.received_at) }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- Live Stream Metadata -->
<div v-if="playbackMode === 'live'" class="stream-metadata-section">
<div class="section-header">
<h3>📻 Live Stream Metadata</h3>
<button class="btn-secondary" @click="fetchStreamMetadata" :disabled="streamMetadataLoading">
<span v-if="streamMetadataLoading"> Loading</span>
<span v-else>🔄 Refresh Metadata</span>
</button>
</div>
<div v-if="streamMetadataError" class="error-message inline-error">
{{ streamMetadataError }}
</div>
<div v-else-if="streamMetadata" class="metadata-content">
<pre class="metadata-json">{{ JSON.stringify(streamMetadata, null, 2) }}</pre>
</div>
<div v-else class="empty-placeholder">
Click "Refresh Metadata" to fetch current stream metadata
</div>
<div v-if="streamMetadataLastUpdated" class="section-meta">
Last updated: {{ formatTimestamp(streamMetadataLastUpdated) }}
</div>
</div>
<!-- Quick Test Tools -->
<div class="quick-test-section">
<h3>🧪 Quick Test Commands</h3>
<p class="section-description">Copy and paste these commands to test streams with external players</p>
<div class="test-commands">
<div class="command-group">
<div class="command-label">
<strong>ffplay (FLAC)</strong>
<button class="btn-copy-small" @click="copyToClipboard(ffplayFlacCommand)">📋</button>
</div>
<code class="command-text">{{ ffplayFlacCommand }}</code>
</div>
<div class="command-group">
<div class="command-label">
<strong>ffplay (OGG)</strong>
<button class="btn-copy-small" @click="copyToClipboard(ffplayOggCommand)">📋</button>
</div>
<code class="command-text">{{ ffplayOggCommand }}</code>
</div>
<div class="command-group">
<div class="command-label">
<strong>VLC</strong>
<button class="btn-copy-small" @click="copyToClipboard(vlcCommand)">📋</button>
</div>
<code class="command-text">{{ vlcCommand }}</code>
</div>
<div class="command-group">
<div class="command-label">
<strong>curl (Download to file)</strong>
<button class="btn-copy-small" @click="copyToClipboard(curlCommand)">📋</button>
</div>
<code class="command-text">{{ curlCommand }}</code>
</div>
</div>
</div>
<!-- Enhanced Media Player -->
<div v-if="isPlaying || audioError" class="enhanced-player-section">
<div class="player-header">
<h3>🎵 Now Playing on Radio Paradise</h3>
<div class="player-controls-header">
<button @click="refreshPlayerMetadata" :disabled="playerMetadataLoading" class="btn-refresh-meta">
<span v-if="playerMetadataLoading"></span>
<span v-else>🔄</span>
</button>
<button @click="stopPlayback" class="btn-stop">
{{ audioError ? '✕ Close' : '⏹️ Stop' }}
</button>
</div>
</div>
<div v-if="audioError" class="audio-error-banner">
{{ audioError }}
</div>
<div v-else class="player-content">
<!-- Cover Art & Metadata -->
<div class="player-info">
<div class="player-cover">
<img
:src="playerMetadata?.cover_url || nowPlaying?.current_song?.cover_url || 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgZmlsbD0iIzFhMWEyZSIvPjxwYXRoIGQ9Ik0xMDAgNDAgTDEwMCAxMjAgTTcwIDkwIEwxMzAgOTAiIHN0cm9rZT0iIzAwZDRmZiIgc3Ryb2tlLXdpZHRoPSI4IiBzdHJva2UtbGluZWNhcD0icm91bmQiLz48Y2lyY2xlIGN4PSI4NSIgY3k9IjE0MCIgcj0iMTUiIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzAwZDRmZiIgc3Ryb2tlLXdpZHRoPSI0Ii8+PGNpcmNsZSBjeD0iMTE1IiBjeT0iMTQwIiByPSIxNSIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDBkNGZmIiBzdHJva2Utd2lkdGg9IjQiLz48L3N2Zz4='"
:alt="playerMetadata?.title || nowPlaying?.current_song?.title || 'Album cover'"
>
</div>
<div class="player-metadata">
<div v-if="playerMetadata" class="metadata-display">
<div class="player-title">{{ playerMetadata.title || 'Unknown Title' }}</div>
<div class="player-artist">{{ playerMetadata.artist || 'Unknown Artist' }}</div>
<div v-if="playerMetadata.album" class="player-album">{{ playerMetadata.album }}</div>
<div v-if="playerMetadata.year" class="player-year">{{ playerMetadata.year }}</div>
</div>
<div v-else class="metadata-loading">
<span v-if="playerMetadataLoading">Loading metadata...</span>
<span v-else>No metadata available</span>
</div>
</div>
</div>
<!-- Audio Element with Controls -->
<div class="player-audio-controls">
<audio
ref="audioPlayer"
controls
@ended="handleAudioEnded"
@error="handleAudioError"
@play="handleAudioPlay"
@pause="handleAudioPause"
></audio>
</div>
<!-- Stream Info -->
<div class="player-stream-info">
<div class="stream-info-item">
<span class="info-label">Channel:</span>
<span class="info-value">{{ channels.find(c => c.id === selectedChannel)?.name || 'Unknown' }}</span>
</div>
<div class="stream-info-item">
<span class="info-label">Format:</span>
<span class="info-value">{{ selectedFormat.toUpperCase() }}</span>
</div>
<div class="stream-info-item">
<span class="info-label">Mode:</span>
<span class="info-value">{{ playbackMode === 'live' ? 'Live' : 'Historic' }}</span>
</div>
<div v-if="playerMetadataLastUpdated" class="stream-info-item">
<span class="info-label">Updated:</span>
<span class="info-value">{{ formatTimestamp(playerMetadataLastUpdated) }}</span>
</div>
</div>
</div>
</div>
<div v-if="error" class="error-message">
{{ error }}
</div>
<!-- Now Playing Section -->
<div v-if="nowPlaying" class="now-playing-section">
<h3>🎵 Now Playing</h3>
<div v-if="nowPlaying.current_song" class="current-song">
<div v-if="nowPlaying.current_song.cover_url" class="cover-art">
<img :src="nowPlaying.current_song.cover_url" :alt="`${nowPlaying.current_song.album} cover`">
</div>
<div class="song-details">
<div class="artist">{{ nowPlaying.current_song.artist }}</div>
<div class="title">{{ nowPlaying.current_song.title }}</div>
<div class="album">{{ nowPlaying.current_song.album }}</div>
<div class="metadata">
<span v-if="nowPlaying.current_song.year" class="year">{{ nowPlaying.current_song.year }}</span>
<span class="duration">{{ formatDuration(nowPlaying.current_song.duration_ms) }}</span>
<span v-if="nowPlaying.current_song.rating" class="rating"> {{ nowPlaying.current_song.rating.toFixed(1) }}</span>
</div>
</div>
</div>
</div>
<!-- Block Info -->
<div v-if="nowPlaying" class="block-info">
<h3>📦 Block Info</h3>
<div class="block-details">
<div class="info-row">
<span class="label">Event ID:</span>
<span class="value">{{ nowPlaying.event }}</span>
</div>
<div class="info-row">
<span class="label">Next Event:</span>
<span class="value">{{ nowPlaying.end_event }}</span>
</div>
<div class="info-row">
<span class="label">Block Length:</span>
<span class="value">{{ formatDuration(nowPlaying.block_length_ms) }}</span>
</div>
<div class="info-row">
<span class="label">Songs in Block:</span>
<span class="value">{{ nowPlaying.songs.length }}</span>
</div>
<div class="info-row">
<span class="label">Stream URL:</span>
<a :href="nowPlaying.stream_url" target="_blank" class="stream-link">{{ nowPlaying.stream_url }}</a>
</div>
</div>
</div>
<div v-if="nowPlaying" class="block-actions">
<button
class="btn-secondary"
@click="loadUpcomingBlock"
:disabled="upcomingLoading || !nowPlaying?.end_event"
>
<span v-if="upcomingLoading"> Loading next block</span>
<span v-else> Preview Next Block</span>
</button>
<div class="next-block-info">
<span>Next event:</span>
<code>{{ nowPlaying.end_event }}</code>
</div>
</div>
<div v-if="upcomingError" class="error-message">
{{ upcomingError }}
</div>
<!-- Songs List -->
<div v-if="nowPlaying && nowPlaying.songs" class="songs-section">
<h3>🎼 Songs in Current Block</h3>
<div class="songs-list">
<div
v-for="song in nowPlaying.songs"
:key="song.index"
:class="['song-item', { 'current': song.index === nowPlaying.current_song_index }]"
>
<div class="song-number">{{ song.index + 1 }}</div>
<div v-if="song.cover_url" class="song-cover-mini">
<img :src="song.cover_url" :alt="`${song.album} cover`">
</div>
<div class="song-info">
<div class="song-title">{{ song.title }}</div>
<div class="song-artist">{{ song.artist }}</div>
<div class="song-album">{{ song.album }}</div>
</div>
<div class="song-meta">
<div class="song-year" v-if="song.year">{{ song.year }}</div>
<div class="song-duration">{{ formatDuration(song.duration_ms) }}</div>
<div class="song-elapsed">@{{ formatDuration(song.elapsed_ms) }}</div>
<div v-if="song.rating" class="song-rating"> {{ song.rating.toFixed(1) }}</div>
</div>
</div>
</div>
</div>
<div v-if="upcomingBlock" class="songs-section upcoming-section">
<div class="section-header">
<h3> Next Block Preview</h3>
<div class="section-meta">
<span>Event:</span>
<code>{{ upcomingBlock.event }}</code>
<span>Next event:</span>
<code>{{ upcomingBlock.end_event }}</code>
</div>
</div>
<div class="summary-row">
<span>{{ upcomingBlock.songs.length }} song(s)</span>
<span>Total duration: {{ formatDuration(upcomingBlock.length_ms) }}</span>
<a :href="upcomingBlock.url" target="_blank" class="stream-link">Open block stream</a>
</div>
<div class="songs-list">
<div v-for="song in upcomingBlock.songs" :key="`${upcomingBlock.event}-${song.index}`" class="song-item">
<div class="song-number">{{ song.index + 1 }}</div>
<div v-if="song.cover_url" class="song-cover-mini">
<img :src="song.cover_url" :alt="`${song.album} cover`">
</div>
<div class="song-info">
<div class="song-title">{{ song.title }}</div>
<div class="song-artist">{{ song.artist }}</div>
<div class="song-album">{{ song.album }}</div>
</div>
<div class="song-meta">
<div class="song-year" v-if="song.year">{{ song.year }}</div>
<div class="song-duration">{{ formatDuration(song.duration_ms) }}</div>
</div>
</div>
</div>
</div>
<div class="block-search">
<h3>🔎 Block Lookup</h3>
<div class="search-controls">
<input
v-model="blockSearchId"
type="number"
inputmode="numeric"
min="0"
placeholder="Enter an event ID (e.g. 123456)"
>
<button
class="btn-secondary"
@click="lookupBlockById"
:disabled="!blockSearchId || blockSearchLoading"
>
<span v-if="blockSearchLoading"> Loading</span>
<span v-else>Load Block</span>
</button>
<button class="btn-tertiary" @click="clearBlockSearch" :disabled="!blockSearchResult && !blockSearchError && !blockSearchId">
Clear
</button>
</div>
<div v-if="blockSearchError" class="error-message">
{{ blockSearchError }}
</div>
<div v-if="blockSearchResult" class="songs-section lookup-section">
<div class="section-header">
<h3>📂 Block {{ blockSearchResult.event }}</h3>
<div class="section-meta">
<span>Next event:</span>
<code>{{ blockSearchResult.end_event }}</code>
</div>
</div>
<div class="summary-row">
<span>{{ blockSearchResult.songs.length }} song(s)</span>
<span>Total duration: {{ formatDuration(blockSearchResult.length_ms) }}</span>
<a :href="blockSearchResult.url" target="_blank" class="stream-link">Open block stream</a>
</div>
<div class="songs-list">
<div v-for="song in blockSearchResult.songs" :key="`${blockSearchResult.event}-${song.index}`" class="song-item">
<div class="song-number">{{ song.index + 1 }}</div>
<div v-if="song.cover_url" class="song-cover-mini">
<img :src="song.cover_url" :alt="`${song.album} cover`">
</div>
<div class="song-info">
<div class="song-title">{{ song.title }}</div>
<div class="song-artist">{{ song.artist }}</div>
<div class="song-album">{{ song.album }}</div>
</div>
<div class="song-meta">
<div class="song-year" v-if="song.year">{{ song.year }}</div>
<div class="song-duration">{{ formatDuration(song.duration_ms) }}</div>
</div>
</div>
</div>
</div>
</div>
<!-- Test New Endpoints -->
<div class="test-endpoints-section">
<h3>🧪 Test Nouveaux Endpoints</h3>
<div class="test-grid">
<!-- Test getCoverUrl -->
<div class="test-card">
<h4>Test: Cover URL avec Fallback</h4>
<div class="test-controls">
<input
v-model.number="testCoverEventId"
type="number"
placeholder="Event ID"
class="test-input"
/>
<input
v-model.number="testCoverSongIndex"
type="number"
placeholder="Song Index"
class="test-input"
/>
<button
@click="testGetCoverUrl"
:disabled="testCoverLoading || !testCoverEventId"
class="btn-secondary"
>
<span v-if="testCoverLoading"> Test...</span>
<span v-else>Test Cover URL</span>
</button>
</div>
<div v-if="testCoverResult" class="test-result">
<div class="result-item">
<strong>Source:</strong> {{ testCoverResult.cover_type }}
</div>
<div v-if="testCoverResult.cover_url" class="result-item">
<strong>URL:</strong>
<a :href="testCoverResult.cover_url" target="_blank" class="stream-link">
{{ testCoverResult.cover_url }}
</a>
</div>
<div v-else class="result-item">
<strong>URL:</strong> <span class="text-muted">None (fallback manquant)</span>
</div>
<div v-if="testCoverResult.cover_url" class="cover-preview">
<img :src="testCoverResult.cover_url" alt="Cover preview" />
</div>
</div>
<div v-if="testCoverError" class="error-message">
{{ testCoverError }}
</div>
</div>
<!-- Test getStreamUrl -->
<div class="test-card">
<h4>Test: Stream URL Direct</h4>
<div class="test-controls">
<input
v-model.number="testStreamEventId"
type="number"
placeholder="Event ID"
class="test-input"
/>
<button
@click="testGetStreamUrl"
:disabled="testStreamLoading || !testStreamEventId"
class="btn-secondary"
>
<span v-if="testStreamLoading"> Test...</span>
<span v-else>Test Stream URL</span>
</button>
</div>
<div v-if="testStreamResult" class="test-result">
<div class="result-item">
<strong>Event:</strong> {{ testStreamResult.event }}
</div>
<div class="result-item">
<strong>Duration:</strong> {{ formatDuration(testStreamResult.length_ms) }}
</div>
<div class="result-item">
<strong>URL:</strong>
<a :href="testStreamResult.stream_url" target="_blank" class="stream-link">
{{ testStreamResult.stream_url }}
</a>
</div>
</div>
<div v-if="testStreamError" class="error-message">
{{ testStreamError }}
</div>
</div>
<!-- Test getSongByIndex -->
<div class="test-card">
<h4>Test: Morceau par Index</h4>
<div class="test-controls">
<input
v-model.number="testSongEventId"
type="number"
placeholder="Event ID"
class="test-input"
/>
<input
v-model.number="testSongIndex"
type="number"
placeholder="Song Index"
class="test-input"
/>
<button
@click="testGetSongByIndex"
:disabled="testSongLoading || !testSongEventId"
class="btn-secondary"
>
<span v-if="testSongLoading"> Test...</span>
<span v-else>Test Get Song</span>
</button>
</div>
<div v-if="testSongResult" class="test-result">
<div class="result-item">
<strong>Title:</strong> {{ testSongResult.title }}
</div>
<div class="result-item">
<strong>Artist:</strong> {{ testSongResult.artist }}
</div>
<div class="result-item">
<strong>Album:</strong> {{ testSongResult.album }}
</div>
<div class="result-item">
<strong>Duration:</strong> {{ formatDuration(testSongResult.duration_ms) }}
</div>
<div v-if="testSongResult.cover_url" class="cover-preview-small">
<img :src="testSongResult.cover_url" alt="Song cover" />
</div>
</div>
<div v-if="testSongError" class="error-message">
{{ testSongError }}
</div>
</div>
</div>
</div>
<!-- Available Channels -->
<div v-if="channelsError" class="error-message inline-error">
{{ channelsError }}
</div>
<div class="channels-section">
<h3>📻 Available Channels</h3>
<div class="channels-grid">
<div
v-for="channel in channels"
:key="channel.id"
:class="['channel-card', { 'active': channel.id === selectedChannel }]"
@click="selectChannel(channel.id)"
>
<div class="channel-name">{{ channel.name }}</div>
<div class="channel-description">{{ channel.description }}</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import {
listChannels,
getNowPlaying,
getCurrentBlock,
getBlockById,
getSongByIndex,
getCoverUrl,
getStreamUrl,
formatDuration as formatDurationUtil,
} from '../services/radioParadise'
const API_BASE = '/api/radioparadise'
const SOURCE_API_BASE = '/api/sources'
const SOURCE_ID = 'radio-paradise'
const STREAM_BASE = '/radioparadise/stream'
const loading = ref(false)
const error = ref(null)
const nowPlaying = ref(null)
const channels = ref([])
const selectedChannel = ref(0)
const audioPlayer = ref(null)
const isPlaying = ref(false)
const audioError = ref('')
const lastUpdated = ref(null)
// New stream format controls
const selectedFormat = ref('flac')
const playbackMode = ref('live')
const historicClientId = ref('test-client-' + Math.random().toString(36).substring(7))
const streamFormats = [
{ id: 'flac', name: 'FLAC', description: 'Pure FLAC lossless audio stream' },
{ id: 'ogg', name: 'OGG-FLAC', description: 'FLAC in OGG container for better streaming' }
]
// Channel slug mapping
const channelSlugs = ['main', 'mellow', 'rock', 'eclectic']
// Computed properties
const currentChannelSlug = computed(() => {
return channelSlugs[selectedChannel.value] || 'main'
})
const currentFormatDescription = computed(() => {
const format = streamFormats.find(f => f.id === selectedFormat.value)
return format ? format.description : ''
})
const currentStreamUrl = computed(() => {
return getStreamUrlFor(selectedFormat.value)
})
const watchedPlaylistIds = computed(() => {
const slug = currentChannelSlug.value
return [
`radio-paradise-live-${slug}`,
`radio-paradise-history-${slug}`
]
})
// Test commands computed properties
const ffplayFlacCommand = computed(() => {
const url = getFullStreamUrl('flac')
return `ffplay -nodisp "${url}"`
})
const ffplayOggCommand = computed(() => {
const url = getFullStreamUrl('ogg')
return `ffplay -nodisp "${url}"`
})
const vlcCommand = computed(() => {
const url = getFullStreamUrl(selectedFormat.value)
return `vlc "${url}"`
})
const curlCommand = computed(() => {
const url = getFullStreamUrl(selectedFormat.value)
const ext = selectedFormat.value === 'ogg' ? 'ogg' : 'flac'
const slug = currentChannelSlug.value
return `curl "${url}" -o "${slug}-stream.${ext}"`
})
function getStreamUrlFor(format) {
const slug = currentChannelSlug.value
if (playbackMode.value === 'historic') {
return `${STREAM_BASE}/${slug}/historic/${historicClientId.value}/${format}`
}
return `${STREAM_BASE}/${slug}/${format}`
}
function getFullStreamUrl(format) {
// Get full URL including protocol and host
const baseUrl = window.location.origin
return baseUrl + getStreamUrlFor(format)
}
function copyStreamUrl() {
copyToClipboard(currentStreamUrl.value)
}
function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(() => {
console.log('Copied to clipboard:', text)
// Could add a toast notification here
}).catch(err => {
console.error('Failed to copy:', err)
})
}
const upcomingBlock = ref(null)
const upcomingLoading = ref(false)
const upcomingError = ref('')
const blockSearchId = ref('')
const blockSearchResult = ref(null)
const blockSearchLoading = ref(false)
const blockSearchError = ref('')
const channelsError = ref('')
// Test new endpoints state
const testCoverEventId = ref(null)
const testCoverSongIndex = ref(0)
const testCoverResult = ref(null)
const testCoverLoading = ref(false)
const testCoverError = ref('')
const testStreamEventId = ref(null)
const testStreamResult = ref(null)
const testStreamLoading = ref(false)
const testStreamError = ref('')
const testSongEventId = ref(null)
const testSongIndex = ref(0)
const testSongResult = ref(null)
const testSongLoading = ref(false)
const testSongError = ref('')
// Stream metadata state
const streamMetadata = ref(null)
const streamMetadataLoading = ref(false)
const streamMetadataError = ref('')
const streamMetadataLastUpdated = ref(null)
// Player metadata state (for the enhanced player)
const playerMetadata = ref(null)
const playerMetadataLoading = ref(false)
const playerMetadataLastUpdated = ref(null)
// Playlist SSE state
const playlistEventSource = ref(null)
const playlistEvents = ref([])
const playlistSseConnected = ref(false)
const playlistSseConnecting = ref(false)
const playlistSseError = ref('')
let refreshTimerId = null
let playerMetadataTimerId = null
// Format duration from milliseconds to MM:SS
function formatDuration(ms) {
if (typeof ms !== 'number' || !Number.isFinite(ms)) {
return '--:--'
}
const seconds = Math.max(0, Math.floor(ms / 1000))
const minutes = Math.floor(seconds / 60)
const remainingSeconds = seconds % 60
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`
}
function formatTimestamp(date) {
if (!date) return ''
return date.toLocaleTimeString()
}
async function fetchBlockByEvent(eventId) {
return await getBlockById(eventId, selectedChannel.value)
}
function playAudio(url) {
if (!url) {
audioError.value = 'No audio URL available'
isPlaying.value = false
return
}
audioError.value = ''
isPlaying.value = true
nextTick(() => {
const player = audioPlayer.value
if (!player) {
return
}
player.src = url
player.play().catch((e) => {
console.error('Failed to start playback:', e)
audioError.value = `Cannot play stream: ${e.message}`
isPlaying.value = false
})
})
}
// Fetch now playing info
async function refreshNowPlaying() {
loading.value = true
error.value = null
try {
nowPlaying.value = await getNowPlaying(selectedChannel.value)
lastUpdated.value = new Date()
upcomingBlock.value = null
upcomingError.value = ''
} catch (e) {
error.value = `Failed to fetch now playing: ${e.message}`
console.error('Error fetching now playing:', e)
} finally {
loading.value = false
}
}
async function fetchStreamMetadata() {
streamMetadataLoading.value = true
streamMetadataError.value = ''
try {
const slug = currentChannelSlug.value
const response = await fetch(`/radioparadise/metadata/${slug}`)
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
streamMetadata.value = await response.json()
streamMetadataLastUpdated.value = new Date()
} catch (e) {
streamMetadataError.value = `Failed to fetch stream metadata: ${e.message}`
console.error('Error fetching stream metadata:', e)
} finally {
streamMetadataLoading.value = false
}
}
// Fetch player metadata (for enhanced player)
async function refreshPlayerMetadata() {
playerMetadataLoading.value = true
try {
const slug = currentChannelSlug.value
const response = await fetch(`/radioparadise/metadata/${slug}`)
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const metadata = await response.json()
playerMetadata.value = metadata
playerMetadataLastUpdated.value = new Date()
// Update Media Session API if available
updateMediaSession(metadata)
} catch (e) {
console.error('Error fetching player metadata:', e)
// Don't show error to user, just log it
} finally {
playerMetadataLoading.value = false
}
}
// Update browser Media Session API for notifications/lock screen
function updateMediaSession(metadata) {
if ('mediaSession' in navigator && metadata) {
navigator.mediaSession.metadata = new MediaMetadata({
title: metadata.title || 'Unknown Title',
artist: metadata.artist || 'Unknown Artist',
album: metadata.album || 'Radio Paradise',
artwork: metadata.cover_url ? [
{ src: metadata.cover_url, sizes: '512x512', type: 'image/jpeg' }
] : []
})
// Set up action handlers
navigator.mediaSession.setActionHandler('play', () => {
audioPlayer.value?.play()
})
navigator.mediaSession.setActionHandler('pause', () => {
audioPlayer.value?.pause()
})
navigator.mediaSession.setActionHandler('stop', () => {
stopPlayback()
})
}
}
// Start auto-refresh of player metadata
function startPlayerMetadataRefresh() {
// Clear any existing timer
if (playerMetadataTimerId) {
clearInterval(playerMetadataTimerId)
}
// Fetch immediately
refreshPlayerMetadata()
// Then refresh every 10 seconds while playing
playerMetadataTimerId = window.setInterval(() => {
if (isPlaying.value) {
refreshPlayerMetadata()
}
}, 10000)
}
// Stop auto-refresh of player metadata
function stopPlayerMetadataRefresh() {
if (playerMetadataTimerId) {
clearInterval(playerMetadataTimerId)
playerMetadataTimerId = null
}
}
// Fetch available channels
async function fetchChannels() {
try {
const data = await listChannels()
channels.value = data
channelsError.value = ''
if (channels.value.length > 0) {
const hasSelected = channels.value.some(channel => channel.id === selectedChannel.value)
if (!hasSelected) {
selectedChannel.value = channels.value[0].id
}
}
} catch (e) {
channelsError.value = `Failed to fetch channels: ${e.message}`
console.error('Error fetching channels:', e)
}
}
// Select a channel
function selectChannel(channelId) {
if (selectedChannel.value === channelId) {
return
}
selectedChannel.value = channelId
changeChannel()
}
async function changeChannel() {
await refreshNowPlaying()
blockSearchResult.value = null
blockSearchError.value = ''
}
function playStream() {
// Use the custom stream URL based on selected format and mode
const streamUrl = currentStreamUrl.value
if (!streamUrl) {
audioError.value = 'No stream URL available'
isPlaying.value = false
return
}
playAudio(streamUrl)
// Start fetching metadata for the player
startPlayerMetadataRefresh()
}
function stopPlayback() {
if (audioPlayer.value) {
audioPlayer.value.pause()
audioPlayer.value.currentTime = 0
audioPlayer.value.src = ''
}
isPlaying.value = false
audioError.value = ''
// Stop metadata refresh
stopPlayerMetadataRefresh()
playerMetadata.value = null
// Clear Media Session
if ('mediaSession' in navigator) {
navigator.mediaSession.metadata = null
}
}
function togglePlayback() {
if (isPlaying.value) {
stopPlayback()
} else {
playStream()
}
}
function handleAudioEnded() {
isPlaying.value = false
stopPlayerMetadataRefresh()
}
function handleAudioError() {
const audio = audioPlayer.value
if (audio?.error) {
audioError.value = `Audio playback error (code ${audio.error.code})`
} else {
audioError.value = 'Unknown audio playback error'
}
isPlaying.value = false
stopPlayerMetadataRefresh()
}
function handleAudioPlay() {
isPlaying.value = true
startPlayerMetadataRefresh()
}
function handleAudioPause() {
// Note: We don't set isPlaying to false here because the user might resume
// Only stop metadata refresh if the audio is actually stopped (not just paused)
}
async function loadUpcomingBlock() {
if (!nowPlaying.value?.end_event) {
return
}
upcomingLoading.value = true
upcomingError.value = ''
try {
upcomingBlock.value = await fetchBlockByEvent(nowPlaying.value.end_event)
} catch (e) {
console.error('Failed to fetch upcoming block:', e)
upcomingError.value = `Failed to load upcoming block: ${e.message}`
} finally {
upcomingLoading.value = false
}
}
async function lookupBlockById() {
if (!blockSearchId.value) {
return
}
const eventId = Number(blockSearchId.value)
if (!Number.isFinite(eventId) || eventId < 0) {
blockSearchError.value = 'Please enter a valid event ID'
return
}
blockSearchLoading.value = true
blockSearchError.value = ''
try {
blockSearchResult.value = await fetchBlockByEvent(eventId)
} catch (e) {
console.error('Failed to fetch block by ID:', e)
blockSearchError.value = `Failed to load block: ${e.message}`
blockSearchResult.value = null
} finally {
blockSearchLoading.value = false
}
}
function clearBlockSearch() {
blockSearchId.value = ''
blockSearchResult.value = null
blockSearchError.value = ''
}
// Test new endpoints functions
async function testGetCoverUrl() {
if (!testCoverEventId.value) return
testCoverLoading.value = true
testCoverError.value = ''
testCoverResult.value = null
try {
testCoverResult.value = await getCoverUrl(
testCoverEventId.value,
testCoverSongIndex.value,
selectedChannel.value
)
} catch (e) {
console.error('Failed to test cover URL:', e)
testCoverError.value = `Failed: ${e.message}`
} finally {
testCoverLoading.value = false
}
}
async function testGetStreamUrl() {
if (!testStreamEventId.value) return
testStreamLoading.value = true
testStreamError.value = ''
testStreamResult.value = null
try {
testStreamResult.value = await getStreamUrl(
testStreamEventId.value,
selectedChannel.value
)
} catch (e) {
console.error('Failed to test stream URL:', e)
testStreamError.value = `Failed: ${e.message}`
} finally {
testStreamLoading.value = false
}
}
async function testGetSongByIndex() {
if (!testSongEventId.value) return
testSongLoading.value = true
testSongError.value = ''
testSongResult.value = null
try {
testSongResult.value = await getSongByIndex(
testSongEventId.value,
testSongIndex.value,
selectedChannel.value
)
} catch (e) {
console.error('Failed to test get song:', e)
testSongError.value = `Failed: ${e.message}`
} finally {
testSongLoading.value = false
}
}
function stopPlaylistSse() {
if (playlistEventSource.value) {
playlistEventSource.value.close()
playlistEventSource.value = null
}
playlistSseConnected.value = false
playlistSseConnecting.value = false
}
function startPlaylistSse() {
stopPlaylistSse()
playlistSseError.value = ''
playlistSseConnecting.value = true
const url = `${window.location.origin}/api/playlists/events`
const source = new EventSource(url)
source.onopen = () => {
playlistSseConnected.value = true
playlistSseConnecting.value = false
}
source.onerror = () => {
playlistSseConnected.value = false
playlistSseConnecting.value = false
playlistSseError.value = 'Erreur SSE playlist'
}
const handlePlaylistEvent = (ev) => {
try {
const data = JSON.parse(ev.data)
playlistEvents.value.unshift({
...data,
received_at: new Date()
})
if (playlistEvents.value.length > 30) {
playlistEvents.value = playlistEvents.value.slice(0, 30)
}
playlistSseError.value = ''
} catch (e) {
playlistSseError.value = `Parse error: ${e.message}`
}
}
source.addEventListener('playlist', handlePlaylistEvent)
// Some browsers also deliver named events through the generic channel
source.onmessage = handlePlaylistEvent
playlistEventSource.value = source
}
function restartPlaylistSse() {
startPlaylistSse()
}
// Initialize on mount
onMounted(async () => {
await fetchChannels()
await refreshNowPlaying()
startPlaylistSse()
// Auto-refresh every 30 seconds
refreshTimerId = window.setInterval(() => {
if (!loading.value) {
refreshNowPlaying()
}
}, 30000)
})
onUnmounted(() => {
if (refreshTimerId) {
clearInterval(refreshTimerId)
}
if (playerMetadataTimerId) {
clearInterval(playerMetadataTimerId)
}
stopPlaylistSse()
})
watch(currentChannelSlug, () => {
restartPlaylistSse()
})
watch(playbackMode, () => {
restartPlaylistSse()
})
</script>
<style scoped>
.radio-paradise-explorer {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 2px solid #333;
}
.header h2 {
margin: 0;
color: #00d4ff;
}
.title-block {
display: flex;
flex-direction: column;
gap: 6px;
}
.status-bar {
display: flex;
flex-wrap: wrap;
gap: 16px;
font-size: 0.85rem;
color: #9aa0a6;
}
.status-bar span {
display: flex;
align-items: center;
gap: 6px;
}
.controls {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
justify-content: flex-end;
}
.btn-primary {
background: #00d4ff;
color: #000;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
transition: background 0.3s;
}
.btn-primary:hover:not(:disabled) {
background: #00a8cc;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-play {
background: rgba(46, 204, 113, 0.2);
color: #2ecc71;
border: 1px solid rgba(46, 204, 113, 0.4);
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
transition: background 0.3s;
}
.btn-play:hover:not(:disabled) {
background: rgba(46, 204, 113, 0.35);
}
.btn-play:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-secondary {
background: rgba(0, 212, 255, 0.12);
color: #00d4ff;
border: 1px solid rgba(0, 212, 255, 0.4);
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-weight: 600;
transition: background 0.3s;
}
.btn-secondary:hover:not(:disabled) {
background: rgba(0, 212, 255, 0.25);
}
.btn-secondary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-tertiary {
background: transparent;
color: #bbb;
border: 1px solid #444;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
transition: border-color 0.3s, color 0.3s;
}
.btn-tertiary:hover:not(:disabled) {
color: #fff;
border-color: #666;
}
.btn-tertiary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.channel-select {
padding: 8px 12px;
border-radius: 4px;
border: 1px solid #333;
background: #1a1a1a;
color: #fff;
cursor: pointer;
}
.bitrate-select {
padding: 8px 12px;
border-radius: 4px;
border: 1px solid #333;
background: #1a1a1a;
color: #fff;
cursor: pointer;
}
.bitrate-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
.bitrate-chip {
padding: 4px 10px;
border-radius: 999px;
border: 1px solid #333;
background: #1c1c1c;
font-size: 0.8rem;
color: #bbb;
}
.bitrate-chip.active {
border-color: #00d4ff;
color: #00d4ff;
background: rgba(0, 212, 255, 0.12);
}
.error-message {
background: #ff4444;
color: white;
padding: 12px;
border-radius: 4px;
margin-bottom: 20px;
}
.inline-error {
margin-top: -8px;
margin-bottom: 16px;
font-size: 0.9rem;
}
.block-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 16px;
margin-bottom: 20px;
}
.next-block-info {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.9rem;
color: #9aa0a6;
}
.next-block-info code {
background: rgba(255, 255, 255, 0.05);
padding: 2px 6px;
border-radius: 4px;
}
/* Now Playing Section */
.now-playing-section {
background: linear-gradient(135deg, #1a1a1a 0%, #2a2a2a 100%);
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
border: 1px solid #333;
}
.now-playing-section h3 {
margin-top: 0;
color: #00d4ff;
}
.current-song {
display: flex;
gap: 20px;
align-items: flex-start;
}
.cover-art {
flex-shrink: 0;
}
.cover-art img {
width: 200px;
height: 200px;
object-fit: cover;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
}
.song-details {
flex: 1;
}
.artist {
font-size: 1.8em;
font-weight: bold;
color: #00d4ff;
margin-bottom: 8px;
}
.title {
font-size: 1.4em;
margin-bottom: 8px;
color: #fff;
}
.album {
font-size: 1.1em;
color: #999;
margin-bottom: 12px;
}
.metadata {
display: flex;
gap: 15px;
font-size: 0.9em;
color: #666;
}
.metadata span {
padding: 4px 8px;
background: #333;
border-radius: 4px;
}
/* Block Info */
.block-info {
background: #1a1a1a;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
border: 1px solid #333;
}
.block-info h3 {
margin-top: 0;
color: #00d4ff;
}
.block-details {
display: flex;
flex-direction: column;
gap: 10px;
}
.info-row {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid #333;
}
.info-row:last-child {
border-bottom: none;
}
.label {
font-weight: bold;
color: #999;
}
.value {
color: #fff;
}
.stream-link {
color: #00d4ff;
text-decoration: none;
word-break: break-all;
}
.stream-link:hover {
text-decoration: underline;
}
/* Songs List */
.section-header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.section-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
color: #9aa0a6;
font-size: 0.85rem;
}
.section-meta code {
background: rgba(255, 255, 255, 0.05);
padding: 2px 6px;
border-radius: 4px;
}
.summary-row {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
margin-bottom: 16px;
color: #9aa0a6;
}
.songs-section {
background: #1a1a1a;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
border: 1px solid #333;
}
.songs-section h3 {
margin-top: 0;
color: #00d4ff;
}
.songs-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.song-item {
display: flex;
gap: 12px;
padding: 12px;
background: #2a2a2a;
border-radius: 4px;
align-items: center;
transition: background 0.3s;
}
.song-item:hover {
background: #333;
}
.song-item.current {
background: #003d4d;
border-left: 4px solid #00d4ff;
}
.song-number {
font-weight: bold;
color: #666;
min-width: 30px;
text-align: center;
}
.song-cover-mini img {
width: 50px;
height: 50px;
object-fit: cover;
border-radius: 4px;
}
.song-info {
flex: 1;
min-width: 0;
}
.song-title {
font-weight: bold;
color: #fff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.song-artist {
color: #00d4ff;
font-size: 0.9em;
}
.song-album {
color: #999;
font-size: 0.85em;
}
.song-meta {
display: flex;
gap: 10px;
font-size: 0.85em;
color: #666;
align-items: center;
}
.song-meta > div {
padding: 2px 6px;
background: #1a1a1a;
border-radius: 3px;
}
.block-search {
margin: 30px 0;
padding: 20px;
border: 1px solid #333;
border-radius: 8px;
background: rgba(0, 0, 0, 0.35);
}
.search-controls {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 12px;
}
.search-controls input {
padding: 8px 12px;
border-radius: 4px;
border: 1px solid #333;
background: #111;
color: #fff;
flex: 1 1 220px;
}
/* Channels Section */
.channels-section {
background: #1a1a1a;
border-radius: 8px;
padding: 20px;
border: 1px solid #333;
}
.channel-tracks-section {
margin-top: 32px;
padding: 20px;
border-radius: 8px;
border: 1px solid #333;
background: #141414;
}
.channels-section h3 {
margin-top: 0;
color: #00d4ff;
}
.channels-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 15px;
}
.channel-card {
background: #2a2a2a;
padding: 15px;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s;
border: 2px solid transparent;
}
.channel-card:hover {
background: #333;
transform: translateY(-2px);
}
.channel-card.active {
border-color: #00d4ff;
background: #003d4d;
}
.channel-name {
font-weight: bold;
color: #00d4ff;
margin-bottom: 8px;
font-size: 1.1em;
}
.channel-description {
color: #999;
font-size: 0.9em;
}
.section-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
margin-top: 8px;
font-size: 0.85rem;
color: #9aa0a6;
}
.section-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.loading-message {
margin-top: 16px;
color: #9aa0a6;
}
.empty-placeholder {
margin-top: 16px;
padding: 16px;
border: 1px dashed #444;
border-radius: 6px;
color: #9aa0a6;
text-align: center;
}
.sub-container-notice {
margin-top: 12px;
font-size: 0.8rem;
color: #9aa0a6;
}
.track-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 16px;
margin-top: 18px;
}
.track-card {
background: rgba(0, 212, 255, 0.08);
border: 1px solid rgba(0, 212, 255, 0.2);
border-radius: 8px;
padding: 14px;
display: flex;
flex-direction: column;
gap: 10px;
transition: border-color 0.2s, box-shadow 0.2s;
}
.status-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
margin-top: 12px;
}
.status-card {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.04);
border-radius: 8px;
padding: 12px;
display: flex;
flex-direction: column;
gap: 4px;
}
.status-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #9aa0a6;
}
.status-value {
font-size: 1.05rem;
font-weight: 600;
color: #f5f5f5;
}
.track-card-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 999px;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.08);
color: #bbb;
}
.status-badge.cached {
background: rgba(46, 204, 113, 0.18);
border-color: rgba(46, 204, 113, 0.45);
color: #2ecc71;
}
.status-badge.caching {
background: rgba(255, 193, 7, 0.15);
border-color: rgba(255, 193, 7, 0.4);
color: #ffc107;
}
.status-badge.failed {
background: rgba(255, 87, 34, 0.18);
border-color: rgba(255, 87, 34, 0.5);
color: #ff7043;
}
.status-badge.pending {
background: rgba(0, 212, 255, 0.12);
border-color: rgba(0, 212, 255, 0.4);
color: #00d4ff;
}
.track-metadata {
font-size: 0.7rem;
color: #666;
}
.track-card.active {
border-color: rgba(46, 204, 113, 0.8);
box-shadow: 0 0 12px rgba(46, 204, 113, 0.25);
}
.track-headline {
display: flex;
flex-direction: column;
gap: 4px;
}
.track-title {
font-weight: 600;
color: #f5f5f5;
}
.track-artist {
color: #9aa0a6;
font-size: 0.9rem;
}
.track-meta {
display: flex;
gap: 12px;
flex-wrap: wrap;
font-size: 0.85rem;
color: #9aa0a6;
}
.track-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.inline-error {
margin-top: 6px;
font-size: 0.8rem;
color: #ff6b6b;
}
.inline-success {
margin-top: 6px;
font-size: 0.8rem;
color: #2ecc71;
}
.formats-list {
margin-top: 10px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
padding-top: 8px;
display: flex;
flex-direction: column;
gap: 4px;
font-size: 0.8rem;
color: #9aa0a6;
}
.format-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.history-list {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 12px;
}
.history-item {
padding: 12px;
border-radius: 8px;
background: rgba(0, 0, 0, 0.25);
border: 1px solid rgba(255, 255, 255, 0.05);
}
.history-title {
font-weight: 600;
color: #f5f5f5;
}
.history-meta {
margin-top: 4px;
font-size: 0.8rem;
color: #9aa0a6;
display: flex;
gap: 6px;
flex-wrap: wrap;
}
/* Enhanced Media Player Section */
.enhanced-player-section {
background: linear-gradient(135deg, #1a1a2e 0%, #0f0f1e 100%);
border-radius: 12px;
padding: 24px;
margin: 20px 0 32px;
border: 2px solid rgba(0, 212, 255, 0.4);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}
.player-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid rgba(0, 212, 255, 0.2);
}
.player-header h3 {
margin: 0;
color: #00d4ff;
font-size: 1.3rem;
}
.player-controls-header {
display: flex;
gap: 10px;
align-items: center;
}
.btn-refresh-meta {
background: rgba(0, 212, 255, 0.15);
border: 1px solid rgba(0, 212, 255, 0.4);
color: #00d4ff;
padding: 8px 12px;
border-radius: 6px;
cursor: pointer;
font-size: 1.1rem;
transition: all 0.2s;
min-width: 40px;
display: flex;
align-items: center;
justify-content: center;
}
.btn-refresh-meta:hover:not(:disabled) {
background: rgba(0, 212, 255, 0.25);
border-color: rgba(0, 212, 255, 0.6);
}
.btn-refresh-meta:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-stop {
padding: 8px 16px;
border-radius: 6px;
border: 1px solid rgba(231, 76, 60, 0.4);
cursor: pointer;
background: rgba(231, 76, 60, 0.15);
color: #e74c3c;
font-weight: bold;
transition: all 0.2s;
}
.btn-stop:hover {
background: rgba(231, 76, 60, 0.25);
border-color: rgba(231, 76, 60, 0.6);
}
.audio-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: 16px;
font-weight: 500;
}
.player-content {
display: flex;
flex-direction: column;
gap: 20px;
}
.player-info {
display: flex;
gap: 24px;
align-items: flex-start;
}
.player-cover {
flex-shrink: 0;
}
.player-cover img {
width: 180px;
height: 180px;
object-fit: cover;
border-radius: 12px;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.6);
border: 2px solid rgba(0, 212, 255, 0.2);
}
.player-metadata {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
gap: 8px;
min-width: 0;
}
.metadata-display {
display: flex;
flex-direction: column;
gap: 8px;
}
.player-title {
font-size: 1.8rem;
font-weight: bold;
color: #ffffff;
line-height: 1.2;
word-wrap: break-word;
}
.player-artist {
font-size: 1.4rem;
color: #00d4ff;
font-weight: 600;
line-height: 1.3;
}
.player-album {
font-size: 1.1rem;
color: #9aa0a6;
font-style: italic;
}
.player-year {
font-size: 0.95rem;
color: #666;
background: rgba(255, 255, 255, 0.05);
padding: 4px 10px;
border-radius: 4px;
display: inline-block;
align-self: flex-start;
margin-top: 4px;
}
.metadata-loading {
color: #9aa0a6;
font-size: 1rem;
font-style: italic;
}
.player-audio-controls {
width: 100%;
}
.player-audio-controls audio {
width: 100%;
border-radius: 8px;
background: #0a0a0a;
outline: none;
}
.player-stream-info {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
padding: 16px;
background: rgba(0, 0, 0, 0.3);
border-radius: 8px;
border: 1px solid rgba(0, 212, 255, 0.15);
}
.stream-info-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.info-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #9aa0a6;
font-weight: 600;
}
.info-value {
font-size: 1rem;
color: #f5f5f5;
font-weight: 500;
}
/* Stream Controls Section */
.stream-controls-section {
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
border: 1px solid rgba(0, 212, 255, 0.3);
}
.stream-controls-section h3 {
margin-top: 0;
color: #00d4ff;
margin-bottom: 16px;
}
.stream-controls-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
}
.control-group {
display: flex;
flex-direction: column;
gap: 10px;
}
.control-group label {
font-weight: 600;
color: #9aa0a6;
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.format-chips,
.mode-chips {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.format-chip,
.mode-chip {
padding: 10px 18px;
border-radius: 6px;
border: 2px solid rgba(0, 212, 255, 0.3);
background: rgba(0, 212, 255, 0.08);
color: #00d4ff;
cursor: pointer;
font-weight: 600;
transition: all 0.2s;
font-size: 0.9rem;
}
.format-chip:hover,
.mode-chip:hover {
background: rgba(0, 212, 255, 0.15);
border-color: rgba(0, 212, 255, 0.5);
transform: translateY(-2px);
}
.format-chip.active,
.mode-chip.active {
background: rgba(0, 212, 255, 0.25);
border-color: #00d4ff;
box-shadow: 0 0 12px rgba(0, 212, 255, 0.3);
}
.format-description {
padding: 8px 12px;
background: rgba(0, 0, 0, 0.3);
border-radius: 4px;
font-size: 0.85rem;
color: #9aa0a6;
font-style: italic;
}
.client-id-input {
padding: 10px 12px;
border-radius: 6px;
border: 1px solid #333;
background: #0f0f0f;
color: #fff;
font-family: 'Courier New', monospace;
font-size: 0.9rem;
}
.client-id-input:focus {
outline: none;
border-color: #00d4ff;
box-shadow: 0 0 8px rgba(0, 212, 255, 0.2);
}
/* Stream Diagnostics Section */
.stream-diagnostics-section {
background: #1a1a1a;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
border: 1px solid #333;
}
.stream-diagnostics-section h3 {
margin-top: 0;
color: #00d4ff;
}
/* Stream Metadata Section */
.stream-metadata-section {
background: #1a1a1a;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
border: 1px solid #333;
}
.stream-metadata-section h3 {
margin-top: 0;
color: #00d4ff;
}
.playlist-events-section {
background: #141414;
border-radius: 8px;
padding: 20px;
margin: 20px 0;
border: 1px solid #333;
}
.status-chip {
padding: 6px 10px;
border-radius: 999px;
border: 1px solid #555;
color: #bbb;
font-size: 0.8rem;
}
.status-chip.connected {
border-color: rgba(46, 204, 113, 0.6);
color: #2ecc71;
background: rgba(46, 204, 113, 0.1);
}
.events-list {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 14px;
}
.event-item {
background: rgba(0, 0, 0, 0.25);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 8px;
padding: 12px;
}
.event-header {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.event-kind {
text-transform: uppercase;
letter-spacing: 0.05em;
color: #00d4ff;
font-weight: 700;
}
.event-time {
color: #9aa0a6;
font-size: 0.9rem;
}
.event-source {
color: #9aa0a6;
font-style: italic;
font-size: 0.85rem;
}
.event-body {
display: flex;
flex-direction: column;
gap: 6px;
}
.event-row {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.event-row .label {
min-width: 80px;
}
.metadata-content {
margin-top: 12px;
}
.metadata-json {
background: #0f0f0f;
border: 1px solid #333;
border-radius: 6px;
padding: 16px;
overflow-x: auto;
font-family: 'Courier New', monospace;
font-size: 0.85rem;
color: #00d4ff;
line-height: 1.5;
max-height: 400px;
overflow-y: auto;
}
.metadata-json::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.metadata-json::-webkit-scrollbar-track {
background: #1a1a1a;
border-radius: 4px;
}
.metadata-json::-webkit-scrollbar-thumb {
background: #333;
border-radius: 4px;
}
.metadata-json::-webkit-scrollbar-thumb:hover {
background: #555;
}
/* Quick Test Section */
.quick-test-section {
background: linear-gradient(135deg, #2a1a1a 0%, #1a1a1a 100%);
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
border: 1px solid rgba(255, 193, 7, 0.3);
}
.quick-test-section h3 {
margin-top: 0;
color: #ffc107;
margin-bottom: 8px;
}
.section-description {
color: #9aa0a6;
font-size: 0.9rem;
margin-bottom: 16px;
}
.test-commands {
display: flex;
flex-direction: column;
gap: 16px;
}
.command-group {
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 193, 7, 0.2);
border-radius: 6px;
padding: 12px;
}
.command-label {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.command-label strong {
color: #ffc107;
font-size: 0.9rem;
}
.btn-copy-small {
background: rgba(255, 193, 7, 0.15);
border: 1px solid rgba(255, 193, 7, 0.3);
color: #ffc107;
padding: 4px 10px;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s;
}
.btn-copy-small:hover {
background: rgba(255, 193, 7, 0.25);
border-color: rgba(255, 193, 7, 0.5);
}
.command-text {
display: block;
background: #0a0a0a;
padding: 10px 12px;
border-radius: 4px;
font-family: 'Courier New', monospace;
font-size: 0.85rem;
color: #00d4ff;
overflow-x: auto;
white-space: nowrap;
border: 1px solid #222;
}
.command-text::-webkit-scrollbar {
height: 6px;
}
.command-text::-webkit-scrollbar-track {
background: #111;
border-radius: 3px;
}
.command-text::-webkit-scrollbar-thumb {
background: #333;
border-radius: 3px;
}
.command-text::-webkit-scrollbar-thumb:hover {
background: #555;
}
.diagnostics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
margin-bottom: 20px;
}
.diagnostic-card {
background: rgba(0, 212, 255, 0.05);
border: 1px solid rgba(0, 212, 255, 0.15);
border-radius: 6px;
padding: 12px;
display: flex;
flex-direction: column;
gap: 6px;
}
.diagnostic-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #9aa0a6;
font-weight: 600;
}
.diagnostic-value {
font-size: 1rem;
font-weight: 600;
color: #f5f5f5;
}
.diagnostic-value code {
background: rgba(0, 0, 0, 0.4);
padding: 4px 8px;
border-radius: 4px;
font-family: 'Courier New', monospace;
font-size: 0.85rem;
color: #00d4ff;
word-break: break-all;
display: block;
margin-top: 4px;
}
.stream-urls-list {
display: flex;
flex-direction: column;
gap: 10px;
background: rgba(0, 0, 0, 0.3);
padding: 16px;
border-radius: 6px;
}
.url-item {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
padding: 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.url-item:last-child {
border-bottom: none;
}
.url-item strong {
color: #00d4ff;
min-width: 120px;
}
/* Test Endpoints Section */
.test-endpoints-section {
background: linear-gradient(135deg, #1a2a1a 0%, #1a1a1a 100%);
border-radius: 8px;
padding: 20px;
margin: 30px 0;
border: 1px solid rgba(46, 204, 113, 0.3);
}
.test-endpoints-section h3 {
margin-top: 0;
color: #2ecc71;
margin-bottom: 16px;
}
.test-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 20px;
}
.test-card {
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(46, 204, 113, 0.2);
border-radius: 8px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.test-card h4 {
margin: 0 0 8px 0;
color: #2ecc71;
font-size: 1rem;
font-weight: 600;
}
.test-controls {
display: flex;
flex-direction: column;
gap: 10px;
}
.test-input {
padding: 8px 12px;
border-radius: 4px;
border: 1px solid #333;
background: #111;
color: #fff;
font-size: 0.9rem;
}
.test-input:focus {
outline: none;
border-color: #2ecc71;
box-shadow: 0 0 6px rgba(46, 204, 113, 0.2);
}
.test-result {
background: rgba(46, 204, 113, 0.05);
border: 1px solid rgba(46, 204, 113, 0.15);
border-radius: 6px;
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 4px;
}
.result-item {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 0.9rem;
}
.result-item strong {
color: #2ecc71;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.text-muted {
color: #666;
font-style: italic;
}
.cover-preview {
margin-top: 8px;
display: flex;
justify-content: center;
}
.cover-preview img {
max-width: 100%;
max-height: 300px;
border-radius: 8px;
border: 2px solid rgba(46, 204, 113, 0.3);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
}
.cover-preview-small {
margin-top: 8px;
display: flex;
justify-content: center;
}
.cover-preview-small img {
max-width: 150px;
max-height: 150px;
border-radius: 6px;
border: 2px solid rgba(46, 204, 113, 0.3);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
}
@media (max-width: 768px) {
.header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.controls {
width: 100%;
justify-content: flex-start;
}
.block-actions {
flex-direction: column;
align-items: flex-start;
}
.summary-row {
flex-direction: column;
align-items: flex-start;
gap: 6px;
}
.search-controls {
flex-direction: column;
align-items: stretch;
}
.stream-controls-grid {
grid-template-columns: 1fr;
}
.diagnostics-grid {
grid-template-columns: 1fr;
}
/* Enhanced Player Responsive */
.player-header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.player-controls-header {
width: 100%;
justify-content: flex-end;
}
.player-info {
flex-direction: column;
align-items: center;
text-align: center;
}
.player-cover img {
width: 150px;
height: 150px;
}
.player-title {
font-size: 1.4rem;
}
.player-artist {
font-size: 1.1rem;
}
.player-album {
font-size: 1rem;
}
.player-stream-info {
grid-template-columns: 1fr;
}
/* Test Section Responsive */
.test-grid {
grid-template-columns: 1fr;
}
.cover-preview img {
max-height: 200px;
}
}
</style>