feat: ajouter la recherche dans les serveurs media

Implémente une fonctionnalité de recherche pour les serveurs media (Qobuz et autres) avec:
- Nouvelle API endpoint /servers/{serverId}/search côté backend
- Fonction searchServer dans useMediaServers.ts pour gérer la requête
- Composants MediaBrowser.vue et ServerDrawer.vue avec barre de recherche interactive
- Support des résultats mixtes (albums, artistes, pistes, playlists)
- Gestion des états de chargement, erreur et résultat vide
- Affichage conditionnel selon le mode navigation/recherche
This commit is contained in:
2026-03-29 20:58:20 +02:00
parent ec852c0002
commit 843f474a98
6 changed files with 490 additions and 14 deletions

View File

@@ -6,7 +6,7 @@ import { useUIStore } from "@/stores/ui";
import Breadcrumb from "./Breadcrumb.vue";
import ContainerItem from "./ContainerItem.vue";
import MediaItem from "./MediaItem.vue";
import { Loader2 } from "lucide-vue-next";
import { Loader2, Search, X } from "lucide-vue-next";
const props = defineProps<{
serverId: string;
@@ -22,8 +22,27 @@ const {
loading,
loadingMore,
error,
searchResults,
searchQuery,
searchServer,
clearSearch,
} = useMediaServers();
const searchInput = ref('');
async function handleSearch() {
if (searchInput.value.trim()) {
await searchServer(props.serverId, searchInput.value.trim());
}
}
function handleClearSearch() {
searchInput.value = '';
clearSearch();
}
const isSearchMode = computed(() => searchQuery.value !== '');
const { playContent, addToQueue, attachAndPlayPlaylist, attachPlaylist } =
useRenderers();
const uiStore = useUIStore();
@@ -33,7 +52,9 @@ const sentinelRef = ref<HTMLElement | null>(null);
let observer: IntersectionObserver | null = null;
const browseData = computed(() =>
getBrowseCached(props.serverId, props.containerId),
isSearchMode.value
? searchResults.value
: getBrowseCached(props.serverId, props.containerId),
);
const containers = computed(
@@ -44,7 +65,7 @@ const items = computed(
() => browseData.value?.entries.filter((e) => !e.is_container) || [],
);
const canLoadMore = computed(() => hasMore(props.serverId, props.containerId));
const canLoadMore = computed(() => !isSearchMode.value && hasMore(props.serverId, props.containerId));
function setupObserver() {
if (observer) observer.disconnect();
@@ -165,6 +186,31 @@ async function handleQueueItem(itemId: string, rendererId: string) {
@navigate="handleNavigate"
/>
<!-- Search bar -->
<div class="search-bar">
<div class="search-input-wrapper">
<Search :size="16" class="search-icon" />
<input
v-model="searchInput"
type="text"
class="search-input"
placeholder="Rechercher..."
@keyup.enter="handleSearch"
/>
<button
v-if="searchInput || isSearchMode"
class="search-clear"
@click="handleClearSearch"
title="Effacer"
>
<X :size="14" />
</button>
</div>
<button class="btn btn-primary search-btn" @click="handleSearch">
Rechercher
</button>
</div>
<!-- Loading state -->
<div v-if="loading" class="browser-loading">
<Loader2 :size="32" class="spinner" />
@@ -220,7 +266,7 @@ async function handleQueueItem(itemId: string, rendererId: string) {
v-if="!containers.length && !items.length"
class="browser-empty"
>
<p>Ce dossier est vide</p>
<p>{{ isSearchMode ? 'Aucun résultat' : 'Ce dossier est vide' }}</p>
</div>
<!-- Sentinel infinite scroll -->
@@ -328,6 +374,64 @@ async function handleQueueItem(itemId: string, rendererId: string) {
color: var(--color-text-secondary);
}
/* Search */
.search-bar {
display: flex;
gap: var(--spacing-sm);
align-items: center;
}
.search-input-wrapper {
flex: 1;
position: relative;
display: flex;
align-items: center;
}
.search-icon {
position: absolute;
left: var(--spacing-sm);
color: var(--color-text-tertiary);
pointer-events: none;
}
.search-input {
width: 100%;
padding: var(--spacing-xs) var(--spacing-xl) var(--spacing-xs) calc(var(--spacing-sm) + 20px);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-secondary);
color: var(--color-text);
font-size: var(--text-sm);
}
.search-input:focus {
outline: none;
border-color: var(--color-primary);
}
.search-clear {
position: absolute;
right: var(--spacing-xs);
background: none;
border: none;
cursor: pointer;
color: var(--color-text-tertiary);
display: flex;
align-items: center;
padding: 2px;
}
.search-clear:hover {
color: var(--color-text);
}
.search-btn {
white-space: nowrap;
padding: var(--spacing-xs) var(--spacing-md);
font-size: var(--text-sm);
}
/* Scrollbar styling */
.browser-content::-webkit-scrollbar {
width: 6px;

View File

@@ -41,8 +41,25 @@ const {
currentPath,
setPath,
clearPath,
searchResults,
searchQuery,
searchServer,
clearSearch,
} = useMediaServers();
const searchInput = ref('');
const isSearchMode = computed(() => searchQuery.value !== '');
async function handleSearch() {
if (!currentServer.value || !searchInput.value.trim()) return;
await searchServer(currentServer.value.id, searchInput.value.trim());
}
function handleClearSearch() {
searchInput.value = '';
clearSearch();
}
const { playContent, addToQueue, addAfterCurrent, attachAndPlayPlaylist } =
useRenderers();
@@ -427,6 +444,28 @@ function handleSettingsClick() {
</button>
</nav>
<!-- Search bar (visible en mode navigation) -->
<div v-if="isNavigating" class="search-bar">
<input
v-model="searchInput"
type="text"
class="search-input"
placeholder="Rechercher..."
@keyup.enter="handleSearch"
/>
<button
v-if="searchInput || isSearchMode"
class="search-clear-btn"
@click="handleClearSearch"
title="Effacer"
>
<X :size="14" />
</button>
<button class="search-btn" @click="handleSearch" title="Rechercher">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
</button>
</div>
<!-- Contenu -->
<div ref="drawerContentRef" class="drawer-content">
<!-- Liste des serveurs -->
@@ -520,6 +559,53 @@ function handleSettingsClick() {
<p>Chargement...</p>
</div>
<!-- Résultats de recherche -->
<div v-else-if="isSearchMode && searchResults">
<p v-if="searchResults.entries.length === 0" class="empty-state">Aucun résultat</p>
<ul v-else class="content-list">
<li
v-for="item in searchResults.entries"
:key="item.id"
class="content-item"
:class="{ navigable: item.is_container }"
@click="handleItemClick(item)"
>
<div class="content-cover">
<img
v-if="item.album_art_uri && !getImageState(item.id).error"
:src="item.album_art_uri"
:alt="item.title"
class="cover-img"
:class="{ loaded: getImageState(item.id).loaded }"
@load="handleImageLoad(item.id)"
@error="handleImageError(item.id)"
/>
<div v-else class="cover-placeholder">
<Folder v-if="item.is_container" :size="24" />
<Music v-else :size="24" />
</div>
</div>
<div class="content-info">
<p class="content-title">{{ item.title }}</p>
<p v-if="item.artist" class="content-subtitle">{{ item.artist }}</p>
</div>
<div class="item-actions" @click.stop>
<button class="action-btn play-btn" @click="handlePlayItem($event, item)" title="Lire">
<Play :size="14" />
</button>
<button class="action-btn" @click="toggleMenu(item.id, $event)" title="Plus">
<MoreVertical :size="14" />
</button>
<div v-if="openMenuId === item.id" class="item-menu">
<button @click="handleAddToQueue($event, item)"><Plus :size="14" /> Ajouter à la queue</button>
<button @click="handleAddAfterCurrent($event, item)"><Plus :size="14" /> Après le current</button>
</div>
</div>
<ChevronRight v-if="item.is_container" :size="16" class="content-chevron" />
</li>
</ul>
</div>
<!-- Contenu du serveur -->
<ul v-else-if="browseData" class="content-list">
<li
@@ -735,6 +821,62 @@ function handleSettingsClick() {
}
/* Breadcrumb */
.search-bar {
display: flex;
align-items: center;
gap: 6px;
padding: var(--spacing-sm) var(--spacing-md);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
flex-shrink: 0;
}
.search-input {
flex: 1;
padding: 6px 10px;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: var(--radius-md);
background: rgba(255, 255, 255, 0.07);
color: var(--color-text);
font-size: var(--text-sm);
min-width: 0;
}
.search-input:focus {
outline: none;
border-color: var(--color-primary);
}
.search-input::placeholder {
color: var(--color-text-tertiary);
}
.search-clear-btn,
.search-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 6px;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
background: rgba(255, 255, 255, 0.07);
color: var(--color-text-secondary);
flex-shrink: 0;
}
.search-clear-btn:hover,
.search-btn:hover {
background: rgba(255, 255, 255, 0.15);
color: var(--color-text);
}
.empty-state {
text-align: center;
padding: var(--spacing-xl);
color: var(--color-text-tertiary);
font-size: var(--text-sm);
}
.breadcrumb {
display: flex;
align-items: center;

View File

@@ -25,6 +25,8 @@ export interface BrowseState {
const serversCache = ref<Map<string, MediaServerSummary>>(new Map())
const browseCache = ref<Map<string, BrowseState>>(new Map())
const currentPath = ref<BreadcrumbItem[]>([])
const searchResults = ref<BrowseState | null>(null)
const searchQuery = ref<string>('')
// Timestamps
const lastFetch = {
@@ -193,6 +195,39 @@ export function useMediaServers() {
}
}
// Recherche dans un serveur
async function searchServer(serverId: string, query: string) {
if (!query.trim()) {
searchResults.value = null
searchQuery.value = ''
return
}
try {
loading.value = true
error.value = null
searchQuery.value = query
const data = await api.searchServer(serverId, query)
searchResults.value = {
container_id: 'search',
entries: data.entries,
total_count: data.total_count,
}
} catch (e) {
error.value = e instanceof Error ? e.message : 'Erreur recherche'
console.error(`[useMediaServers] Erreur search ${serverId}:`, e)
throw e
} finally {
loading.value = false
}
}
function clearSearch() {
searchResults.value = null
searchQuery.value = ''
}
// Getters
function getServerById(id: string) {
return serversCache.value.get(id)
@@ -247,6 +282,11 @@ export function useMediaServers() {
getServerById,
getBrowseCached,
hasMore,
// Search
searchResults,
searchQuery,
searchServer,
clearSearch,
// Actions
fetchServers,
browseContainer,

View File

@@ -426,6 +426,16 @@ class PMOControlAPI {
);
}
/**
* Recherche dans un serveur media
* GET /api/control/servers/{serverId}/search?q={query}
*/
async searchServer(serverId: string, query: string): Promise<BrowseResponse> {
return this.request<BrowseResponse>(
`/servers/${encodeURIComponent(serverId)}/search?q=${encodeURIComponent(query)}`,
);
}
// ============================================================================
// SLEEP TIMER
// ============================================================================