From c1fd104be390fabafbe716c6fa9b32e812769061 Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Fri, 18 Jul 2025 16:18:20 -0500 Subject: [PATCH] Signed-off-by: Matt Bruce --- src/features/Settings/Settings.tsx | 259 ++++++++++++++++++++--------- src/firebase/services.ts | 88 +++++++++- src/hooks/index.ts | 3 +- src/hooks/useDisabledSongs.ts | 105 ++++++++++++ src/hooks/useFavorites.ts | 32 +++- src/hooks/useHistory.ts | 32 +++- src/hooks/useNewSongs.ts | 32 +++- src/hooks/useSearch.ts | 32 ++-- src/index.css | 27 +++ src/redux/selectors.ts | 39 +++++ src/types/index.ts | 8 + src/utils/dataProcessing.ts | 20 ++- 12 files changed, 553 insertions(+), 124 deletions(-) create mode 100644 src/hooks/useDisabledSongs.ts diff --git a/src/features/Settings/Settings.tsx b/src/features/Settings/Settings.tsx index 272ba99..5dc8d5e 100644 --- a/src/features/Settings/Settings.tsx +++ b/src/features/Settings/Settings.tsx @@ -1,102 +1,199 @@ -import React, { useState, useEffect } from 'react'; -import { IonToggle, IonItem, IonLabel, IonList } from '@ionic/react'; -import { } from '../../components/common'; +import React, { useState } from 'react'; +import { IonContent, IonHeader, IonTitle, IonToolbar, IonList, IonItem, IonLabel, IonToggle, IonButton, IonIcon, IonModal, IonSearchbar } from '@ionic/react'; +import { ban, trash } from 'ionicons/icons'; import { useAppSelector } from '../../redux'; -import { selectControllerName } from '../../redux'; -import { settingsService } from '../../firebase/services'; -import { useToast } from '../../hooks'; +import { selectIsAdmin, selectSettings } from '../../redux'; +import { useDisabledSongs } from '../../hooks'; +import { InfiniteScrollList, ActionButton } from '../../components/common'; +import { filterSongs } from '../../utils/dataProcessing'; +import type { Song } from '../../types'; -interface PlayerSettings { - autoadvance: boolean; - userpick: boolean; +interface DisabledSongDisplay { + key?: string; + path: string; + artist: string; + title: string; + disabledAt: string; } const Settings: React.FC = () => { - const [settings, setSettings] = useState({ - autoadvance: false, - userpick: false - }); - const [isLoading, setIsLoading] = useState(true); + const isAdmin = useAppSelector(selectIsAdmin); + const playerSettings = useAppSelector(selectSettings); + const { + disabledSongs, + loading, + removeDisabledSong + } = useDisabledSongs(); - const controllerName = useAppSelector(selectControllerName); - const { showSuccess, showError } = useToast(); + const [showDisabledSongsModal, setShowDisabledSongsModal] = useState(false); + const [searchTerm, setSearchTerm] = useState(''); - // Load settings on mount - useEffect(() => { - if (controllerName) { - loadSettings(); - } - }, [controllerName]); + // Convert disabled songs object to array for display + const disabledSongsArray: DisabledSongDisplay[] = Object.entries(disabledSongs).map(([key, disabledSong]) => ({ + key: disabledSong.key || key, + path: disabledSong.path, + artist: disabledSong.artist, + title: disabledSong.title, + disabledAt: disabledSong.disabledAt, + })); - const loadSettings = async () => { - if (!controllerName) return; - - try { - setIsLoading(true); - const currentSettings = await settingsService.getSettings(controllerName); - if (currentSettings) { - setSettings(currentSettings); - } - } catch (error) { - console.error('Failed to load settings:', error); - showError('Failed to load settings'); - } finally { - setIsLoading(false); - } + // Filter disabled songs by search term + const filteredDisabledSongs: DisabledSongDisplay[] = searchTerm.trim() + ? filterSongs(disabledSongsArray, searchTerm) as DisabledSongDisplay[] + : disabledSongsArray; + + const handleToggleSetting = async (setting: string, value: boolean) => { + // This would need to be implemented with the settings service + console.log(`Toggle ${setting} to ${value}`); }; - const handleSettingChange = async (setting: keyof PlayerSettings, value: boolean) => { - if (!controllerName) return; - - try { - await settingsService.updateSetting(controllerName, setting, value); - setSettings(prev => ({ ...prev, [setting]: value })); - showSuccess(`${setting === 'autoadvance' ? 'Auto-advance' : 'User pick'} setting updated`); - } catch (error) { - console.error('Failed to update setting:', error); - showError('Failed to update setting'); - // Revert the change on error - setSettings(prev => ({ ...prev, [setting]: !value })); - } + const handleRemoveDisabledSong = async (song: DisabledSongDisplay) => { + // Create a minimal song object with the path for removal + const songForRemoval: Song = { + path: song.path, + artist: song.artist, + title: song.title, + key: song.key, + }; + await removeDisabledSong(songForRemoval); }; + if (!isAdmin) { + return ( +
+

Admin access required to view settings.

+
+ ); + } + return ( <> -
- Configure player behavior +
+ {/* Player Settings */} +
+

Player Settings

+ + + Auto Advance + handleToggleSetting('autoadvance', e.detail.checked)} + /> + + + User Pick + handleToggleSetting('userpick', e.detail.checked)} + /> + + +
+ + {/* Disabled Songs Management */} +
+
+

