mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-23 02:35:14 +00:00
Enhancement: Implement event-driven logo loading orchestration on Channels page
Introduce gated logo loading system that ensures logos render after both ChannelsTable and StreamsTable have completed their initial data fetch, preventing visual race conditions and ensuring proper paint order. Changes: - Add `allowLogoRendering` flag to logos store to gate logo fetching - Implement `onReady` callbacks in ChannelsTable and StreamsTable - Add orchestration logic in Channels.jsx to coordinate table readiness - Use double requestAnimationFrame to defer logo loading until after browser paint - Remove background logo loading from App.jsx (now page-specific) - Simplify fetchChannelAssignableLogos to reuse fetchAllLogos - Remove logos dependency from ChannelsTable columns to prevent re-renders This ensures visual loading order: Channels → EPG → Streams → Logos, regardless of network speed or data size, without timer-based hacks.
This commit is contained in:
parent
9c9cbab94c
commit
f5c6d2b576
7 changed files with 118 additions and 89 deletions
|
|
@ -19,7 +19,6 @@ import Users from './pages/Users';
|
|||
import LogosPage from './pages/Logos';
|
||||
import VODsPage from './pages/VODs';
|
||||
import useAuthStore from './store/auth';
|
||||
import useLogosStore from './store/logos';
|
||||
import FloatingVideo from './components/FloatingVideo';
|
||||
import { WebsocketProvider } from './WebSocket';
|
||||
import { Box, AppShell, MantineProvider } from '@mantine/core';
|
||||
|
|
@ -40,8 +39,6 @@ const defaultRoute = '/channels';
|
|||
|
||||
const App = () => {
|
||||
const [open, setOpen] = useState(true);
|
||||
const [backgroundLoadingStarted, setBackgroundLoadingStarted] =
|
||||
useState(false);
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
const setIsAuthenticated = useAuthStore((s) => s.setIsAuthenticated);
|
||||
const logout = useAuthStore((s) => s.logout);
|
||||
|
|
@ -81,11 +78,7 @@ const App = () => {
|
|||
const loggedIn = await initializeAuth();
|
||||
if (loggedIn) {
|
||||
await initData();
|
||||
// Start background logo loading after app is fully initialized (only once)
|
||||
if (!backgroundLoadingStarted) {
|
||||
setBackgroundLoadingStarted(true);
|
||||
useLogosStore.getState().startBackgroundLoading();
|
||||
}
|
||||
// Logos are now loaded at the end of initData, no need for background loading
|
||||
} else {
|
||||
await logout();
|
||||
}
|
||||
|
|
@ -96,7 +89,7 @@ const App = () => {
|
|||
};
|
||||
|
||||
checkAuth();
|
||||
}, [initializeAuth, initData, logout, backgroundLoadingStarted]);
|
||||
}, [initializeAuth, initData, logout]);
|
||||
|
||||
return (
|
||||
<MantineProvider
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Skeleton } from '@mantine/core';
|
||||
import useLogosStore from '../store/logos';
|
||||
import logo from '../images/logo.png'; // Default logo
|
||||
|
|
@ -16,15 +16,16 @@ const LazyLogo = ({
|
|||
}) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const fetchAttempted = useRef(new Set()); // Track which IDs we've already tried to fetch
|
||||
const fetchAttempted = useRef(new Set());
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
const logos = useLogosStore((s) => s.logos);
|
||||
const fetchLogosByIds = useLogosStore((s) => s.fetchLogosByIds);
|
||||
const allowLogoRendering = useLogosStore((s) => s.allowLogoRendering);
|
||||
|
||||
// Determine the logo source
|
||||
const logoData = logoId && logos[logoId];
|
||||
const logoSrc = logoData?.cache_url || fallbackSrc; // Only use cache URL if we have logo data
|
||||
const logoSrc = logoData?.cache_url || fallbackSrc;
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
|
|
@ -34,6 +35,9 @@ const LazyLogo = ({
|
|||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Don't start fetching until logo rendering is allowed
|
||||
if (!allowLogoRendering) return;
|
||||
|
||||
// If we have a logoId but no logo data, add it to the batch request queue
|
||||
if (
|
||||
logoId &&
|
||||
|
|
@ -44,7 +48,7 @@ const LazyLogo = ({
|
|||
isMountedRef.current
|
||||
) {
|
||||
setIsLoading(true);
|
||||
fetchAttempted.current.add(logoId); // Mark this ID as attempted
|
||||
fetchAttempted.current.add(logoId);
|
||||
logoRequestQueue.add(logoId);
|
||||
|
||||
// Clear existing timer and set new one to batch requests
|
||||
|
|
@ -82,7 +86,7 @@ const LazyLogo = ({
|
|||
setIsLoading(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [logoId, fetchLogosByIds, logoData]); // Include logoData to detect when it becomes available
|
||||
}, [logoId, fetchLogosByIds, logoData, allowLogoRendering]);
|
||||
|
||||
// Reset error state when logoId changes
|
||||
useEffect(() => {
|
||||
|
|
@ -91,8 +95,10 @@ const LazyLogo = ({
|
|||
}
|
||||
}, [logoId]);
|
||||
|
||||
// Show skeleton while loading
|
||||
if (isLoading && !logoData) {
|
||||
// Show skeleton if:
|
||||
// 1. Logo rendering is not allowed yet, OR
|
||||
// 2. We don't have logo data yet (regardless of loading state)
|
||||
if (logoId && (!allowLogoRendering || !logoData)) {
|
||||
return (
|
||||
<Skeleton
|
||||
height={style.maxHeight || 18}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import React, {
|
|||
useRef,
|
||||
} from 'react';
|
||||
import useChannelsStore from '../../store/channels';
|
||||
import useLogosStore from '../../store/logos';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import API from '../../api';
|
||||
import ChannelForm from '../forms/Channel';
|
||||
|
|
@ -225,7 +224,7 @@ const ChannelRowActions = React.memo(
|
|||
}
|
||||
);
|
||||
|
||||
const ChannelsTable = ({}) => {
|
||||
const ChannelsTable = ({ onReady }) => {
|
||||
// EPG data lookup
|
||||
const tvgsById = useEPGsStore((s) => s.tvgsById);
|
||||
const epgs = useEPGsStore((s) => s.epgs);
|
||||
|
|
@ -235,6 +234,7 @@ const ChannelsTable = ({}) => {
|
|||
const canDeleteChannelGroup = useChannelsStore(
|
||||
(s) => s.canDeleteChannelGroup
|
||||
);
|
||||
const hasSignaledReady = useRef(false);
|
||||
|
||||
/**
|
||||
* STORES
|
||||
|
|
@ -260,7 +260,6 @@ const ChannelsTable = ({}) => {
|
|||
const channels = useChannelsStore((s) => s.channels);
|
||||
const profiles = useChannelsStore((s) => s.profiles);
|
||||
const selectedProfileId = useChannelsStore((s) => s.selectedProfileId);
|
||||
const logos = useLogosStore((s) => s.logos);
|
||||
const [tablePrefs, setTablePrefs] = useLocalStorage('channel-table-prefs', {
|
||||
pageSize: 50,
|
||||
});
|
||||
|
|
@ -372,8 +371,10 @@ const ChannelsTable = ({}) => {
|
|||
});
|
||||
});
|
||||
|
||||
const channelsTableLength = (Object.keys(data).length > 0 || hasFetchedData.current) ?
|
||||
Object.keys(data).length : undefined;
|
||||
const channelsTableLength =
|
||||
Object.keys(data).length > 0 || hasFetchedData.current
|
||||
? Object.keys(data).length
|
||||
: undefined;
|
||||
|
||||
/**
|
||||
* Functions
|
||||
|
|
@ -420,7 +421,14 @@ const ChannelsTable = ({}) => {
|
|||
pageSize: pagination.pageSize,
|
||||
});
|
||||
setAllRowIds(ids);
|
||||
}, [pagination, sorting, debouncedFilters]);
|
||||
|
||||
// Signal ready after first successful data fetch
|
||||
// EPG data is already loaded in initData before this component mounts
|
||||
if (!hasSignaledReady.current && onReady) {
|
||||
hasSignaledReady.current = true;
|
||||
onReady();
|
||||
}
|
||||
}, [pagination, sorting, debouncedFilters, onReady]);
|
||||
|
||||
const stopPropagation = useCallback((e) => {
|
||||
e.stopPropagation();
|
||||
|
|
@ -907,8 +915,10 @@ const ChannelsTable = ({}) => {
|
|||
// columns from being recreated during drag operations (which causes infinite loops).
|
||||
// The column.size values are only used for INITIAL sizing - TanStack Table manages
|
||||
// the actual sizes through its own state after initialization.
|
||||
// Note: logos is intentionally excluded - LazyLogo components handle their own logo data
|
||||
// from the store, so we don't need to recreate columns when logos load.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[selectedProfileId, channelGroups, logos, theme]
|
||||
[selectedProfileId, channelGroups, theme]
|
||||
);
|
||||
|
||||
const renderHeaderCell = (header) => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
import React, { useEffect, useMemo, useCallback, useState } from 'react';
|
||||
import React, {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useCallback,
|
||||
useState,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import API from '../../api';
|
||||
import StreamForm from '../forms/Stream';
|
||||
import usePlaylistsStore from '../../store/playlists';
|
||||
|
|
@ -167,8 +173,9 @@ const StreamRowActions = ({
|
|||
);
|
||||
};
|
||||
|
||||
const StreamsTable = () => {
|
||||
const StreamsTable = ({ onReady }) => {
|
||||
const theme = useMantineTheme();
|
||||
const hasSignaledReady = useRef(false);
|
||||
|
||||
/**
|
||||
* useState
|
||||
|
|
@ -430,6 +437,12 @@ const StreamsTable = () => {
|
|||
|
||||
// Generate the string
|
||||
setPaginationString(`${startItem} to ${endItem} of ${result.count}`);
|
||||
|
||||
// Signal that initial data load is complete
|
||||
if (!hasSignaledReady.current && onReady) {
|
||||
hasSignaledReady.current = true;
|
||||
onReady();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching data:', error);
|
||||
}
|
||||
|
|
@ -442,6 +455,7 @@ const StreamsTable = () => {
|
|||
groupsLoaded,
|
||||
channelGroups,
|
||||
fetchChannelGroups,
|
||||
onReady,
|
||||
]);
|
||||
|
||||
// Bulk creation: create channels from selected streams asynchronously
|
||||
|
|
|
|||
|
|
@ -1,21 +1,59 @@
|
|||
import React from 'react';
|
||||
import React, { useCallback, useRef } from 'react';
|
||||
import ChannelsTable from '../components/tables/ChannelsTable';
|
||||
import StreamsTable from '../components/tables/StreamsTable';
|
||||
import { Box, } from '@mantine/core';
|
||||
import { Box } from '@mantine/core';
|
||||
import { Allotment } from 'allotment';
|
||||
import { USER_LEVELS } from '../constants';
|
||||
import useAuthStore from '../store/auth';
|
||||
import useLogosStore from '../store/logos';
|
||||
import useLocalStorage from '../hooks/useLocalStorage';
|
||||
import ErrorBoundary from '../components/ErrorBoundary';
|
||||
|
||||
const PageContent = () => {
|
||||
const authUser = useAuthStore((s) => s.user);
|
||||
const fetchChannelAssignableLogos = useLogosStore(
|
||||
(s) => s.fetchChannelAssignableLogos
|
||||
);
|
||||
const enableLogoRendering = useLogosStore((s) => s.enableLogoRendering);
|
||||
|
||||
const channelsReady = useRef(false);
|
||||
const streamsReady = useRef(false);
|
||||
const logosTriggered = useRef(false);
|
||||
|
||||
const [allotmentSizes, setAllotmentSizes] = useLocalStorage(
|
||||
'channels-splitter-sizes',
|
||||
[50, 50]
|
||||
);
|
||||
|
||||
// Only load logos when BOTH tables are ready
|
||||
const tryLoadLogos = useCallback(() => {
|
||||
if (
|
||||
channelsReady.current &&
|
||||
streamsReady.current &&
|
||||
!logosTriggered.current
|
||||
) {
|
||||
logosTriggered.current = true;
|
||||
// Use requestAnimationFrame to defer logo loading until after browser paint
|
||||
// This ensures EPG column is fully rendered before logos start loading
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
enableLogoRendering();
|
||||
fetchChannelAssignableLogos();
|
||||
});
|
||||
});
|
||||
}
|
||||
}, [fetchChannelAssignableLogos, enableLogoRendering]);
|
||||
|
||||
const handleChannelsReady = useCallback(() => {
|
||||
channelsReady.current = true;
|
||||
tryLoadLogos();
|
||||
}, [tryLoadLogos]);
|
||||
|
||||
const handleStreamsReady = useCallback(() => {
|
||||
streamsReady.current = true;
|
||||
tryLoadLogos();
|
||||
}, [tryLoadLogos]);
|
||||
|
||||
const handleSplitChange = (sizes) => {
|
||||
setAllotmentSizes(sizes);
|
||||
};
|
||||
|
|
@ -29,18 +67,18 @@ const PageContent = () => {
|
|||
if (authUser.user_level <= USER_LEVELS.STANDARD) {
|
||||
return (
|
||||
<Box style={{ padding: 10 }}>
|
||||
<ChannelsTable />
|
||||
<ChannelsTable onReady={handleChannelsReady} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box h={'100vh'} w={'100%'} display={'flex'}
|
||||
style={{ overflowX: 'auto' }}
|
||||
>
|
||||
<Box h={'100vh'} w={'100%'} display={'flex'} style={{ overflowX: 'auto' }}>
|
||||
<Allotment
|
||||
defaultSizes={allotmentSizes}
|
||||
h={'100%'} w={'100%'} miw={'600px'}
|
||||
h={'100%'}
|
||||
w={'100%'}
|
||||
miw={'600px'}
|
||||
className="custom-allotment"
|
||||
minSize={100}
|
||||
onChange={handleSplitChange}
|
||||
|
|
@ -48,12 +86,12 @@ const PageContent = () => {
|
|||
>
|
||||
<Box p={10} miw={'100px'} style={{ overflowX: 'auto' }}>
|
||||
<Box miw={'600px'}>
|
||||
<ChannelsTable />
|
||||
<ChannelsTable onReady={handleChannelsReady} />
|
||||
</Box>
|
||||
</Box>
|
||||
<Box p={10} miw={'100px'} style={{ overflowX: 'auto' }}>
|
||||
<Box miw={'600px'}>
|
||||
<StreamsTable />
|
||||
<StreamsTable onReady={handleStreamsReady} />
|
||||
</Box>
|
||||
</Box>
|
||||
</Allotment>
|
||||
|
|
@ -64,7 +102,7 @@ const PageContent = () => {
|
|||
const ChannelsPage = () => {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<PageContent/>
|
||||
<PageContent />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import useEPGsStore from './epgs';
|
|||
import useStreamProfilesStore from './streamProfiles';
|
||||
import useUserAgentsStore from './userAgents';
|
||||
import useUsersStore from './users';
|
||||
import useLogosStore from './logos';
|
||||
import API from '../api';
|
||||
import { USER_LEVELS } from '../constants';
|
||||
|
||||
|
|
@ -64,6 +63,9 @@ const useAuthStore = create((set, get) => ({
|
|||
if (user.user_level >= USER_LEVELS.ADMIN) {
|
||||
await Promise.all([useUsersStore.getState().fetchUsers()]);
|
||||
}
|
||||
|
||||
// Note: Logos are loaded after the Channels page tables finish loading
|
||||
// This is handled by the tables themselves signaling completion
|
||||
} catch (error) {
|
||||
console.error('Error initializing data:', error);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,16 +9,10 @@ const useLogosStore = create((set, get) => ({
|
|||
hasLoadedAll: false, // Track if we've loaded all logos
|
||||
hasLoadedChannelLogos: false, // Track if we've loaded channel logos
|
||||
error: null,
|
||||
allowLogoRendering: false, // Gate to prevent logo rendering until tables are ready
|
||||
|
||||
// Basic CRUD operations
|
||||
setLogos: (logos) => {
|
||||
set({
|
||||
logos: logos.reduce((acc, logo) => {
|
||||
acc[logo.id] = { ...logo };
|
||||
return acc;
|
||||
}, {}),
|
||||
});
|
||||
},
|
||||
// Enable logo rendering (call this after tables have loaded and painted)
|
||||
enableLogoRendering: () => set({ allowLogoRendering: true }),
|
||||
|
||||
addLogo: (newLogo) =>
|
||||
set((state) => {
|
||||
|
|
@ -73,6 +67,9 @@ const useLogosStore = create((set, get) => ({
|
|||
|
||||
// Smart loading methods
|
||||
fetchLogos: async (pageSize = 100) => {
|
||||
// Don't fetch if logo fetching is not allowed yet
|
||||
if (!get().allowLogoFetching) return [];
|
||||
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const response = await api.getLogos({ page_size: pageSize });
|
||||
|
|
@ -163,59 +160,28 @@ const useLogosStore = create((set, get) => ({
|
|||
},
|
||||
|
||||
fetchChannelAssignableLogos: async () => {
|
||||
const { backgroundLoading, hasLoadedChannelLogos, channelLogos } = get();
|
||||
const { hasLoadedChannelLogos, channelLogos } = get();
|
||||
|
||||
// Prevent concurrent calls
|
||||
if (
|
||||
backgroundLoading ||
|
||||
(hasLoadedChannelLogos && Object.keys(channelLogos).length > 0)
|
||||
) {
|
||||
// Return cached if already loaded
|
||||
if (hasLoadedChannelLogos && Object.keys(channelLogos).length > 0) {
|
||||
return Object.values(channelLogos);
|
||||
}
|
||||
|
||||
set({ backgroundLoading: true, error: null });
|
||||
try {
|
||||
// Load all channel logos (no special filtering needed - all Logo entries are for channels)
|
||||
const response = await api.getLogos({
|
||||
no_pagination: 'true', // Get all channel logos
|
||||
});
|
||||
// Fetch all logos and cache them as channel logos
|
||||
const logos = await get().fetchAllLogos();
|
||||
|
||||
// Handle both paginated and non-paginated responses
|
||||
const logos = Array.isArray(response) ? response : response.results || [];
|
||||
set({
|
||||
channelLogos: logos.reduce((acc, logo) => {
|
||||
acc[logo.id] = { ...logo };
|
||||
return acc;
|
||||
}, {}),
|
||||
hasLoadedChannelLogos: true,
|
||||
});
|
||||
|
||||
console.log(`Fetched ${logos.length} channel logos`);
|
||||
|
||||
// Store in both places, but this is intentional and only when specifically requested
|
||||
set({
|
||||
logos: {
|
||||
...get().logos, // Keep existing logos
|
||||
...logos.reduce((acc, logo) => {
|
||||
acc[logo.id] = { ...logo };
|
||||
return acc;
|
||||
}, {}),
|
||||
},
|
||||
channelLogos: logos.reduce((acc, logo) => {
|
||||
acc[logo.id] = { ...logo };
|
||||
return acc;
|
||||
}, {}),
|
||||
hasLoadedChannelLogos: true,
|
||||
backgroundLoading: false,
|
||||
});
|
||||
|
||||
return logos;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch channel logos:', error);
|
||||
set({
|
||||
error: 'Failed to load channel logos.',
|
||||
backgroundLoading: false,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
return logos;
|
||||
},
|
||||
|
||||
fetchLogosByIds: async (logoIds) => {
|
||||
if (!logoIds || logoIds.length === 0) return [];
|
||||
|
||||
try {
|
||||
// Filter out logos we already have
|
||||
const missingIds = logoIds.filter((id) => !get().logos[id]);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue