debugagen openhome
This commit is contained in:
@@ -2,7 +2,11 @@
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { usePlaybackStore } from '@/stores/playback'
|
||||
import type { RendererSummary, RendererState } from '@/services/pmocontrol/types'
|
||||
import type {
|
||||
RendererCapabilitiesSummary,
|
||||
RendererSummary,
|
||||
RendererState,
|
||||
} from '@/services/pmocontrol/types'
|
||||
import StatusBadge from './StatusBadge.vue'
|
||||
import { Music, Volume2, VolumeX } from 'lucide-vue-next'
|
||||
|
||||
@@ -18,30 +22,44 @@ const metadata = computed(() => playbackStore.getTrackMetadata(props.renderer.id
|
||||
|
||||
const protocolLabel = computed(() => {
|
||||
switch (props.renderer.protocol) {
|
||||
case 'UpnpAvOnly':
|
||||
case 'upnp':
|
||||
return 'UPnP AV'
|
||||
case 'OpenHomeOnly':
|
||||
case 'openhome':
|
||||
return 'OpenHome'
|
||||
case 'Hybrid':
|
||||
return 'Hybrid'
|
||||
case 'hybrid':
|
||||
return 'Hybrid (UPnP + OpenHome)'
|
||||
default:
|
||||
return 'Unknown'
|
||||
return 'Inconnu'
|
||||
}
|
||||
})
|
||||
|
||||
const protocolClass = computed(() => {
|
||||
switch (props.renderer.protocol) {
|
||||
case 'UpnpAvOnly':
|
||||
case 'upnp':
|
||||
return 'protocol-upnp'
|
||||
case 'OpenHomeOnly':
|
||||
case 'openhome':
|
||||
return 'protocol-openhome'
|
||||
case 'Hybrid':
|
||||
case 'hybrid':
|
||||
return 'protocol-hybrid'
|
||||
default:
|
||||
return 'protocol-unknown'
|
||||
}
|
||||
})
|
||||
|
||||
const capabilityBadges = computed(() => {
|
||||
const caps = props.renderer.capabilities
|
||||
if (!caps) return []
|
||||
const mapping: Array<{ key: keyof RendererCapabilitiesSummary; label: string }> = [
|
||||
{ key: 'has_avtransport', label: 'AVTransport' },
|
||||
{ key: 'has_oh_playlist', label: 'OpenHome' },
|
||||
{ key: 'has_linkplay_http', label: 'Hybrid' },
|
||||
{ key: 'has_oh_volume', label: 'Vol' },
|
||||
{ key: 'has_oh_time', label: 'Time' },
|
||||
{ key: 'has_oh_info', label: 'Info' },
|
||||
]
|
||||
return mapping.filter(({ key }) => caps[key]).map(({ key, label }) => ({ key, label }))
|
||||
})
|
||||
|
||||
const hasCover = computed(() => !!metadata.value?.album_art_uri)
|
||||
|
||||
function goToRenderer() {
|
||||
@@ -66,6 +84,12 @@ function goToRenderer() {
|
||||
</div>
|
||||
|
||||
<!-- Cover Art -->
|
||||
<div v-if="capabilityBadges.length" class="capabilities">
|
||||
<span v-for="badge in capabilityBadges" :key="badge.key" class="capability-badge">
|
||||
{{ badge.label }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="card-cover">
|
||||
<img
|
||||
v-if="hasCover"
|
||||
@@ -166,6 +190,21 @@ function goToRenderer() {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.capabilities {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.capability-badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-xs);
|
||||
background-color: var(--color-bg-tertiary);
|
||||
color: var(--color-text-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.protocol-badge {
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
|
||||
55
pmoapp/webapp/src/services/openhomePlaylist.ts
Normal file
55
pmoapp/webapp/src/services/openhomePlaylist.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type {
|
||||
OpenHomePlaylistAddRequest,
|
||||
OpenHomePlaylistSnapshot,
|
||||
} from '@/services/pmocontrol/types'
|
||||
|
||||
const API_BASE = '/api/control'
|
||||
|
||||
export async function getOpenHomePlaylist(rendererId: string): Promise<OpenHomePlaylistSnapshot> {
|
||||
const resp = await fetch(
|
||||
`${API_BASE}/renderers/${encodeURIComponent(rendererId)}/oh/playlist`,
|
||||
)
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Failed to fetch OpenHome playlist: ${resp.status} ${resp.statusText}`)
|
||||
}
|
||||
return resp.json()
|
||||
}
|
||||
|
||||
export async function clearOpenHomePlaylist(rendererId: string): Promise<void> {
|
||||
const resp = await fetch(
|
||||
`${API_BASE}/renderers/${encodeURIComponent(rendererId)}/oh/playlist/clear`,
|
||||
{ method: 'POST' },
|
||||
)
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Failed to clear OpenHome playlist: ${resp.status} ${resp.statusText}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function addOpenHomeTrack(
|
||||
rendererId: string,
|
||||
payload: OpenHomePlaylistAddRequest,
|
||||
): Promise<void> {
|
||||
const resp = await fetch(
|
||||
`${API_BASE}/renderers/${encodeURIComponent(rendererId)}/oh/playlist/add`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
)
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Failed to add track to OpenHome playlist: ${resp.status} ${resp.statusText}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function playOpenHomeTrack(rendererId: string, trackId: number): Promise<void> {
|
||||
const resp = await fetch(
|
||||
`${API_BASE}/renderers/${encodeURIComponent(
|
||||
rendererId,
|
||||
)}/oh/playlist/play/${encodeURIComponent(trackId.toString())}`,
|
||||
{ method: 'POST' },
|
||||
)
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Failed to play OpenHome track ${trackId}: ${resp.status} ${resp.statusText}`)
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,28 @@
|
||||
// RENDERERS
|
||||
// ============================================================================
|
||||
|
||||
export type RendererProtocolSummary = 'upnp' | 'openhome' | 'hybrid'
|
||||
|
||||
export interface RendererCapabilitiesSummary {
|
||||
has_avtransport: boolean
|
||||
has_avtransport_set_next: boolean
|
||||
has_rendering_control: boolean
|
||||
has_connection_manager: boolean
|
||||
has_linkplay_http: boolean
|
||||
has_arylic_tcp: boolean
|
||||
has_oh_playlist: boolean
|
||||
has_oh_volume: boolean
|
||||
has_oh_info: boolean
|
||||
has_oh_time: boolean
|
||||
has_oh_radio: boolean
|
||||
}
|
||||
|
||||
export interface RendererSummary {
|
||||
id: string
|
||||
friendly_name: string
|
||||
model_name: string
|
||||
protocol: 'UpnpAvOnly' | 'OpenHomeOnly' | 'Hybrid'
|
||||
protocol: RendererProtocolSummary
|
||||
capabilities: RendererCapabilitiesSummary
|
||||
online: boolean
|
||||
}
|
||||
|
||||
@@ -51,6 +68,32 @@ export interface QueueSnapshot {
|
||||
current_index: number | null // Index de la piste en cours (null si rien en lecture)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// OPENHOME PLAYLIST
|
||||
// ============================================================================
|
||||
|
||||
export interface OpenHomePlaylistTrack {
|
||||
id: number
|
||||
uri: string
|
||||
title: string | null
|
||||
artist: string | null
|
||||
album: string | null
|
||||
album_art_uri: string | null
|
||||
}
|
||||
|
||||
export interface OpenHomePlaylistSnapshot {
|
||||
renderer_id: string
|
||||
current_id: number | null
|
||||
tracks: OpenHomePlaylistTrack[]
|
||||
}
|
||||
|
||||
export interface OpenHomePlaylistAddRequest {
|
||||
uri: string
|
||||
metadata: string
|
||||
after_id?: number | null
|
||||
play?: boolean
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MEDIA SERVERS
|
||||
// ============================================================================
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useRenderersStore } from '@/stores/renderers'
|
||||
import { useUIStore } from '@/stores/ui'
|
||||
@@ -10,6 +10,13 @@ import QueueViewer from '@/components/pmocontrol/QueueViewer.vue'
|
||||
import PlaylistBindingPanel from '@/components/pmocontrol/PlaylistBindingPanel.vue'
|
||||
import StatusBadge from '@/components/pmocontrol/StatusBadge.vue'
|
||||
import { ArrowLeft, Radio } from 'lucide-vue-next'
|
||||
import {
|
||||
addOpenHomeTrack,
|
||||
clearOpenHomePlaylist,
|
||||
getOpenHomePlaylist,
|
||||
playOpenHomeTrack,
|
||||
} from '@/services/openhomePlaylist'
|
||||
import type { OpenHomePlaylistSnapshot } from '@/services/pmocontrol/types'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -19,6 +26,22 @@ const uiStore = useUIStore()
|
||||
const rendererId = computed(() => route.params.id as string)
|
||||
const renderer = computed(() => renderersStore.getRendererById(rendererId.value))
|
||||
const state = computed(() => renderersStore.getStateById(rendererId.value))
|
||||
const openHomeSupported = computed(() => {
|
||||
const current = renderer.value
|
||||
if (!current) return false
|
||||
const caps = current.capabilities
|
||||
return (
|
||||
current.protocol === 'openhome' ||
|
||||
current.protocol === 'hybrid' ||
|
||||
caps?.has_oh_playlist === true
|
||||
)
|
||||
})
|
||||
const ohPlaylist = ref<OpenHomePlaylistSnapshot | null>(null)
|
||||
const ohLoading = ref(false)
|
||||
const ohError = ref<string | null>(null)
|
||||
const newOhUri = ref('')
|
||||
const newOhMeta = ref('')
|
||||
const canAddOhTrack = computed(() => newOhUri.value.trim().length > 0)
|
||||
|
||||
// Charger les données au montage si nécessaire
|
||||
onMounted(async () => {
|
||||
@@ -34,6 +57,25 @@ onMounted(async () => {
|
||||
await renderersStore.fetchQueue(rendererId.value)
|
||||
})
|
||||
|
||||
watch(
|
||||
openHomeSupported,
|
||||
async supported => {
|
||||
if (supported) {
|
||||
await refreshOhPlaylist()
|
||||
} else {
|
||||
ohPlaylist.value = null
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(rendererId, () => {
|
||||
ohPlaylist.value = null
|
||||
ohError.value = null
|
||||
newOhUri.value = ''
|
||||
newOhMeta.value = ''
|
||||
})
|
||||
|
||||
// Nettoyer la sélection au démontage
|
||||
onUnmounted(() => {
|
||||
uiStore.selectRenderer(null)
|
||||
@@ -46,16 +88,69 @@ function goBack() {
|
||||
const protocolLabel = computed(() => {
|
||||
if (!renderer.value) return ''
|
||||
switch (renderer.value.protocol) {
|
||||
case 'UpnpAvOnly':
|
||||
case 'upnp':
|
||||
return 'UPnP AV'
|
||||
case 'OpenHomeOnly':
|
||||
case 'openhome':
|
||||
return 'OpenHome'
|
||||
case 'Hybrid':
|
||||
return 'Hybrid'
|
||||
case 'hybrid':
|
||||
return 'Hybrid (UPnP + OpenHome)'
|
||||
default:
|
||||
return 'Unknown'
|
||||
return 'Inconnu'
|
||||
}
|
||||
})
|
||||
|
||||
async function refreshOhPlaylist() {
|
||||
if (!renderer.value || !openHomeSupported.value) return
|
||||
ohLoading.value = true
|
||||
ohError.value = null
|
||||
try {
|
||||
ohPlaylist.value = await getOpenHomePlaylist(renderer.value.id)
|
||||
} catch (e) {
|
||||
ohError.value =
|
||||
e instanceof Error ? e.message : 'Failed to load OpenHome playlist'
|
||||
} finally {
|
||||
ohLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOhClear() {
|
||||
if (!renderer.value) return
|
||||
try {
|
||||
await clearOpenHomePlaylist(renderer.value.id)
|
||||
await refreshOhPlaylist()
|
||||
} catch (e) {
|
||||
ohError.value =
|
||||
e instanceof Error ? e.message : 'Failed to clear OpenHome playlist'
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOhPlay(trackId: number) {
|
||||
if (!renderer.value) return
|
||||
try {
|
||||
await playOpenHomeTrack(renderer.value.id, trackId)
|
||||
await refreshOhPlaylist()
|
||||
} catch (e) {
|
||||
ohError.value =
|
||||
e instanceof Error ? e.message : `Failed to play OpenHome track ${trackId}`
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOhAdd() {
|
||||
if (!renderer.value || !canAddOhTrack.value) return
|
||||
try {
|
||||
await addOpenHomeTrack(renderer.value.id, {
|
||||
uri: newOhUri.value.trim(),
|
||||
metadata: newOhMeta.value,
|
||||
play: false,
|
||||
})
|
||||
newOhUri.value = ''
|
||||
newOhMeta.value = ''
|
||||
await refreshOhPlaylist()
|
||||
} catch (e) {
|
||||
ohError.value =
|
||||
e instanceof Error ? e.message : 'Failed to add track to OpenHome playlist'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -106,6 +201,65 @@ const protocolLabel = computed(() => {
|
||||
<section class="content-section">
|
||||
<PlaylistBindingPanel :rendererId="rendererId" />
|
||||
</section>
|
||||
|
||||
<section v-if="openHomeSupported" class="content-section openhome-playlist">
|
||||
<h2>OpenHome Playlist</h2>
|
||||
|
||||
<div v-if="ohLoading">Chargement de la playlist…</div>
|
||||
<div v-else-if="ohError" class="error">{{ ohError }}</div>
|
||||
|
||||
<div v-else-if="ohPlaylist && ohPlaylist.tracks.length === 0">
|
||||
Playlist vide.
|
||||
</div>
|
||||
|
||||
<div v-else-if="ohPlaylist">
|
||||
<table class="oh-playlist-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Titre</th>
|
||||
<th>Artiste</th>
|
||||
<th>Album</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="track in ohPlaylist.tracks"
|
||||
:key="track.id"
|
||||
:class="{ current: ohPlaylist.current_id === track.id }"
|
||||
>
|
||||
<td>{{ track.id }}</td>
|
||||
<td>{{ track.title || '—' }}</td>
|
||||
<td>{{ track.artist || '—' }}</td>
|
||||
<td>{{ track.album || '—' }}</td>
|
||||
<td class="actions-cell">
|
||||
<button class="btn btn-secondary btn-icon" @click="handleOhPlay(track.id)" title="Lire ce morceau">
|
||||
▶
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="oh-controls">
|
||||
<button class="btn btn-secondary" @click="refreshOhPlaylist">
|
||||
🔁 Rafraîchir
|
||||
</button>
|
||||
<button class="btn btn-danger" @click="handleOhClear">
|
||||
🗑 Effacer la playlist
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="oh-add-form">
|
||||
<input v-model="newOhUri" placeholder="URI à ajouter" />
|
||||
<textarea v-model="newOhMeta" placeholder="DIDL-Lite (optionnel)" rows="2"></textarea>
|
||||
<button class="btn btn-primary" @click="handleOhAdd" :disabled="!canAddOhTrack">
|
||||
➕ Ajouter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Right column (Desktop) / Bottom (Mobile) -->
|
||||
@@ -244,6 +398,77 @@ const protocolLabel = computed(() => {
|
||||
margin: 0 0 var(--spacing-md);
|
||||
}
|
||||
|
||||
.openhome-playlist h2 {
|
||||
margin: 0 0 var(--spacing-md);
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.oh-playlist-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.oh-playlist-table th,
|
||||
.oh-playlist-table td {
|
||||
padding: var(--spacing-xs);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.oh-playlist-table tbody tr:hover {
|
||||
background-color: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.oh-playlist-table tr.current {
|
||||
background-color: rgba(16, 185, 129, 0.15);
|
||||
}
|
||||
|
||||
.actions-cell {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.oh-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-sm);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.oh-add-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.oh-add-form input,
|
||||
.oh-add-form textarea {
|
||||
width: 100%;
|
||||
padding: var(--spacing-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background-color: var(--color-bg-tertiary);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.oh-add-form button {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--status-error, #dc2626);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Responsive - Desktop */
|
||||
@media (min-width: 1024px) {
|
||||
.renderer-content {
|
||||
|
||||
Reference in New Issue
Block a user