From 4b2d1dcf770d8733e3bdb3cfb00b0e2b585c4e32 Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Fri, 18 Jul 2025 17:05:39 -0500 Subject: [PATCH] Signed-off-by: Matt Bruce --- src/features/NewSongs/NewSongs.tsx | 6 +-- src/features/Queue/Queue.tsx | 7 ++-- src/hooks/useSongOperations.ts | 4 +- src/redux/authSlice.ts | 6 +-- src/redux/controllerSlice.ts | 19 ++++++---- src/redux/selectors.ts | 60 ++++++++++++++++++++++++------ src/utils/dataProcessing.ts | 2 + 7 files changed, 72 insertions(+), 32 deletions(-) diff --git a/src/features/NewSongs/NewSongs.tsx b/src/features/NewSongs/NewSongs.tsx index cab1297..418cafb 100644 --- a/src/features/NewSongs/NewSongs.tsx +++ b/src/features/NewSongs/NewSongs.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { InfiniteScrollList, SongItem } from '../../components/common'; import { useNewSongs } from '../../hooks'; import { useAppSelector } from '../../redux'; -import { selectNewSongs } from '../../redux'; +import { selectNewSongsArray } from '../../redux'; import { debugLog } from '../../utils/logger'; import type { Song } from '../../types'; @@ -15,8 +15,8 @@ const NewSongs: React.FC = () => { handleToggleFavorite, } = useNewSongs(); - const newSongs = useAppSelector(selectNewSongs); - const newSongsCount = Object.keys(newSongs).length; + const newSongsArray = useAppSelector(selectNewSongsArray); + const newSongsCount = newSongsArray.length; // Debug logging debugLog('NewSongs component - new songs count:', newSongsCount); diff --git a/src/features/Queue/Queue.tsx b/src/features/Queue/Queue.tsx index 98563d5..8120268 100644 --- a/src/features/Queue/Queue.tsx +++ b/src/features/Queue/Queue.tsx @@ -4,7 +4,7 @@ import { trash, reorderThreeOutline, reorderTwoOutline, playCircle } from 'ionic import { ActionButton } from '../../components/common'; import { useQueue } from '../../hooks'; import { useAppSelector } from '../../redux'; -import { selectQueue, selectPlayerState, selectIsAdmin, selectControllerName } from '../../redux'; +import { selectQueueLength, selectPlayerStateMemoized, selectIsAdmin, selectControllerName } from '../../redux'; import { PlayerState } from '../../types'; import { queueService } from '../../firebase/services'; import { debugLog } from '../../utils/logger'; @@ -23,11 +23,10 @@ const Queue: React.FC = () => { handleRemoveFromQueue, } = useQueue(); - const queue = useAppSelector(selectQueue); - const playerState = useAppSelector(selectPlayerState); + const queueCount = useAppSelector(selectQueueLength); + const playerState = useAppSelector(selectPlayerStateMemoized); const isAdmin = useAppSelector(selectIsAdmin); const controllerName = useAppSelector(selectControllerName); - const queueCount = Object.keys(queue).length; // Debug logging debugLog('Queue component - queue count:', queueCount); diff --git a/src/hooks/useSongOperations.ts b/src/hooks/useSongOperations.ts index 2d4f982..be2ecf9 100644 --- a/src/hooks/useSongOperations.ts +++ b/src/hooks/useSongOperations.ts @@ -1,13 +1,13 @@ import { useCallback } from 'react'; import { useAppSelector } from '../redux'; -import { selectControllerName, selectCurrentSinger } from '../redux'; +import { selectControllerName, selectCurrentSinger, selectQueueObject } from '../redux'; import { queueService, favoritesService } from '../firebase/services'; import type { Song, QueueItem } from '../types'; export const useSongOperations = () => { const controllerName = useAppSelector(selectControllerName); const currentSinger = useAppSelector(selectCurrentSinger); - const currentQueue = useAppSelector((state) => state.controller.data?.player?.queue || {}); + const currentQueue = useAppSelector(selectQueueObject); const addToQueue = useCallback(async (song: Song) => { if (!controllerName || !currentSinger) { diff --git a/src/redux/authSlice.ts b/src/redux/authSlice.ts index 162e316..dd22afb 100644 --- a/src/redux/authSlice.ts +++ b/src/redux/authSlice.ts @@ -72,8 +72,8 @@ export const selectAuth = (state: { auth: AuthState }) => state.auth.data; export const selectAuthLoading = (state: { auth: AuthState }) => state.auth.loading; export const selectAuthError = (state: { auth: AuthState }) => state.auth.error; export const selectIsAuthenticated = (state: { auth: AuthState }) => state.auth.data?.authenticated || false; -export const selectCurrentSinger = (state: { auth: AuthState }) => state.auth.data?.singer || ''; -export const selectIsAdmin = (state: { auth: AuthState }) => state.auth.data?.isAdmin || false; -export const selectControllerName = (state: { auth: AuthState }) => state.auth.data?.controller || ''; +export const selectCurrentSinger = (state: { auth: AuthState }) => state.auth.data?.singer ?? ''; +export const selectIsAdmin = (state: { auth: AuthState }) => Boolean(state.auth.data?.isAdmin); +export const selectControllerName = (state: { auth: AuthState }) => state.auth.data?.controller ?? ''; export default authSlice.reducer; \ No newline at end of file diff --git a/src/redux/controllerSlice.ts b/src/redux/controllerSlice.ts index 2a6637c..390c14b 100644 --- a/src/redux/controllerSlice.ts +++ b/src/redux/controllerSlice.ts @@ -151,14 +151,17 @@ export const selectControllerLoading = (state: { controller: ControllerState }) export const selectControllerError = (state: { controller: ControllerState }) => state.controller.error; export const selectLastUpdated = (state: { controller: ControllerState }) => state.controller.lastUpdated; +// Constants for empty objects to prevent new references +const EMPTY_OBJECT = {}; + // Selectors for specific data -export const selectSongs = (state: { controller: ControllerState }) => state.controller.data?.songs || {}; -export const selectQueue = (state: { controller: ControllerState }) => state.controller.data?.player?.queue || {}; -export const selectFavorites = (state: { controller: ControllerState }) => state.controller.data?.favorites || {}; -export const selectHistory = (state: { controller: ControllerState }) => state.controller.data?.history || {}; -export const selectTopPlayed = (state: { controller: ControllerState }) => state.controller.data?.topPlayed || {}; -export const selectNewSongs = (state: { controller: ControllerState }) => state.controller.data?.newSongs || {}; -export const selectSongList = (state: { controller: ControllerState }) => state.controller.data?.songList || {}; +export const selectSongs = (state: { controller: ControllerState }) => state.controller.data?.songs ?? EMPTY_OBJECT; +export const selectQueue = (state: { controller: ControllerState }) => state.controller.data?.player?.queue ?? EMPTY_OBJECT; +export const selectFavorites = (state: { controller: ControllerState }) => state.controller.data?.favorites ?? EMPTY_OBJECT; +export const selectHistory = (state: { controller: ControllerState }) => state.controller.data?.history ?? EMPTY_OBJECT; +export const selectTopPlayed = (state: { controller: ControllerState }) => state.controller.data?.topPlayed ?? EMPTY_OBJECT; +export const selectNewSongs = (state: { controller: ControllerState }) => state.controller.data?.newSongs ?? EMPTY_OBJECT; +export const selectSongList = (state: { controller: ControllerState }) => state.controller.data?.songList ?? EMPTY_OBJECT; export const selectPlayerState = (state: { controller: ControllerState }) => { const playerState = state.controller.data?.player?.state; @@ -173,6 +176,6 @@ export const selectPlayerState = (state: { controller: ControllerState }) => { return playerState; }; export const selectSettings = (state: { controller: ControllerState }) => state.controller.data?.player?.settings; -export const selectSingers = (state: { controller: ControllerState }) => state.controller.data?.player?.singers || {}; +export const selectSingers = (state: { controller: ControllerState }) => state.controller.data?.player?.singers ?? EMPTY_OBJECT; export default controllerSlice.reducer; \ No newline at end of file diff --git a/src/redux/selectors.ts b/src/redux/selectors.ts index b00c61a..abe79ca 100644 --- a/src/redux/selectors.ts +++ b/src/redux/selectors.ts @@ -1,5 +1,5 @@ import { createSelector } from '@reduxjs/toolkit'; -import type { RootState } from '../types'; +import type { RootState, QueueItem, Singer, Song } from '../types'; import { selectSongs, selectQueue, @@ -10,7 +10,8 @@ import { selectSongList, selectSingers, selectIsAdmin, - selectCurrentSinger + selectCurrentSinger, + selectPlayerState } from './index'; import { objectToArray, @@ -19,8 +20,7 @@ import { sortHistoryByDate, sortTopPlayedByCount, sortSongsByArtistAndTitle, - limitArray, - getQueueStats + limitArray } from '../utils/dataProcessing'; import { UI_CONSTANTS } from '../constants'; @@ -54,7 +54,18 @@ export const selectQueueArray = createSelector( export const selectQueueStats = createSelector( [selectQueue], - (queue) => getQueueStats(queue) + (queue) => { + const queueArray = Object.values(queue) as QueueItem[]; + const totalSongs = queueArray.length; + const singers = [...new Set(queueArray.map(item => item.singer.name))]; + const estimatedDuration = totalSongs * 3; // Rough estimate: 3 minutes per song + + return { + totalSongs, + singers, + estimatedDuration, + }; + } ); export const selectHistoryArray = createSelector( @@ -92,7 +103,7 @@ export const selectNewSongsArrayWithoutDisabled = createSelector( export const selectSingersArray = createSelector( [selectSingers], - (singers) => objectToArray(singers).sort((a, b) => a.name.localeCompare(b.name)) + (singers) => (objectToArray(singers) as Singer[]).sort((a, b) => a.name.localeCompare(b.name)) ); export const selectSongListArray = createSelector( @@ -104,7 +115,7 @@ export const selectArtistsArray = createSelector( [selectSongs], (songs) => { const artists = new Set(); - Object.values(songs).forEach(song => { + (Object.values(songs) as Song[]).forEach((song) => { if (song.artist) { artists.add(song.artist); } @@ -122,12 +133,12 @@ export const selectTopPlayedArray = createSelector( export const selectUserQueueItems = createSelector( [selectQueueArray, selectCurrentSinger], (queueArray, currentSinger) => - queueArray.filter(item => item.singer.name === currentSinger) + queueArray.filter((item: QueueItem) => item.singer.name === currentSinger) ); export const selectCanReorderQueue = createSelector( [selectIsAdmin], - (isAdmin) => isAdmin + (isAdmin) => Boolean(isAdmin) ); // Search-specific selectors @@ -151,15 +162,40 @@ export const selectSearchResultsWithoutDisabled = createSelector( // Queue-specific selectors export const selectQueueWithUserInfo = createSelector( [selectQueueArray, selectCurrentSinger], - (queueArray, currentSinger) => - queueArray.map(item => ({ + (queueArray, currentSinger) => { + // If no items, return empty array + if (queueArray.length === 0) return []; + + // If no current singer, return items without isCurrentUser flag + if (!currentSinger) { + return queueArray.map(item => ({ + ...item, + isCurrentUser: false, + })); + } + + // Map items and add isCurrentUser flag + return queueArray.map(item => ({ ...item, isCurrentUser: item.singer.name === currentSinger, - })) + })); + } ); // Memoized selector for queue length to prevent unnecessary re-renders export const selectQueueLength = createSelector( [selectQueue], (queue) => Object.keys(queue).length +); + +// Memoized selector for queue object to prevent unnecessary re-renders +export const selectQueueObject = createSelector( + [selectQueue], + (queue) => Object.keys(queue).length > 0 ? { ...queue } : {} +); + +// Memoized selector for player state to prevent unnecessary re-renders +export const selectPlayerStateMemoized = createSelector( + [selectPlayerState], + (playerState) => playerState ? { ...playerState } : null ); \ No newline at end of file diff --git a/src/utils/dataProcessing.ts b/src/utils/dataProcessing.ts index 1480c83..fca48b2 100644 --- a/src/utils/dataProcessing.ts +++ b/src/utils/dataProcessing.ts @@ -5,6 +5,8 @@ import type { Song, QueueItem, TopPlayed } from '../types'; export const objectToArray = ( obj: Record ): T[] => { + if (Object.keys(obj).length === 0) return []; + return Object.entries(obj).map(([key, item]) => ({ ...item, key,