amélioration de la webapp
This commit is contained in:
@@ -8,6 +8,13 @@
|
||||
<span v-else>🔄</span>
|
||||
Refresh
|
||||
</button>
|
||||
<button
|
||||
@click="togglePlayback"
|
||||
:disabled="!nowPlaying || !nowPlaying.stream_url"
|
||||
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 }}
|
||||
@@ -16,6 +23,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Audio Player -->
|
||||
<div v-if="isPlaying || audioError" class="audio-player-container">
|
||||
<audio
|
||||
v-if="!audioError"
|
||||
ref="audioPlayer"
|
||||
controls
|
||||
@ended="handleAudioEnded"
|
||||
@error="handleAudioError"
|
||||
></audio>
|
||||
<p v-if="audioError" class="audio-error">{{ audioError }}</p>
|
||||
<button @click="stopPlayback" class="btn-stop">
|
||||
{{ audioError ? '✕ Close' : '⏹️ Stop' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">
|
||||
❌ {{ error }}
|
||||
</div>
|
||||
@@ -114,7 +136,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, onMounted, nextTick } from 'vue'
|
||||
|
||||
const API_BASE = '/api/radioparadise'
|
||||
|
||||
@@ -123,6 +145,9 @@ 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('')
|
||||
|
||||
// Format duration from milliseconds to MM:SS
|
||||
function formatDuration(ms) {
|
||||
@@ -178,6 +203,61 @@ function changeChannel() {
|
||||
// TODO: Implement channel switching in the API
|
||||
}
|
||||
|
||||
function playStream() {
|
||||
if (!nowPlaying.value?.stream_url) {
|
||||
audioError.value = 'No stream URL available'
|
||||
return
|
||||
}
|
||||
|
||||
audioError.value = ''
|
||||
isPlaying.value = true
|
||||
|
||||
nextTick(() => {
|
||||
const player = audioPlayer.value
|
||||
if (!player) {
|
||||
return
|
||||
}
|
||||
player.src = nowPlaying.value.stream_url
|
||||
player.play().catch((e) => {
|
||||
console.error('Failed to start playback:', e)
|
||||
audioError.value = `Cannot play stream: ${e.message}`
|
||||
isPlaying.value = false
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function stopPlayback() {
|
||||
if (audioPlayer.value) {
|
||||
audioPlayer.value.pause()
|
||||
audioPlayer.value.currentTime = 0
|
||||
audioPlayer.value.src = ''
|
||||
}
|
||||
isPlaying.value = false
|
||||
audioError.value = ''
|
||||
}
|
||||
|
||||
function togglePlayback() {
|
||||
if (isPlaying.value) {
|
||||
stopPlayback()
|
||||
} else {
|
||||
playStream()
|
||||
}
|
||||
}
|
||||
|
||||
function handleAudioEnded() {
|
||||
isPlaying.value = false
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Initialize on mount
|
||||
onMounted(async () => {
|
||||
await fetchChannels()
|
||||
@@ -239,6 +319,26 @@ onMounted(async () => {
|
||||
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;
|
||||
}
|
||||
|
||||
.channel-select {
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
@@ -513,4 +613,31 @@ onMounted(async () => {
|
||||
color: #999;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.audio-player-container {
|
||||
margin: 12px 0 24px;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border: 1px solid #333;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.audio-error {
|
||||
margin: 0;
|
||||
color: #ff6b6b;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.btn-stop {
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
background: rgba(231, 76, 60, 0.2);
|
||||
color: #e74c3c;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -55,13 +55,37 @@
|
||||
</div>
|
||||
|
||||
<!-- Services -->
|
||||
<div v-else class="services-list">
|
||||
<ServicePanel
|
||||
v-for="service in device.services"
|
||||
:key="service.name"
|
||||
:service="service"
|
||||
:device-udn="device.udn"
|
||||
/>
|
||||
<div v-else class="device-content">
|
||||
<div class="device-summary">
|
||||
<div class="meta-row">
|
||||
<span class="meta-label">UDN:</span>
|
||||
<code class="meta-value">{{ device.udn }}</code>
|
||||
</div>
|
||||
<div class="meta-row" v-if="device.description_url">
|
||||
<span class="meta-label">Description:</span>
|
||||
<a
|
||||
:href="device.description_url"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="meta-link"
|
||||
>
|
||||
View XML
|
||||
</a>
|
||||
</div>
|
||||
<div class="meta-row" v-if="device.base_url">
|
||||
<span class="meta-label">Base URL:</span>
|
||||
<code class="meta-value">{{ device.base_url }}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="services-list">
|
||||
<ServicePanel
|
||||
v-for="service in device.services"
|
||||
:key="service.name"
|
||||
:service="service"
|
||||
:device-udn="device.udn"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
@@ -353,6 +377,51 @@ onUnmounted(() => {
|
||||
color: #95a5a6;
|
||||
}
|
||||
|
||||
.device-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.device-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem 1.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 6px;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid rgba(52, 152, 219, 0.25);
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
font-weight: 600;
|
||||
color: #95a5a6;
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
background: rgba(44, 62, 80, 0.6);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
color: #ecf0f1;
|
||||
}
|
||||
|
||||
.meta-link {
|
||||
color: #1abc9c;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.meta-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.services-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -30,6 +30,13 @@
|
||||
<span v-if="action.out_arguments.length > 0" class="badge out-badge" title="Output arguments">
|
||||
⬅️ {{ action.out_arguments.length }}
|
||||
</span>
|
||||
<span
|
||||
v-if="action.stateless"
|
||||
class="badge stateless-badge"
|
||||
title="Does not mutate state variables"
|
||||
>
|
||||
🧊 Stateless
|
||||
</span>
|
||||
<span class="expand-indicator">
|
||||
{{ expandedAction === action.name ? '▼' : '▶' }}
|
||||
</span>
|
||||
@@ -38,6 +45,12 @@
|
||||
|
||||
<transition name="expand-args">
|
||||
<div v-if="expandedAction === action.name" class="action-details">
|
||||
<div v-if="action.stateless" class="action-flags">
|
||||
<span class="stateless-pill">
|
||||
Stateless action — no state variables updated
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Input arguments -->
|
||||
<div v-if="action.in_arguments.length > 0" class="arguments-section">
|
||||
<h5 class="section-title">
|
||||
@@ -299,6 +312,12 @@ onMounted(() => {
|
||||
border: 1px solid rgba(46, 204, 113, 0.3);
|
||||
}
|
||||
|
||||
.stateless-badge {
|
||||
background: rgba(155, 89, 182, 0.2);
|
||||
color: #9b59b6;
|
||||
border: 1px solid rgba(155, 89, 182, 0.3);
|
||||
}
|
||||
|
||||
.expand-indicator {
|
||||
color: #3498db;
|
||||
font-size: 0.9rem;
|
||||
@@ -316,6 +335,23 @@ onMounted(() => {
|
||||
border-top: 1px solid rgba(52, 152, 219, 0.2);
|
||||
}
|
||||
|
||||
.action-flags {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.stateless-pill {
|
||||
display: inline-block;
|
||||
padding: 0.3rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(155, 89, 182, 0.15);
|
||||
border: 1px solid rgba(155, 89, 182, 0.25);
|
||||
color: #d2a6e6;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
}
|
||||
|
||||
.arguments-section {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user