feat: add web share target support and implement catalog search API
This patch release bumps the version to 0.3.61 and introduces several key improvements across the stack. The backend `/info` route registration is deferred until after UPnP initialization to ensure the local server ID is correctly exposed. A new `GET /{id}/search` endpoint has been added to the pmosource API for querying music catalogs. On the frontend, Web Share Target support is enabled via Vite configuration and a dedicated composable that handles incoming URL parameters, playback state, and error notifications. Renderer selection state is also now exposed for UI synchronization.
This commit is contained in:
@@ -10,7 +10,17 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { watch } from 'vue'
|
||||
import NotificationToast from '@/components/NotificationToast.vue'
|
||||
import { useShareTarget } from '@/composables/useShareTarget'
|
||||
import { useUIStore } from '@/stores/ui'
|
||||
|
||||
const ui = useUIStore()
|
||||
const { shareError } = useShareTarget()
|
||||
|
||||
watch(shareError, (err) => {
|
||||
if (err) ui.notifyError(err)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -579,6 +579,8 @@ export function useRenderers() {
|
||||
volumeUp,
|
||||
volumeDown,
|
||||
toggleMute,
|
||||
// Selection
|
||||
selectedRendererId,
|
||||
// Playlist binding
|
||||
attachPlaylist,
|
||||
detachPlaylist,
|
||||
|
||||
90
pmoapp/webapp/src/composables/useShareTarget.ts
Normal file
90
pmoapp/webapp/src/composables/useShareTarget.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { searchSource } from '@/services/pmosource'
|
||||
import { useRenderers } from '@/composables/useRenderers'
|
||||
|
||||
export interface ShareTargetResult {
|
||||
url: string
|
||||
title: string | null
|
||||
containerId: string
|
||||
}
|
||||
|
||||
const pendingShare = ref<ShareTargetResult | null>(null)
|
||||
const shareError = ref<string | null>(null)
|
||||
|
||||
let localServerId: string | null = null
|
||||
|
||||
async function fetchLocalServerId(): Promise<string | null> {
|
||||
if (localServerId) return localServerId
|
||||
try {
|
||||
const resp = await fetch('/api/info')
|
||||
if (!resp.ok) return null
|
||||
const data = await resp.json()
|
||||
localServerId = data.local_server_id ?? null
|
||||
return localServerId
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function useShareTarget() {
|
||||
const { selectedRendererId, attachAndPlayPlaylist } = useRenderers()
|
||||
|
||||
async function handleShareIfPresent() {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const sharedUrl = params.get('share_url') ?? params.get('share_text') ?? null
|
||||
const sharedTitle = params.get('share_title')
|
||||
|
||||
if (!sharedUrl) return
|
||||
|
||||
const clean = new URL(window.location.href)
|
||||
clean.searchParams.delete('share_url')
|
||||
clean.searchParams.delete('share_title')
|
||||
clean.searchParams.delete('share_text')
|
||||
window.history.replaceState({}, '', clean.toString())
|
||||
|
||||
try {
|
||||
shareError.value = null
|
||||
const result = await searchSource('url', sharedUrl)
|
||||
if (result.total === 0) {
|
||||
shareError.value = `Aucun contenu trouvé pour : ${sharedUrl}`
|
||||
return
|
||||
}
|
||||
|
||||
const container = result.containers[0] ?? null
|
||||
const containerId = container?.id ?? result.items[0]?.id
|
||||
|
||||
if (!containerId) {
|
||||
shareError.value = 'Contenu résolu mais sans identifiant jouable'
|
||||
return
|
||||
}
|
||||
|
||||
const serverId = await fetchLocalServerId()
|
||||
const rendererId = selectedRendererId.value
|
||||
|
||||
if (!serverId || !rendererId) {
|
||||
// Pas de renderer sélectionné ou serveur inconnu : stocker pour affichage manuel
|
||||
pendingShare.value = { url: sharedUrl, title: sharedTitle, containerId }
|
||||
return
|
||||
}
|
||||
|
||||
await attachAndPlayPlaylist(rendererId, serverId, containerId)
|
||||
} catch (e) {
|
||||
shareError.value = e instanceof Error ? e.message : 'Erreur lors de la résolution'
|
||||
}
|
||||
}
|
||||
|
||||
function clearShare() {
|
||||
pendingShare.value = null
|
||||
shareError.value = null
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
handleShareIfPresent()
|
||||
})
|
||||
|
||||
return {
|
||||
pendingShare,
|
||||
shareError,
|
||||
clearShare,
|
||||
}
|
||||
}
|
||||
@@ -171,6 +171,18 @@ export function getSourceImageUrl(sourceId: string): string {
|
||||
return `${API_BASE}/${sourceId}/image`
|
||||
}
|
||||
|
||||
/**
|
||||
* Recherche dans une source musicale (URL, texte libre…)
|
||||
*/
|
||||
export async function searchSource(sourceId: string, query: string): Promise<BrowseResponse> {
|
||||
const params = new URLSearchParams({ q: query })
|
||||
const response = await fetch(`${API_BASE}/${sourceId}/search?${params.toString()}`)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Search failed: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les capacités d'une source
|
||||
*/
|
||||
|
||||
@@ -32,6 +32,15 @@ export default defineConfig({
|
||||
purpose: 'any maskable',
|
||||
},
|
||||
],
|
||||
share_target: {
|
||||
action: '/app/',
|
||||
method: 'GET',
|
||||
params: {
|
||||
url: 'share_url',
|
||||
title: 'share_title',
|
||||
text: 'share_text',
|
||||
},
|
||||
},
|
||||
},
|
||||
workbox: {
|
||||
navigateFallback: '/app/index.html',
|
||||
|
||||
Reference in New Issue
Block a user