amélioration de la webapp

This commit is contained in:
2025-10-19 13:42:29 +02:00
parent 756ae7f82c
commit 208fe8be76
179 changed files with 2426 additions and 1685 deletions

View File

@@ -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>

View File

@@ -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;

View File

@@ -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;
}