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