Files
pmomusic/pmoapp/webapp/src/components/pmocontrol/MediaBrowser.vue
Eric Coissac 0dab3077cf feat: unify search state and dynamic container routing
Refactor search handling across the frontend and backend to use a unified reactive state and dynamic container ID generation. The frontend now leverages a centralized `useMediaServers` composable for search queries and results, while the backend computes container IDs dynamically from source entries instead of using hardcoded values. Bumps version to 0.3.52.
2026-06-11 22:17:53 +02:00

449 lines
12 KiB
Vue

<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useMediaServers } from "@/composables/useMediaServers";
import { useRenderers } from "@/composables/useRenderers";
import { useUIStore } from "@/stores/ui";
import Breadcrumb from "./Breadcrumb.vue";
import ContainerItem from "./ContainerItem.vue";
import MediaItem from "./MediaItem.vue";
import { Loader2, Search, X } from "lucide-vue-next";
const props = defineProps<{
serverId: string;
containerId: string;
}>();
const {
getBrowseCached,
browseContainer,
loadMoreBrowse,
hasMore,
currentPath: breadcrumbPath,
loading,
loadingMore,
error,
searchServer,
} = useMediaServers();
const searchInput = ref('');
async function handleSearch() {
if (searchInput.value.trim()) {
const virtualId = await searchServer(props.serverId, searchInput.value.trim());
if (virtualId) {
emit("navigate", virtualId);
}
}
}
function handleClearSearch() {
searchInput.value = '';
}
const { playContent, addToQueue, attachAndPlayPlaylist, attachPlaylist } =
useRenderers();
const uiStore = useUIStore();
const isRefreshing = ref(false);
const sentinelRef = ref<HTMLElement | null>(null);
let observer: IntersectionObserver | null = null;
const browseData = computed(() =>
getBrowseCached(props.serverId, props.containerId),
);
const containers = computed(
() => browseData.value?.entries.filter((e) => e.is_container) || [],
);
const items = computed(
() => browseData.value?.entries.filter((e) => !e.is_container) || [],
);
const canLoadMore = computed(() => hasMore(props.serverId, props.containerId));
function setupObserver() {
if (observer) observer.disconnect();
observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting && canLoadMore.value && !loadingMore.value) {
loadMoreBrowse(props.serverId, props.containerId);
}
},
{ threshold: 0.1 },
);
if (sentinelRef.value) observer.observe(sentinelRef.value);
}
onMounted(() => setupObserver());
onBeforeUnmount(() => observer?.disconnect());
watch(sentinelRef, (el) => {
if (el) setupObserver();
});
// Charger le container au montage et quand containerId change
watch(
() => props.containerId,
async (newContainerId) => {
if (newContainerId) {
await browseContainer(props.serverId, newContainerId);
}
},
{ immediate: true },
);
// Recharger si le cache est invalidé (ContainersUpdated SSE)
watch(
() => browseData.value,
async (data) => {
if (
!data &&
props.containerId &&
!loading.value &&
!isRefreshing.value
) {
console.log(
`[MediaBrowser] Cache invalidé pour ${props.serverId}/${props.containerId}, rechargement...`,
);
isRefreshing.value = true;
await browseContainer(props.serverId, props.containerId, false);
isRefreshing.value = false;
}
},
);
const emit = defineEmits<{
navigate: [containerId: string];
}>();
function handleNavigate(containerId: string) {
emit("navigate", containerId);
}
function handleBrowseContainer(containerId: string) {
emit("navigate", containerId);
}
// Actions handlers pour les containers (playlists/albums)
async function handlePlayContainer(containerId: string, rendererId: string) {
try {
await attachAndPlayPlaylist(rendererId, props.serverId, containerId);
uiStore.notifySuccess("Lecture de la playlist démarrée !");
} catch (err) {
const message = err instanceof Error ? err.message : "Erreur inconnue";
uiStore.notifyError(
`Erreur lors de la lecture de la playlist: ${message}`,
);
}
}
async function handleQueueContainer(containerId: string, rendererId: string) {
try {
await attachPlaylist(rendererId, props.serverId, containerId);
uiStore.notifySuccess("Playlist attachée à la queue !");
} catch (err) {
const message = err instanceof Error ? err.message : "Erreur inconnue";
uiStore.notifyError(
`Erreur lors de l'ajout de la playlist: ${message}`,
);
}
}
// Actions handlers pour les items (tracks)
async function handlePlayItem(itemId: string, rendererId: string) {
try {
await playContent(rendererId, props.serverId, itemId);
uiStore.notifySuccess("Lecture démarrée !");
} catch (err) {
const message = err instanceof Error ? err.message : "Erreur inconnue";
uiStore.notifyError(`Erreur lors de la lecture: ${message}`);
}
}
async function handleQueueItem(itemId: string, rendererId: string) {
try {
await addToQueue(rendererId, props.serverId, itemId);
uiStore.notifySuccess("Ajouté à la queue !");
} catch (err) {
const message = err instanceof Error ? err.message : "Erreur inconnue";
uiStore.notifyError(`Erreur lors de l'ajout à la queue: ${message}`);
}
}
</script>
<template>
<div class="media-browser">
<!-- Breadcrumb -->
<Breadcrumb
:items="breadcrumbPath"
:serverId="serverId"
@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"
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" />
<p>Chargement...</p>
</div>
<!-- Error state -->
<div v-else-if="error" class="browser-error">
<p class="error-message">{{ error }}</p>
<button
class="btn btn-secondary"
@click="browseContainer(serverId, containerId, false)"
>
Réessayer
</button>
</div>
<!-- Content -->
<div v-else class="browser-content">
<!-- Containers section -->
<div v-if="containers.length" class="browser-section">
<h3 class="section-title">Dossiers et playlists</h3>
<div class="entries-list">
<ContainerItem
v-for="container in containers"
:key="container.id"
:entry="container"
:server-id="serverId"
@browse="handleBrowseContainer"
@play-now="handlePlayContainer"
@add-to-queue="handleQueueContainer"
/>
</div>
</div>
<!-- Items section -->
<div v-if="items.length" class="browser-section">
<h3 class="section-title">Pistes</h3>
<div class="entries-list">
<MediaItem
v-for="item in items"
:key="item.id"
:entry="item"
:server-id="serverId"
@play-now="handlePlayItem"
@add-to-queue="handleQueueItem"
/>
</div>
</div>
<!-- Empty state -->
<div
v-if="!containers.length && !items.length"
class="browser-empty"
>
<p>{{ 'Ce dossier est vide' }}</p>
</div>
<!-- Sentinel infinite scroll -->
<div ref="sentinelRef" class="scroll-sentinel" />
<!-- Spinner load more -->
<div v-if="loadingMore" class="load-more-spinner">
<Loader2 :size="20" class="spinner" />
</div>
</div>
</div>
</template>
<style scoped>
.media-browser {
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
height: 100%;
}
/* Loading */
.browser-loading {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--spacing-md);
color: var(--color-text-secondary);
}
.spinner {
animation: spin 1s linear infinite;
}
/* @keyframes spin is now global in pmocontrol.css */
/* Error */
.browser-error {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--spacing-md);
}
.error-message {
font-size: var(--text-base);
color: var(--status-offline);
margin: 0;
}
/* Content */
.browser-content {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: var(--spacing-xl);
padding-right: var(--spacing-xs);
}
.browser-section {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.section-title {
font-size: var(--text-lg);
font-weight: 600;
color: var(--color-text);
margin: 0;
padding-bottom: var(--spacing-sm);
border-bottom: 1px solid var(--color-border);
}
.entries-list {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
/* Empty state */
.browser-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-tertiary);
font-size: var(--text-base);
padding: var(--spacing-xl);
}
.scroll-sentinel {
height: 1px;
}
.load-more-spinner {
display: flex;
justify-content: center;
padding: var(--spacing-md);
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;
}
.browser-content::-webkit-scrollbar-track {
background: var(--color-bg-secondary);
border-radius: var(--radius-full);
}
.browser-content::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: var(--radius-full);
}
.browser-content::-webkit-scrollbar-thumb:hover {
background: var(--color-text-tertiary);
}
</style>