diff --git a/src/components/common/SelectSinger.tsx b/src/components/common/SelectSinger.tsx index e193e88..ebd1f59 100644 --- a/src/components/common/SelectSinger.tsx +++ b/src/components/common/SelectSinger.tsx @@ -10,6 +10,7 @@ import { useAppSelector } from '../../redux'; import { selectSingersArray, selectControllerName, selectQueueObject } from '../../redux'; import { queueService } from '../../firebase/services'; import { useToast } from '../../hooks/useToast'; +import { useActions } from '../../hooks'; import { ModalHeader } from './ModalHeader'; import { NumberDisplay } from './NumberDisplay'; import { SongInfoDisplay } from './SongItem'; @@ -29,6 +30,7 @@ const SelectSinger: React.FC = ({ isOpen, onClose, song }) => const showSuccess = toast?.showSuccess; const showError = toast?.showError; const [isLoading, setIsLoading] = useState(false); + const { handleAddToQueue } = useActions(); const handleSelectSinger = async (singer: Singer) => { if (!controllerName) { @@ -38,23 +40,7 @@ const SelectSinger: React.FC = ({ isOpen, onClose, song }) => setIsLoading(true); try { - // Calculate the next order by finding the highest order value and adding 1 - const queueItems = Object.values(currentQueue) as QueueItem[]; - const maxOrder = queueItems.length > 0 - ? Math.max(...queueItems.map(item => item.order || 0)) - : 0; - const nextOrder = maxOrder + 1; - - const queueItem: Omit = { - order: nextOrder, - singer: { - name: singer.name, - lastLogin: singer.lastLogin || '', - }, - song: song, - }; - - await queueService.addToQueue(controllerName, queueItem); + await handleAddToQueue(song, singer); if (showSuccess) showSuccess(`${song.title} added to queue for ${singer.name}`); onClose(); } catch (error) { diff --git a/src/components/common/SongItem.tsx b/src/components/common/SongItem.tsx index f1d53dd..bff945d 100644 --- a/src/components/common/SongItem.tsx +++ b/src/components/common/SongItem.tsx @@ -2,7 +2,7 @@ import React, { useMemo, useCallback } from 'react'; import { IonItem, IonLabel } from '@ionic/react'; import ActionButton from './ActionButton'; import { useAppSelector } from '../../redux'; -import { selectQueue, selectFavorites } from '../../redux'; +import { selectQueue, selectFavorites, selectCurrentSinger } from '../../redux'; import { useActions } from '../../hooks/useActions'; import { useModal } from '../../hooks/useModalContext'; import { debugLog } from '../../utils/logger'; @@ -209,6 +209,7 @@ const SongItem: React.FC = React.memo(({ // Get current state from Redux const queue = useAppSelector(selectQueue); const favorites = useAppSelector(selectFavorites); + const currentSingerName = useAppSelector(selectCurrentSinger); // Get unified action handlers const { handleAddToQueue, handleToggleFavorite, handleRemoveFromQueue } = useActions(); @@ -255,8 +256,15 @@ const SongItem: React.FC = React.memo(({ // Memoized handler functions for performance const handleAddToQueueClick = useCallback(async () => { - await handleAddToQueue(song); - }, [handleAddToQueue, song]); + // Find the current singer object from the queue or create a minimal one + let singer = undefined; + if (currentSingerName) { + // Try to find a matching singer in the queue (for lastLogin) + const queueSingers = (Object.values(queue) as QueueItem[]).map(item => item.singer); + singer = queueSingers.find(s => s.name === currentSingerName) || { name: currentSingerName, lastLogin: '' }; + } + await handleAddToQueue(song, singer); + }, [handleAddToQueue, song, currentSingerName, queue]); const handleToggleFavoriteClick = useCallback(async () => { await handleToggleFavorite(song); diff --git a/src/firebase/services.ts b/src/firebase/services.ts index 0e95f88..00fe50c 100644 --- a/src/firebase/services.ts +++ b/src/firebase/services.ts @@ -279,51 +279,86 @@ export const playerService = { // History operations export const historyService = { - // Add song to history + // Add song to history (by path, with count) addToHistory: async (controllerName: string, song: Omit) => { const historyRef = ref(database, `controllers/${controllerName}/history`); const historySnapshot = await get(historyRef); const currentHistory = historySnapshot.exists() ? historySnapshot.val() : {}; - - // Find the next available sequential key - const nextKey = findNextSequentialKey(Object.keys(currentHistory)); - debugLog('addToHistory - existing keys:', Object.keys(currentHistory)); - debugLog('addToHistory - next key:', nextKey); - - const newHistoryRef = ref(database, `controllers/${controllerName}/history/${nextKey}`); - await set(newHistoryRef, song); - return { key: nextKey.toString() }; + const now = Date.now(); + // Find if song with same path exists + const existingEntry = Object.entries(currentHistory).find( + ([, item]) => typeof item === 'object' && item !== null && 'path' in item && (item as { path: string }).path === song.path + ); + if (existingEntry) { + const [key, item] = existingEntry; + await update(ref(database, `controllers/${controllerName}/history/${key}`), { + count: (item.count || 1) + 1, + lastPlayed: now, + }); + // Move this entry to the most recent by updating lastPlayed + // (No need to reorder keys, just use lastPlayed for recency) + // Cap size after update + } else { + // Add new entry with count: 1 and lastPlayed + const nextKey = findNextSequentialKey(Object.keys(currentHistory)); + await set(ref(database, `controllers/${controllerName}/history/${nextKey}`), { + ...song, + count: 1, + lastPlayed: now, + }); + } + // Cap history size (remove oldest by lastPlayed if over 250) + const updatedSnapshot = await get(historyRef); + const updatedHistory = updatedSnapshot.exists() ? updatedSnapshot.val() : {}; + const entries = Object.entries(updatedHistory); + if (entries.length > 250) { + // Find the oldest entry by lastPlayed using a for loop for type safety + let oldestKey: string | null = null; + let oldestTime: number | null = null; + for (const [key, item] of entries) { + if (typeof item === 'object' && item !== null && 'lastPlayed' in item && typeof (item as { lastPlayed?: number }).lastPlayed === 'number') { + const lastPlayed = (item as { lastPlayed: number }).lastPlayed; + if (oldestTime === null || lastPlayed < oldestTime) { + oldestTime = lastPlayed; + oldestKey = key; + } + } + } + if (oldestKey) { + await remove(ref(database, `controllers/${controllerName}/history/${oldestKey}`)); + } + } }, - // Remove song from history - removeFromHistory: async (controllerName: string, historyItemKey: string) => { + // Remove song from history (by path, with count logic) + removeFromHistory: async (controllerName: string, songPath: string) => { const historyRef = ref(database, `controllers/${controllerName}/history`); const historySnapshot = await get(historyRef); - if (!historySnapshot.exists()) { throw new Error('History not found'); } - const history = historySnapshot.val(); - debugLog('removeFromHistory - original history:', history); - - // Find the item to remove and get its key - const itemToRemove = Object.entries(history).find(([key, item]) => - key === historyItemKey && item + // Find entry by path + const existingEntry = Object.entries(history).find( + ([, item]) => typeof item === 'object' && item !== null && 'path' in item && (item as { path: string }).path === songPath ); - - if (!itemToRemove) { + if (!existingEntry) { throw new Error('History item not found'); } - - const [removedKey, removedItem] = itemToRemove; - debugLog('removeFromHistory - removing item:', removedItem, 'with key:', removedKey); - - // Use utility function to create shift-down updates - const updates = shiftDownAfterDeletion(history, removedKey, 'history item'); - - // Apply all updates atomically - await update(historyRef, updates); + const [key, item] = existingEntry; + let count = 1; + if (typeof item === 'object' && item !== null && 'count' in item) { + count = (item as { count?: number }).count ?? 1; + } + const now = Date.now(); + if (count > 1) { + await update(ref(database, `controllers/${controllerName}/history/${key}`), { + count: count - 1, + lastPlayed: now, + }); + } else { + await remove(ref(database, `controllers/${controllerName}/history/${key}`)); + } }, // Listen to history changes diff --git a/src/hooks/useActions.ts b/src/hooks/useActions.ts index 66f4b34..2082579 100644 --- a/src/hooks/useActions.ts +++ b/src/hooks/useActions.ts @@ -2,13 +2,14 @@ import { useState, useCallback } from 'react'; import { useAppSelector, useAppDispatch } from '../redux'; import { selectControllerName, selectPlayerStateMemoized, selectIsAdmin } from '../redux'; import { reorderQueueAsync } from '../redux/queueSlice'; +import { addToQueue as addToQueueThunk } from '../redux/queueSlice'; import { useSongOperations } from './useSongOperations'; import { useToast } from './useToast'; import { useDisabledSongs } from './useDisabledSongs'; import { historyService } from '../firebase/services'; import { debugLog } from '../utils/logger'; import { PlayerState } from '../types'; -import type { Song, QueueItem } from '../types'; +import type { Song, QueueItem, Singer } from '../types'; export type QueueMode = 'delete' | 'reorder'; @@ -31,14 +32,56 @@ export const useActions = () => { const canDeleteFirstItem = isAdmin && (playerState?.state === PlayerState.stopped || playerState?.state === PlayerState.paused); // Only allow deleting first item if not playing // Song operations - const handleAddToQueue = useCallback(async (song: Song) => { + const handleAddToQueue = useCallback(async (song: Song, singerOverride?: Singer) => { try { - await addToQueue(song); + // If a singer is provided, use it; otherwise, use the current singer from state + let singer = singerOverride; + if (!singer) { + // Try to get from Redux state + const state = (window as unknown as { store?: { getState?: () => unknown } }).store?.getState?.(); + if (state && typeof state === 'object' && 'auth' in state) { + const authState = (state as { auth?: { data?: { singer?: Singer } } }).auth; + if (authState && authState.data && authState.data.singer) { + singer = authState.data.singer; + } + } + } + if (!singer) throw new Error('No singer specified'); + // Calculate order + const state = (window as unknown as { store?: { getState?: () => unknown } }).store?.getState?.(); + let queueItems: Array = []; + if (state && typeof state === 'object' && 'queue' in state) { + const queueState = (state as { queue?: { data?: Record } }).queue; + if (queueState && queueState.data && typeof queueState.data === 'object') { + queueItems = Object.entries(queueState.data).map(([key, item]) => ({ ...item, key })); + } + } + const maxOrder = queueItems.length > 0 + ? Math.max(...queueItems.map(item => item.order || 0)) + : 0; + const nextOrder = maxOrder + 1; + const queueItem: Omit = { + order: nextOrder, + singer: { + name: singer.name, + lastLogin: singer.lastLogin || '', + }, + song: song, + }; + await dispatch(addToQueueThunk({ controllerName, queueItem })).unwrap(); + if (controllerName) { + try { + await historyService.addToHistory(controllerName, song); + if (showSuccess) showSuccess('Song added to history'); + } catch { + if (showError) showError('Failed to add song to history'); + } + } if (showSuccess) showSuccess('Song added to queue'); } catch { if (showError) showError('Failed to add song to queue'); } - }, [addToQueue, showSuccess, showError]); + }, [addToQueue, showSuccess, showError, controllerName]); // Utility to fix queue order after deletes const fixQueueOrder = useCallback(async () => { @@ -69,13 +112,21 @@ export const useActions = () => { try { await removeFromQueue(queueItem.key); + if (controllerName && queueItem.song && queueItem.song.path) { + try { + await historyService.removeFromHistory(controllerName, queueItem.song.path); + if (showSuccess) showSuccess('Song removed from history'); + } catch { + if (showError) showError('Failed to remove song from history'); + } + } if (showSuccess) showSuccess('Song removed from queue'); // After removal, fix the order of all items await fixQueueOrder(); } catch { if (showError) showError('Failed to remove song from queue'); } - }, [removeFromQueue, showSuccess, showError, fixQueueOrder]); + }, [removeFromQueue, showSuccess, showError, fixQueueOrder, controllerName]); const handleToggleFavorite = useCallback(async (song: Song) => { try {