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.
449 lines
12 KiB
Vue
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>
|