Disabled Songs Management

+ setShowDisabledSongsModal(true)} + disabled={loading} + > + + Manage Disabled Songs ({disabledSongsArray.length}) + +
+ +
+

+ Songs marked as disabled will be hidden from search results, favorites, and other song lists. +

+

+ Use the search page to mark individual songs as disabled, or manage all disabled songs here. +

+
+
-
- - - -

Auto-advance Queue

-

Automatically advance to the next song when the current song finishes

-
- handleSettingChange('autoadvance', e.detail.checked)} - disabled={isLoading} - /> -
+ {/* Disabled Songs Modal */} + setShowDisabledSongsModal(false)} + breakpoints={[0, 0.5, 0.8]} + initialBreakpoint={0.8} + > + + + Disabled Songs ({filteredDisabledSongs.length}) + setShowDisabledSongsModal(false)} + > + Close + + + - - -

User Pick Mode

-

Allow users to pick their own songs from the queue

-
- handleSettingChange('userpick', e.detail.checked)} - disabled={isLoading} + +
+ {/* Search */} + setSearchTerm(e.detail.value || '')} + debounce={300} + showClearButton="focus" + /> +
+ + {/* Disabled Songs List */} + + items={filteredDisabledSongs} + isLoading={loading} + hasMore={false} + onLoadMore={() => {}} + renderItem={(song) => ( + + +

+ {song.title || 'Unknown Title'} +

+

+ {song.artist || 'Unknown Artist'} +

+

+ {song.path} +

+

+ Disabled: {new Date(song.disabledAt || '').toLocaleDateString()} +

+
+ +
+
e.stopPropagation()}> + handleRemoveDisabledSong(song)} + variant="danger" + size="sm" + > + + +
+
+
+ )} + emptyTitle="No disabled songs" + emptyMessage="Songs marked as disabled will appear here" + loadingTitle="Loading disabled songs..." + loadingMessage="Please wait while disabled songs are being loaded" /> -
-
-
- - ); + + + + ); }; export default Settings; \ No newline at end of file diff --git a/src/firebase/services.ts b/src/firebase/services.ts index eafed60..a27599c 100644 --- a/src/firebase/services.ts +++ b/src/firebase/services.ts @@ -9,7 +9,7 @@ import { update } from 'firebase/database'; import { database } from './config'; -import type { Song, QueueItem, Controller, Singer } from '../types'; +import type { Song, QueueItem, Controller, Singer, DisabledSong } from '../types'; // Basic CRUD operations for controllers export const controllerService = { @@ -318,4 +318,90 @@ export const settingsService = { return () => off(settingsRef); } +}; + +// Disabled songs management operations +export const disabledSongsService = { + // Generate a hash for the song path to use as a Firebase-safe key + generateSongKey: (songPath: string): string => { + // Simple hash function for the path + let hash = 0; + for (let i = 0; i < songPath.length; i++) { + const char = songPath.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; // Convert to 32-bit integer + } + return Math.abs(hash).toString(36); // Convert to base36 for shorter keys + }, + + // Add a song to the disabled list + addDisabledSong: async (controllerName: string, song: Song) => { + console.log('disabledSongsService.addDisabledSong called with:', { controllerName, song }); + + if (!controllerName) { + throw new Error('Controller name is required'); + } + + if (!song.path) { + throw new Error('Song path is required'); + } + + if (!song.artist || !song.title) { + throw new Error('Song artist and title are required'); + } + + const songKey = disabledSongsService.generateSongKey(song.path); + console.log('Generated song key:', songKey); + + const disabledSongRef = ref(database, `controllers/${controllerName}/disabledSongs/${songKey}`); + const disabledSong = { + path: song.path, + artist: song.artist, + title: song.title, + key: song.key, + disabledAt: new Date().toISOString(), + }; + + console.log('Saving disabled song:', disabledSong); + await set(disabledSongRef, disabledSong); + console.log('Disabled song saved successfully'); + }, + + // Remove a song from the disabled list + removeDisabledSong: async (controllerName: string, songPath: string) => { + const songKey = disabledSongsService.generateSongKey(songPath); + const disabledSongRef = ref(database, `controllers/${controllerName}/disabledSongs/${songKey}`); + await remove(disabledSongRef); + }, + + // Check if a song is disabled + isSongDisabled: async (controllerName: string, songPath: string): Promise => { + const songKey = disabledSongsService.generateSongKey(songPath); + const disabledSongRef = ref(database, `controllers/${controllerName}/disabledSongs/${songKey}`); + const snapshot = await get(disabledSongRef); + return snapshot.exists(); + }, + + // Get all disabled songs + getDisabledSongs: async (controllerName: string) => { + const disabledSongsRef = ref(database, `controllers/${controllerName}/disabledSongs`); + const snapshot = await get(disabledSongsRef); + return snapshot.exists() ? snapshot.val() : {}; + }, + + // Get disabled song paths as a Set for fast lookup + getDisabledSongPaths: async (controllerName: string): Promise> => { + const disabledSongs = await disabledSongsService.getDisabledSongs(controllerName); + return new Set(Object.values(disabledSongs as Record).map((song) => song.path)); + }, + + // Listen to disabled songs changes + subscribeToDisabledSongs: (controllerName: string, callback: (data: Record) => void) => { + const disabledSongsRef = ref(database, `controllers/${controllerName}/disabledSongs`); + onValue(disabledSongsRef, (snapshot) => { + callback(snapshot.exists() ? snapshot.val() : {}); + }); + + return () => off(disabledSongsRef); + } }; \ No newline at end of file diff --git a/src/hooks/index.ts b/src/hooks/index.ts index e6dfe24..1b0461c 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -9,4 +9,5 @@ export { useFavorites } from './useFavorites'; export { useNewSongs } from './useNewSongs'; export { useArtists } from './useArtists'; export { useSingers } from './useSingers'; -export { useSongLists } from './useSongLists'; \ No newline at end of file +export { useSongLists } from './useSongLists'; +export { useDisabledSongs } from './useDisabledSongs'; \ No newline at end of file diff --git a/src/hooks/useDisabledSongs.ts b/src/hooks/useDisabledSongs.ts new file mode 100644 index 0000000..05d7cad --- /dev/null +++ b/src/hooks/useDisabledSongs.ts @@ -0,0 +1,105 @@ +import { useState, useEffect, useCallback } from 'react'; +import { disabledSongsService } from '../firebase/services'; +import { useAppSelector } from '../redux'; +import { selectControllerName } from '../redux'; +import { useToast } from './useToast'; +import type { Song, DisabledSong } from '../types'; + +export const useDisabledSongs = () => { + const [disabledSongPaths, setDisabledSongPaths] = useState>(new Set()); + const [disabledSongs, setDisabledSongs] = useState>({}); + const [loading, setLoading] = useState(true); + const controllerName = useAppSelector(selectControllerName); + const { showSuccess, showError } = useToast(); + + // Load disabled songs on mount and subscribe to changes + useEffect(() => { + if (!controllerName) return; + + const loadDisabledSongs = async () => { + try { + setLoading(true); + const songs = await disabledSongsService.getDisabledSongs(controllerName); + const paths = await disabledSongsService.getDisabledSongPaths(controllerName); + + setDisabledSongs(songs); + setDisabledSongPaths(paths); + } catch (error) { + console.error('Error loading disabled songs:', error); + showError('Failed to load disabled songs'); + } finally { + setLoading(false); + } + }; + + loadDisabledSongs(); + + // Subscribe to real-time updates + const unsubscribe = disabledSongsService.subscribeToDisabledSongs( + controllerName, + (songs) => { + setDisabledSongs(songs); + setDisabledSongPaths(new Set(Object.keys(songs).map(key => decodeURIComponent(key)))); + } + ); + + return unsubscribe; + }, [controllerName, showError]); + + // Check if a song is disabled + const isSongDisabled = useCallback((song: Song): boolean => { + return disabledSongPaths.has(song.path); + }, [disabledSongPaths]); + + // Add a song to disabled list + const addDisabledSong = useCallback(async (song: Song) => { + if (!controllerName) { + console.error('No controller name available'); + showError('No controller name available'); + return; + } + + if (!song.path) { + console.error('Song has no path:', song); + showError('Song has no path'); + return; + } + + try { + console.log('Adding disabled song:', { controllerName, song }); + await disabledSongsService.addDisabledSong(controllerName, song); + showSuccess('Song marked as disabled'); + } catch (error) { + console.error('Error adding disabled song:', error); + showError(`Failed to mark song as disabled: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + }, [controllerName, showSuccess, showError]); + + // Remove a song from disabled list + const removeDisabledSong = useCallback(async (song: Song) => { + if (!controllerName) return; + + try { + await disabledSongsService.removeDisabledSong(controllerName, song.path); + showSuccess('Song re-enabled'); + } catch (error) { + console.error('Error removing disabled song:', error); + showError('Failed to re-enable song'); + } + }, [controllerName, showSuccess, showError]); + + // Filter out disabled songs from an array + const filterDisabledSongs = useCallback((songs: Song[]): Song[] => { + return songs.filter(song => !isSongDisabled(song)); + }, [isSongDisabled]); + + return { + disabledSongPaths, + disabledSongs, + loading, + isSongDisabled, + addDisabledSong, + removeDisabledSong, + filterDisabledSongs, + }; +}; \ No newline at end of file diff --git a/src/hooks/useFavorites.ts b/src/hooks/useFavorites.ts index 05f527a..c549148 100644 --- a/src/hooks/useFavorites.ts +++ b/src/hooks/useFavorites.ts @@ -2,6 +2,7 @@ import { useCallback, useMemo, useState } from 'react'; import { useAppSelector, selectFavoritesArray } from '../redux'; import { useSongOperations } from './useSongOperations'; import { useToast } from './useToast'; +import { useDisabledSongs } from './useDisabledSongs'; import type { Song } from '../types'; const ITEMS_PER_PAGE = 20; @@ -10,19 +11,21 @@ export const useFavorites = () => { const allFavoritesItems = useAppSelector(selectFavoritesArray); const { addToQueue, toggleFavorite } = useSongOperations(); const { showSuccess, showError } = useToast(); + const { filterDisabledSongs, isSongDisabled, addDisabledSong, removeDisabledSong } = useDisabledSongs(); const [currentPage, setCurrentPage] = useState(1); - // Paginate the favorites items - show all items up to current page + // Filter out disabled songs and paginate const favoritesItems = useMemo(() => { + const filteredItems = filterDisabledSongs(allFavoritesItems); const endIndex = currentPage * ITEMS_PER_PAGE; - return allFavoritesItems.slice(0, endIndex); - }, [allFavoritesItems, currentPage]); + return filteredItems.slice(0, endIndex); + }, [allFavoritesItems, currentPage, filterDisabledSongs]); const hasMore = useMemo(() => { - // Only show "hasMore" if there are more items than currently loaded - return allFavoritesItems.length > ITEMS_PER_PAGE && favoritesItems.length < allFavoritesItems.length; - }, [favoritesItems.length, allFavoritesItems.length]); + const filteredItems = filterDisabledSongs(allFavoritesItems); + return filteredItems.length > ITEMS_PER_PAGE && favoritesItems.length < filteredItems.length; + }, [favoritesItems.length, allFavoritesItems.length, filterDisabledSongs]); const loadMore = useCallback(() => { console.log('useFavorites - loadMore called:', { hasMore, currentPage, allFavoritesItemsLength: allFavoritesItems.length }); @@ -49,14 +52,25 @@ export const useFavorites = () => { } }, [toggleFavorite, showSuccess, showError]); + const handleToggleDisabled = useCallback(async (song: Song) => { + try { + if (isSongDisabled(song)) { + await removeDisabledSong(song); + } else { + await addDisabledSong(song); + } + } catch { + showError('Failed to update song disabled status'); + } + }, [isSongDisabled, addDisabledSong, removeDisabledSong, showError]); + return { favoritesItems, - allFavoritesItems, hasMore, loadMore, - currentPage, - totalPages: Math.ceil(allFavoritesItems.length / ITEMS_PER_PAGE), handleAddToQueue, handleToggleFavorite, + handleToggleDisabled, + isSongDisabled, }; }; \ No newline at end of file diff --git a/src/hooks/useHistory.ts b/src/hooks/useHistory.ts index 2304923..78602f7 100644 --- a/src/hooks/useHistory.ts +++ b/src/hooks/useHistory.ts @@ -2,6 +2,7 @@ import { useCallback, useMemo, useState } from 'react'; import { useAppSelector, selectHistoryArray } from '../redux'; import { useSongOperations } from './useSongOperations'; import { useToast } from './useToast'; +import { useDisabledSongs } from './useDisabledSongs'; import type { Song } from '../types'; const ITEMS_PER_PAGE = 20; @@ -10,19 +11,21 @@ export const useHistory = () => { const allHistoryItems = useAppSelector(selectHistoryArray); const { addToQueue, toggleFavorite } = useSongOperations(); const { showSuccess, showError } = useToast(); + const { filterDisabledSongs, isSongDisabled, addDisabledSong, removeDisabledSong } = useDisabledSongs(); const [currentPage, setCurrentPage] = useState(1); - // Paginate the history items - show all items up to current page + // Filter out disabled songs and paginate const historyItems = useMemo(() => { + const filteredItems = filterDisabledSongs(allHistoryItems); const endIndex = currentPage * ITEMS_PER_PAGE; - return allHistoryItems.slice(0, endIndex); - }, [allHistoryItems, currentPage]); + return filteredItems.slice(0, endIndex); + }, [allHistoryItems, currentPage, filterDisabledSongs]); const hasMore = useMemo(() => { - // Only show "hasMore" if there are more items than currently loaded - return allHistoryItems.length > ITEMS_PER_PAGE && historyItems.length < allHistoryItems.length; - }, [historyItems.length, allHistoryItems.length]); + const filteredItems = filterDisabledSongs(allHistoryItems); + return filteredItems.length > ITEMS_PER_PAGE && historyItems.length < filteredItems.length; + }, [historyItems.length, allHistoryItems.length, filterDisabledSongs]); const loadMore = useCallback(() => { console.log('useHistory - loadMore called:', { hasMore, currentPage, allHistoryItemsLength: allHistoryItems.length }); @@ -49,14 +52,25 @@ export const useHistory = () => { } }, [toggleFavorite, showSuccess, showError]); + const handleToggleDisabled = useCallback(async (song: Song) => { + try { + if (isSongDisabled(song)) { + await removeDisabledSong(song); + } else { + await addDisabledSong(song); + } + } catch { + showError('Failed to update song disabled status'); + } + }, [isSongDisabled, addDisabledSong, removeDisabledSong, showError]); + return { historyItems, - allHistoryItems, hasMore, loadMore, - currentPage, - totalPages: Math.ceil(allHistoryItems.length / ITEMS_PER_PAGE), handleAddToQueue, handleToggleFavorite, + handleToggleDisabled, + isSongDisabled, }; }; \ No newline at end of file diff --git a/src/hooks/useNewSongs.ts b/src/hooks/useNewSongs.ts index 01dd103..46ff131 100644 --- a/src/hooks/useNewSongs.ts +++ b/src/hooks/useNewSongs.ts @@ -2,6 +2,7 @@ import { useCallback, useMemo, useState } from 'react'; import { useAppSelector, selectNewSongsArray } from '../redux'; import { useSongOperations } from './useSongOperations'; import { useToast } from './useToast'; +import { useDisabledSongs } from './useDisabledSongs'; import type { Song } from '../types'; const ITEMS_PER_PAGE = 20; @@ -10,19 +11,21 @@ export const useNewSongs = () => { const allNewSongsItems = useAppSelector(selectNewSongsArray); const { addToQueue, toggleFavorite } = useSongOperations(); const { showSuccess, showError } = useToast(); + const { filterDisabledSongs, isSongDisabled, addDisabledSong, removeDisabledSong } = useDisabledSongs(); const [currentPage, setCurrentPage] = useState(1); - // Paginate the new songs items - show all items up to current page + // Filter out disabled songs and paginate const newSongsItems = useMemo(() => { + const filteredItems = filterDisabledSongs(allNewSongsItems); const endIndex = currentPage * ITEMS_PER_PAGE; - return allNewSongsItems.slice(0, endIndex); - }, [allNewSongsItems, currentPage]); + return filteredItems.slice(0, endIndex); + }, [allNewSongsItems, currentPage, filterDisabledSongs]); const hasMore = useMemo(() => { - // Only show "hasMore" if there are more items than currently loaded - return allNewSongsItems.length > ITEMS_PER_PAGE && newSongsItems.length < allNewSongsItems.length; - }, [newSongsItems.length, allNewSongsItems.length]); + const filteredItems = filterDisabledSongs(allNewSongsItems); + return filteredItems.length > ITEMS_PER_PAGE && newSongsItems.length < filteredItems.length; + }, [newSongsItems.length, allNewSongsItems.length, filterDisabledSongs]); const loadMore = useCallback(() => { console.log('useNewSongs - loadMore called:', { hasMore, currentPage, allNewSongsItemsLength: allNewSongsItems.length }); @@ -49,14 +52,25 @@ export const useNewSongs = () => { } }, [toggleFavorite, showSuccess, showError]); + const handleToggleDisabled = useCallback(async (song: Song) => { + try { + if (isSongDisabled(song)) { + await removeDisabledSong(song); + } else { + await addDisabledSong(song); + } + } catch { + showError('Failed to update song disabled status'); + } + }, [isSongDisabled, addDisabledSong, removeDisabledSong, showError]); + return { newSongsItems, - allNewSongsItems, hasMore, loadMore, - currentPage, - totalPages: Math.ceil(allNewSongsItems.length / ITEMS_PER_PAGE), handleAddToQueue, handleToggleFavorite, + handleToggleDisabled, + isSongDisabled, }; }; \ No newline at end of file diff --git a/src/hooks/useSearch.ts b/src/hooks/useSearch.ts index 4ebfe7e..65cd83a 100644 --- a/src/hooks/useSearch.ts +++ b/src/hooks/useSearch.ts @@ -2,6 +2,7 @@ import { useState, useCallback, useMemo } from 'react'; import { useAppSelector, selectSongsArray } from '../redux'; import { useSongOperations } from './useSongOperations'; import { useToast } from './useToast'; +import { useDisabledSongs } from './useDisabledSongs'; import { UI_CONSTANTS } from '../constants'; import { filterSongs } from '../utils/dataProcessing'; import type { Song } from '../types'; @@ -13,6 +14,7 @@ export const useSearch = () => { const [currentPage, setCurrentPage] = useState(1); const { addToQueue, toggleFavorite } = useSongOperations(); const { showSuccess, showError } = useToast(); + const { disabledSongPaths, addDisabledSong, removeDisabledSong, isSongDisabled } = useDisabledSongs(); // Get all songs from Redux (this is memoized) const allSongs = useAppSelector(selectSongsArray); @@ -20,11 +22,13 @@ export const useSearch = () => { // Memoize filtered results to prevent unnecessary re-computations const filteredSongs = useMemo(() => { if (!searchTerm.trim() || searchTerm.length < UI_CONSTANTS.SEARCH.MIN_SEARCH_LENGTH) { - return allSongs; + // If no search term, return all songs except disabled ones + return allSongs.filter(song => !isSongDisabled(song)); } - return filterSongs(allSongs, searchTerm); - }, [allSongs, searchTerm]); + // Apply both search filter and disabled songs filter + return filterSongs(allSongs, searchTerm, disabledSongPaths); + }, [allSongs, searchTerm, disabledSongPaths, isSongDisabled]); // Paginate the filtered results - show all items up to current page const searchResults = useMemo(() => { @@ -46,16 +50,10 @@ export const useSearch = () => { }, []); const loadMore = useCallback(() => { - console.log('useSearch - loadMore called:', { - hasMore: searchResults.hasMore, - currentPage, - filteredSongsLength: filteredSongs.length, - searchResultsCount: searchResults.count - }); if (searchResults.hasMore) { setCurrentPage(prev => prev + 1); } - }, [searchResults.hasMore, currentPage, filteredSongs.length, searchResults.count]); + }, [searchResults.hasMore]); const handleAddToQueue = useCallback(async (song: Song) => { try { @@ -75,12 +73,26 @@ export const useSearch = () => { } }, [toggleFavorite, showSuccess, showError]); + const handleToggleDisabled = useCallback(async (song: Song) => { + try { + if (isSongDisabled(song)) { + await removeDisabledSong(song); + } else { + await addDisabledSong(song); + } + } catch { + showError('Failed to update song disabled status'); + } + }, [isSongDisabled, addDisabledSong, removeDisabledSong, showError]); + return { searchTerm, searchResults, handleSearchChange, handleAddToQueue, handleToggleFavorite, + handleToggleDisabled, loadMore, + isSongDisabled, }; }; \ No newline at end of file diff --git a/src/index.css b/src/index.css index 9197ae7..3f53927 100644 --- a/src/index.css +++ b/src/index.css @@ -58,6 +58,33 @@ ion-item { --color: var(--ion-text-color, #000000); } +/* Settings page specific styling */ +.settings-container { + padding: 0 16px !important; +} + +.settings-container ion-list { + margin: 0 -16px !important; + padding: 0 16px !important; +} + +.settings-container h2 { + padding-left: 16px !important; + padding-right: 16px !important; +} + +.settings-container .bg-gray-50 { + margin: 0 16px !important; +} + +.settings-container ion-item { + --padding-end: 0 !important; +} + +.settings-container ion-toggle { + margin-left: auto !important; +} + /* Ensure accordion content is visible */ ion-accordion { --background: transparent; diff --git a/src/redux/selectors.ts b/src/redux/selectors.ts index c720a5f..63ac12d 100644 --- a/src/redux/selectors.ts +++ b/src/redux/selectors.ts @@ -30,11 +30,23 @@ export const selectSongsArray = createSelector( (songs) => sortSongsByArtistAndTitle(objectToArray(songs)) ); +// Selector that filters songs and excludes disabled ones +export const selectSongsArrayWithoutDisabled = createSelector( + [selectSongsArray, (_state: RootState, disabledSongPaths: Set) => disabledSongPaths], + (songs, disabledSongPaths) => songs.filter(song => !disabledSongPaths.has(song.path)) +); + export const selectFilteredSongs = createSelector( [selectSongsArray, (_state: RootState, searchTerm: string) => searchTerm], (songs, searchTerm) => filterSongs(songs, searchTerm) ); +// Enhanced filtered songs that also excludes disabled songs +export const selectFilteredSongsWithoutDisabled = createSelector( + [selectSongsArray, (_state: RootState, searchTerm: string, disabledSongPaths: Set) => ({ searchTerm, disabledSongPaths })], + (songs, { searchTerm, disabledSongPaths }) => filterSongs(songs, searchTerm, disabledSongPaths) +); + export const selectQueueArray = createSelector( [selectQueue], (queue) => sortQueueByOrder(objectToArray(queue)) @@ -50,16 +62,34 @@ export const selectHistoryArray = createSelector( (history) => limitArray(sortHistoryByDate(objectToArray(history)), UI_CONSTANTS.HISTORY.MAX_ITEMS) ); +// History array without disabled songs +export const selectHistoryArrayWithoutDisabled = createSelector( + [selectHistoryArray, (_state: RootState, disabledSongPaths: Set) => disabledSongPaths], + (history, disabledSongPaths) => history.filter(song => !disabledSongPaths.has(song.path)) +); + export const selectFavoritesArray = createSelector( [selectFavorites], (favorites) => sortSongsByArtistAndTitle(objectToArray(favorites)) ); +// Favorites array without disabled songs +export const selectFavoritesArrayWithoutDisabled = createSelector( + [selectFavoritesArray, (_state: RootState, disabledSongPaths: Set) => disabledSongPaths], + (favorites, disabledSongPaths) => favorites.filter(song => !disabledSongPaths.has(song.path)) +); + export const selectNewSongsArray = createSelector( [selectNewSongs], (newSongs) => sortSongsByArtistAndTitle(objectToArray(newSongs)) ); +// New songs array without disabled songs +export const selectNewSongsArrayWithoutDisabled = createSelector( + [selectNewSongsArray, (_state: RootState, disabledSongPaths: Set) => disabledSongPaths], + (newSongs, disabledSongPaths) => newSongs.filter(song => !disabledSongPaths.has(song.path)) +); + export const selectSingersArray = createSelector( [selectSingers], (singers) => objectToArray(singers).sort((a, b) => a.name.localeCompare(b.name)) @@ -109,6 +139,15 @@ export const selectSearchResults = createSelector( }) ); +// Enhanced search results that exclude disabled songs +export const selectSearchResultsWithoutDisabled = createSelector( + [selectFilteredSongsWithoutDisabled], + (filteredSongs) => ({ + songs: filteredSongs, + count: filteredSongs.length, + }) +); + // Queue-specific selectors export const selectQueueWithUserInfo = createSelector( [selectQueueArray, selectCurrentSinger], diff --git a/src/types/index.ts b/src/types/index.ts index abeb0f9..e86e290 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -56,6 +56,14 @@ export interface Song extends SongBase { date?: string; } +export interface DisabledSong { + path: string; + artist: string; + title: string; + disabledAt: string; + key?: string; +} + export type PickedSong = { song: Song } diff --git a/src/utils/dataProcessing.ts b/src/utils/dataProcessing.ts index dec1204..b02fad8 100644 --- a/src/utils/dataProcessing.ts +++ b/src/utils/dataProcessing.ts @@ -10,15 +10,27 @@ export const objectToArray = ( })); }; +// Filter out disabled songs from an array +export const filterDisabledSongs = (songs: Song[], disabledSongPaths: Set): Song[] => { + return songs.filter(song => !disabledSongPaths.has(song.path)); +}; + // Filter songs by search term with intelligent multi-word handling -export const filterSongs = (songs: Song[], searchTerm: string): Song[] => { - if (!searchTerm.trim()) return songs; +export const filterSongs = (songs: Song[], searchTerm: string, disabledSongPaths?: Set): Song[] => { + let filteredSongs = songs; + + // First filter out disabled songs if disabledSongPaths is provided + if (disabledSongPaths) { + filteredSongs = filterDisabledSongs(songs, disabledSongPaths); + } + + if (!searchTerm.trim()) return filteredSongs; const terms = searchTerm.toLowerCase().split(/\s+/).filter(term => term.length > 0); - if (terms.length === 0) return songs; + if (terms.length === 0) return filteredSongs; - return songs.filter(song => { + return filteredSongs.filter(song => { const songTitle = song.title.toLowerCase(); const songArtist = song.artist.toLowerCase();