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:
SergeantPanda 2025-12-26 12:30:08 -06:00
parent 9c9cbab94c
commit f5c6d2b576
7 changed files with 118 additions and 89 deletions

View file

@ -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

View file

@ -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}

View file

@ -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) => {

View file

@ -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

View file

@ -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>
);
};

View file

@ -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);
}

View file

@ -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]);