2025-12-04 22:43:01 +01:00
|
|
|
<script setup lang="ts">
|
2026-03-24 15:24:20 +01:00
|
|
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
2026-01-11 15:44:37 +01:00
|
|
|
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 } from "lucide-vue-next";
|
2025-12-04 22:43:01 +01:00
|
|
|
|
|
|
|
|
const props = defineProps<{
|
2026-01-11 15:44:37 +01:00
|
|
|
serverId: string;
|
|
|
|
|
containerId: string;
|
|
|
|
|
}>();
|
2025-12-04 22:43:01 +01:00
|
|
|
|
2025-12-05 23:37:21 +01:00
|
|
|
const {
|
2026-01-11 15:44:37 +01:00
|
|
|
getBrowseCached,
|
|
|
|
|
browseContainer,
|
2026-03-24 15:24:20 +01:00
|
|
|
loadMoreBrowse,
|
|
|
|
|
hasMore,
|
2026-01-11 15:44:37 +01:00
|
|
|
currentPath: breadcrumbPath,
|
|
|
|
|
loading,
|
2026-03-24 15:24:20 +01:00
|
|
|
loadingMore,
|
2026-01-11 15:44:37 +01:00
|
|
|
error,
|
|
|
|
|
} = useMediaServers();
|
2025-12-05 23:37:21 +01:00
|
|
|
|
2026-01-11 15:44:37 +01:00
|
|
|
const { playContent, addToQueue, attachAndPlayPlaylist, attachPlaylist } =
|
|
|
|
|
useRenderers();
|
|
|
|
|
const uiStore = useUIStore();
|
2025-12-04 22:43:01 +01:00
|
|
|
|
2026-01-11 15:44:37 +01:00
|
|
|
const isRefreshing = ref(false);
|
2026-03-24 15:24:20 +01:00
|
|
|
const sentinelRef = ref<HTMLElement | null>(null);
|
|
|
|
|
let observer: IntersectionObserver | null = null;
|
2025-12-05 07:08:03 +01:00
|
|
|
|
2025-12-04 22:43:01 +01:00
|
|
|
const browseData = computed(() =>
|
2026-01-11 15:44:37 +01:00
|
|
|
getBrowseCached(props.serverId, props.containerId),
|
|
|
|
|
);
|
2025-12-04 22:43:01 +01:00
|
|
|
|
2026-01-11 15:44:37 +01:00
|
|
|
const containers = computed(
|
|
|
|
|
() => browseData.value?.entries.filter((e) => e.is_container) || [],
|
|
|
|
|
);
|
2025-12-04 22:43:01 +01:00
|
|
|
|
2026-01-11 15:44:37 +01:00
|
|
|
const items = computed(
|
|
|
|
|
() => browseData.value?.entries.filter((e) => !e.is_container) || [],
|
|
|
|
|
);
|
2025-12-04 22:43:01 +01:00
|
|
|
|
2026-03-24 15:24:20 +01:00
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
|
2025-12-04 22:43:01 +01:00
|
|
|
// Charger le container au montage et quand containerId change
|
|
|
|
|
watch(
|
2026-01-11 15:44:37 +01:00
|
|
|
() => props.containerId,
|
|
|
|
|
async (newContainerId) => {
|
|
|
|
|
if (newContainerId) {
|
|
|
|
|
await browseContainer(props.serverId, newContainerId);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
{ immediate: true },
|
|
|
|
|
);
|
2025-12-04 22:43:01 +01:00
|
|
|
|
2026-03-24 15:24:20 +01:00
|
|
|
// Recharger si le cache est invalidé (ContainersUpdated SSE)
|
2025-12-05 07:08:03 +01:00
|
|
|
watch(
|
2026-01-11 15:44:37 +01:00
|
|
|
() => browseData.value,
|
2026-01-13 19:01:16 +01:00
|
|
|
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;
|
2025-12-05 07:08:03 +01:00
|
|
|
}
|
2026-01-11 15:44:37 +01:00
|
|
|
},
|
|
|
|
|
);
|
2025-12-05 07:08:03 +01:00
|
|
|
|
2025-12-04 22:43:01 +01:00
|
|
|
const emit = defineEmits<{
|
2026-01-11 15:44:37 +01:00
|
|
|
navigate: [containerId: string];
|
|
|
|
|
}>();
|
2025-12-04 22:43:01 +01:00
|
|
|
|
|
|
|
|
function handleNavigate(containerId: string) {
|
2026-01-11 15:44:37 +01:00
|
|
|
emit("navigate", containerId);
|
2025-12-04 22:43:01 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleBrowseContainer(containerId: string) {
|
2026-01-11 15:44:37 +01:00
|
|
|
emit("navigate", containerId);
|
2025-12-04 22:43:01 +01:00
|
|
|
}
|
|
|
|
|
|
2025-12-05 07:08:03 +01:00
|
|
|
// Actions handlers pour les containers (playlists/albums)
|
|
|
|
|
async function handlePlayContainer(containerId: string, rendererId: string) {
|
2026-01-11 15:44:37 +01:00
|
|
|
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}`,
|
|
|
|
|
);
|
|
|
|
|
}
|
2025-12-04 22:43:01 +01:00
|
|
|
}
|
|
|
|
|
|
2025-12-05 07:08:03 +01:00
|
|
|
async function handleQueueContainer(containerId: string, rendererId: string) {
|
2026-01-11 15:44:37 +01:00
|
|
|
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}`,
|
|
|
|
|
);
|
|
|
|
|
}
|
2025-12-04 22:43:01 +01:00
|
|
|
}
|
|
|
|
|
|
2025-12-05 07:08:03 +01:00
|
|
|
// Actions handlers pour les items (tracks)
|
|
|
|
|
async function handlePlayItem(itemId: string, rendererId: string) {
|
2026-01-11 15:44:37 +01:00
|
|
|
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}`);
|
|
|
|
|
}
|
2025-12-05 07:08:03 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function handleQueueItem(itemId: string, rendererId: string) {
|
2026-01-11 15:44:37 +01:00
|
|
|
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}`);
|
|
|
|
|
}
|
2025-12-04 22:43:01 +01:00
|
|
|
}
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<template>
|
2026-01-11 15:44:37 +01:00
|
|
|
<div class="media-browser">
|
|
|
|
|
<!-- Breadcrumb -->
|
|
|
|
|
<Breadcrumb
|
|
|
|
|
:items="breadcrumbPath"
|
|
|
|
|
:serverId="serverId"
|
|
|
|
|
@navigate="handleNavigate"
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
<!-- Loading state -->
|
|
|
|
|
<div v-if="loading" class="browser-loading">
|
|
|
|
|
<Loader2 :size="32" class="spinner" />
|
|
|
|
|
<p>Chargement...</p>
|
2025-12-04 22:43:01 +01:00
|
|
|
</div>
|
2026-01-11 15:44:37 +01:00
|
|
|
|
|
|
|
|
<!-- 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>
|
2025-12-04 22:43:01 +01:00
|
|
|
</div>
|
|
|
|
|
|
2026-01-11 15:44:37 +01:00
|
|
|
<!-- 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>
|
2026-03-24 15:24:20 +01:00
|
|
|
|
|
|
|
|
<!-- 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>
|
2026-01-11 15:44:37 +01:00
|
|
|
</div>
|
2025-12-04 22:43:01 +01:00
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
.media-browser {
|
2026-01-11 15:44:37 +01:00
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
gap: var(--spacing-lg);
|
|
|
|
|
height: 100%;
|
2025-12-04 22:43:01 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Loading */
|
|
|
|
|
.browser-loading {
|
2026-01-11 15:44:37 +01:00
|
|
|
flex: 1;
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
gap: var(--spacing-md);
|
|
|
|
|
color: var(--color-text-secondary);
|
2025-12-04 22:43:01 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.spinner {
|
2026-01-11 15:44:37 +01:00
|
|
|
animation: spin 1s linear infinite;
|
2025-12-04 22:43:01 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@keyframes spin {
|
2026-01-11 15:44:37 +01:00
|
|
|
from {
|
|
|
|
|
transform: rotate(0deg);
|
|
|
|
|
}
|
|
|
|
|
to {
|
|
|
|
|
transform: rotate(360deg);
|
|
|
|
|
}
|
2025-12-04 22:43:01 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Error */
|
|
|
|
|
.browser-error {
|
2026-01-11 15:44:37 +01:00
|
|
|
flex: 1;
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
gap: var(--spacing-md);
|
2025-12-04 22:43:01 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.error-message {
|
2026-01-11 15:44:37 +01:00
|
|
|
font-size: var(--text-base);
|
|
|
|
|
color: var(--status-offline);
|
|
|
|
|
margin: 0;
|
2025-12-04 22:43:01 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Content */
|
|
|
|
|
.browser-content {
|
2026-01-11 15:44:37 +01:00
|
|
|
flex: 1;
|
|
|
|
|
overflow-y: auto;
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
gap: var(--spacing-xl);
|
|
|
|
|
padding-right: var(--spacing-xs);
|
2025-12-04 22:43:01 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.browser-section {
|
2026-01-11 15:44:37 +01:00
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
gap: var(--spacing-md);
|
2025-12-04 22:43:01 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.section-title {
|
2026-01-11 15:44:37 +01:00
|
|
|
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);
|
2025-12-04 22:43:01 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.entries-list {
|
2026-01-11 15:44:37 +01:00
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
gap: var(--spacing-xs);
|
2025-12-04 22:43:01 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Empty state */
|
|
|
|
|
.browser-empty {
|
2026-01-11 15:44:37 +01:00
|
|
|
flex: 1;
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
color: var(--color-text-tertiary);
|
|
|
|
|
font-size: var(--text-base);
|
|
|
|
|
padding: var(--spacing-xl);
|
2025-12-04 22:43:01 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-24 15:24:20 +01:00
|
|
|
.scroll-sentinel {
|
|
|
|
|
height: 1px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.load-more-spinner {
|
|
|
|
|
display: flex;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
padding: var(--spacing-md);
|
|
|
|
|
color: var(--color-text-secondary);
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-04 22:43:01 +01:00
|
|
|
/* Scrollbar styling */
|
|
|
|
|
.browser-content::-webkit-scrollbar {
|
2026-01-11 15:44:37 +01:00
|
|
|
width: 6px;
|
2025-12-04 22:43:01 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.browser-content::-webkit-scrollbar-track {
|
2026-01-11 15:44:37 +01:00
|
|
|
background: var(--color-bg-secondary);
|
|
|
|
|
border-radius: var(--radius-full);
|
2025-12-04 22:43:01 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.browser-content::-webkit-scrollbar-thumb {
|
2026-01-11 15:44:37 +01:00
|
|
|
background: var(--color-border);
|
|
|
|
|
border-radius: var(--radius-full);
|
2025-12-04 22:43:01 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.browser-content::-webkit-scrollbar-thumb:hover {
|
2026-01-11 15:44:37 +01:00
|
|
|
background: var(--color-text-tertiary);
|
2025-12-04 22:43:01 +01:00
|
|
|
}
|
|
|
|
|
</style>
|