Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
fa2532503c
commit
c1fd104be3
@ -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;
|
||||
@ -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);
|
||||
}
|
||||
};
|
||||
@ -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';
|
||||
105
src/hooks/useDisabledSongs.ts
Normal file
105
src/hooks/useDisabledSongs.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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;
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user