From f5c6d2b576f0b6c050b704ef63cc38bbac406215 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 26 Dec 2025 12:30:08 -0600 Subject: [PATCH] Enhancement: Implement event-driven logo loading orchestration on Channels page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/src/App.jsx | 11 +-- frontend/src/components/LazyLogo.jsx | 20 ++++-- .../src/components/tables/ChannelsTable.jsx | 24 +++++-- .../src/components/tables/StreamsTable.jsx | 18 ++++- frontend/src/pages/Channels.jsx | 58 ++++++++++++--- frontend/src/store/auth.jsx | 4 +- frontend/src/store/logos.jsx | 72 +++++-------------- 7 files changed, 118 insertions(+), 89 deletions(-) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 3c7c3877..f22d408f 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 ( { 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 ( { +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) => { diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx index f3f4dc20..ef652864 100644 --- a/frontend/src/components/tables/StreamsTable.jsx +++ b/frontend/src/components/tables/StreamsTable.jsx @@ -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 diff --git a/frontend/src/pages/Channels.jsx b/frontend/src/pages/Channels.jsx index 26ed77fa..0fe4f7a7 100644 --- a/frontend/src/pages/Channels.jsx +++ b/frontend/src/pages/Channels.jsx @@ -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 ( - + ); } return ( - + { > - + - + @@ -64,7 +102,7 @@ const PageContent = () => { const ChannelsPage = () => { return ( - + ); }; diff --git a/frontend/src/store/auth.jsx b/frontend/src/store/auth.jsx index 7f92f669..8fe943b7 100644 --- a/frontend/src/store/auth.jsx +++ b/frontend/src/store/auth.jsx @@ -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); } diff --git a/frontend/src/store/logos.jsx b/frontend/src/store/logos.jsx index 4634f672..5843b113 100644 --- a/frontend/src/store/logos.jsx +++ b/frontend/src/store/logos.jsx @@ -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]);