Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2025-07-18 16:18:20 -05:00
parent fa2532503c
commit c1fd104be3
12 changed files with 553 additions and 124 deletions

View File

@ -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<PlayerSettings>({
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 (
<div className="max-w-4xl mx-auto p-6 text-center">
<p className="text-gray-500">Admin access required to view settings.</p>
</div>
);
}
return (
<>
<div className="text-sm text-gray-500 text-center mb-4">
Configure player behavior
<div className="max-w-4xl mx-auto p-6 settings-container">
{/* Player Settings */}
<div className="mb-8">
<h2 className="text-large font-semibold mb-4">Player Settings</h2>
<IonList className="rounded-lg overflow-hidden">
<IonItem>
<IonLabel>Auto Advance</IonLabel>
<IonToggle
slot="end"
checked={playerSettings?.autoadvance || false}
onIonChange={(e) => handleToggleSetting('autoadvance', e.detail.checked)}
/>
</IonItem>
<IonItem>
<IonLabel>User Pick</IonLabel>
<IonToggle
slot="end"
checked={playerSettings?.userpick || false}
onIonChange={(e) => handleToggleSetting('userpick', e.detail.checked)}
/>
</IonItem>
</IonList>
</div>
{/* Disabled Songs Management */}
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Disabled Songs Management</h2>
<IonButton
fill="outline"
onClick={() => setShowDisabledSongsModal(true)}
disabled={loading}
>
<IonIcon icon={ban} slot="start" />
Manage Disabled Songs ({disabledSongsArray.length})
</IonButton>
</div>
<div className="bg-gray-50 rounded-lg p-4">
<p className="text-sm text-gray-600 mb-2">
Songs marked as disabled will be hidden from search results, favorites, and other song lists.
</p>
<p className="text-sm text-gray-600">
Use the search page to mark individual songs as disabled, or manage all disabled songs here.
</p>
</div>
</div>
</div>
<div className="max-w-4xl mx-auto p-6">
<IonList>
<IonItem>
<IonLabel>
<h3>Auto-advance Queue</h3>
<p>Automatically advance to the next song when the current song finishes</p>
</IonLabel>
<IonToggle
slot="end"
checked={settings.autoadvance}
onIonChange={(e) => handleSettingChange('autoadvance', e.detail.checked)}
disabled={isLoading}
/>
</IonItem>
{/* Disabled Songs Modal */}
<IonModal
isOpen={showDisabledSongsModal}
onDidDismiss={() => setShowDisabledSongsModal(false)}
breakpoints={[0, 0.5, 0.8]}
initialBreakpoint={0.8}
>
<IonHeader>
<IonToolbar>
<IonTitle>Disabled Songs ({filteredDisabledSongs.length})</IonTitle>
<IonButton
slot="end"
fill="clear"
onClick={() => setShowDisabledSongsModal(false)}
>
Close
</IonButton>
</IonToolbar>
</IonHeader>
<IonItem>
<IonLabel>
<h3>User Pick Mode</h3>
<p>Allow users to pick their own songs from the queue</p>
</IonLabel>
<IonToggle
slot="end"
checked={settings.userpick}
onIonChange={(e) => handleSettingChange('userpick', e.detail.checked)}
disabled={isLoading}
<IonContent>
<div className="p-4">
{/* Search */}
<IonSearchbar
placeholder="Search disabled songs..."
value={searchTerm}
onIonInput={(e) => setSearchTerm(e.detail.value || '')}
debounce={300}
showClearButton="focus"
/>
</div>
{/* Disabled Songs List */}
<InfiniteScrollList<DisabledSongDisplay>
items={filteredDisabledSongs}
isLoading={loading}
hasMore={false}
onLoadMore={() => {}}
renderItem={(song) => (
<IonItem>
<IonLabel>
<h3 className="text-sm font-medium text-gray-900">
{song.title || 'Unknown Title'}
</h3>
<p className="text-sm text-gray-500">
{song.artist || 'Unknown Artist'}
</p>
<p className="text-xs text-gray-400 break-words">
{song.path}
</p>
<p className="text-xs text-gray-400">
Disabled: {new Date(song.disabledAt || '').toLocaleDateString()}
</p>
</IonLabel>
<div slot="end" className="flex items-center gap-2 ml-2">
<div onClick={(e) => e.stopPropagation()}>
<ActionButton
onClick={() => handleRemoveDisabledSong(song)}
variant="danger"
size="sm"
>
<IonIcon icon={trash} />
</ActionButton>
</div>
</div>
</IonItem>
)}
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"
/>
</IonItem>
</IonList>
</div>
</>
);
</IonContent>
</IonModal>
</>
);
};
export default Settings;

View File

@ -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<boolean> => {
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<Set<string>> => {
const disabledSongs = await disabledSongsService.getDisabledSongs(controllerName);
return new Set(Object.values(disabledSongs as Record<string, DisabledSong>).map((song) => song.path));
},
// Listen to disabled songs changes
subscribeToDisabledSongs: (controllerName: string, callback: (data: Record<string, DisabledSong>) => void) => {
const disabledSongsRef = ref(database, `controllers/${controllerName}/disabledSongs`);
onValue(disabledSongsRef, (snapshot) => {
callback(snapshot.exists() ? snapshot.val() : {});
});
return () => off(disabledSongsRef);
}
};

View File

@ -9,4 +9,5 @@ export { useFavorites } from './useFavorites';
export { useNewSongs } from './useNewSongs';
export { useArtists } from './useArtists';
export { useSingers } from './useSingers';
export { useSongLists } from './useSongLists';
export { useSongLists } from './useSongLists';
export { useDisabledSongs } from './useDisabledSongs';

View File

@ -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<Set<string>>(new Set());
const [disabledSongs, setDisabledSongs] = useState<Record<string, DisabledSong>>({});
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,
};
};

View File

@ -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,
};
};

View File

@ -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,
};
};

View File

@ -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,
};
};

View File

@ -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,
};
};

View File

@ -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;

View File

@ -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<string>) => 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<string>) => ({ 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<string>) => 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<string>) => 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<string>) => 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],

View File

@ -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
}

View File

@ -10,15 +10,27 @@ export const objectToArray = <T extends { key?: string }>(
}));
};
// Filter out disabled songs from an array
export const filterDisabledSongs = (songs: Song[], disabledSongPaths: Set<string>): 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<string>): 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();