Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
cf78d4d4a5
commit
1c714ec341
@ -1,11 +1,8 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useAppDispatch, useAppSelector } from '../../redux/hooks';
|
||||
import { setAuth } from '../../redux/authSlice';
|
||||
import { selectIsAuthenticated } from '../../redux/authSlice';
|
||||
import { CONTROLLER_NAME } from '../../constants';
|
||||
import { LoginPrompt } from './index';
|
||||
import type { Authentication } from '../../types';
|
||||
|
||||
interface AuthInitializerProps {
|
||||
children: React.ReactNode;
|
||||
@ -14,40 +11,47 @@ interface AuthInitializerProps {
|
||||
const AuthInitializer: React.FC<AuthInitializerProps> = ({ children }) => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const [showLogin, setShowLogin] = useState(false);
|
||||
const [isAdminMode, setIsAdminMode] = useState(false);
|
||||
const [hasProcessedAdminParam, setHasProcessedAdminParam] = useState(false);
|
||||
const dispatch = useAppDispatch();
|
||||
const isAuthenticated = useAppSelector(selectIsAuthenticated);
|
||||
|
||||
useEffect(() => {
|
||||
// Only process admin parameter once
|
||||
if (hasProcessedAdminParam) return;
|
||||
|
||||
// Check for admin parameter in URL
|
||||
const isAdmin = searchParams.get('admin') === 'true';
|
||||
|
||||
// If admin parameter is present, auto-authenticate
|
||||
if (isAdmin) {
|
||||
const auth: Authentication = {
|
||||
authenticated: true,
|
||||
singer: 'Admin',
|
||||
isAdmin: true,
|
||||
controller: CONTROLLER_NAME,
|
||||
};
|
||||
dispatch(setAuth(auth));
|
||||
|
||||
// Clean up URL
|
||||
// Set admin mode but don't auto-authenticate
|
||||
setIsAdminMode(true);
|
||||
setHasProcessedAdminParam(true);
|
||||
}
|
||||
|
||||
// Show login prompt if not authenticated (for both admin and regular users)
|
||||
if (!isAuthenticated) {
|
||||
setShowLogin(true);
|
||||
}
|
||||
}, [searchParams, dispatch, isAuthenticated, hasProcessedAdminParam]);
|
||||
|
||||
// Clean up admin parameter after successful authentication
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && isAdminMode && hasProcessedAdminParam) {
|
||||
// Clean up URL after successful admin login
|
||||
if (window.history.replaceState) {
|
||||
const newUrl = new URL(window.location.href);
|
||||
newUrl.searchParams.delete('admin');
|
||||
window.history.replaceState({}, '', newUrl.toString());
|
||||
}
|
||||
} else if (!isAuthenticated) {
|
||||
// Show login prompt for regular users
|
||||
setShowLogin(true);
|
||||
}
|
||||
}, [searchParams, dispatch, isAuthenticated]);
|
||||
}, [isAuthenticated, isAdminMode, hasProcessedAdminParam]);
|
||||
|
||||
// Show login prompt if not authenticated
|
||||
if (showLogin && !isAuthenticated) {
|
||||
return (
|
||||
<LoginPrompt
|
||||
isAdmin={false}
|
||||
isAdmin={isAdminMode}
|
||||
onComplete={() => setShowLogin(false)}
|
||||
/>
|
||||
);
|
||||
|
||||
133
src/components/common/PlayerControls.tsx
Normal file
133
src/components/common/PlayerControls.tsx
Normal file
@ -0,0 +1,133 @@
|
||||
import React from 'react';
|
||||
import ActionButton from './ActionButton';
|
||||
import { useAppSelector } from '../../redux';
|
||||
import { selectPlayerState, selectIsAdmin, selectQueue } from '../../redux';
|
||||
import { playerService } from '../../firebase/services';
|
||||
import { selectControllerName } from '../../redux';
|
||||
import { useToast } from '../../hooks/useToast';
|
||||
import { PlayerState } from '../../types';
|
||||
|
||||
interface PlayerControlsProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const PlayerControls: React.FC<PlayerControlsProps> = ({ className = '' }) => {
|
||||
const playerState = useAppSelector(selectPlayerState);
|
||||
const isAdmin = useAppSelector(selectIsAdmin);
|
||||
const controllerName = useAppSelector(selectControllerName);
|
||||
const queue = useAppSelector(selectQueue);
|
||||
const { showSuccess, showError } = useToast();
|
||||
|
||||
// Debug logging
|
||||
console.log('PlayerControls - playerState:', playerState);
|
||||
console.log('PlayerControls - isAdmin:', isAdmin);
|
||||
console.log('PlayerControls - queue length:', Object.keys(queue).length);
|
||||
|
||||
const handlePlay = async () => {
|
||||
if (!controllerName) return;
|
||||
|
||||
try {
|
||||
await playerService.updatePlayerStateValue(controllerName, PlayerState.playing);
|
||||
showSuccess('Playback started');
|
||||
} catch (error) {
|
||||
console.error('Failed to start playback:', error);
|
||||
showError('Failed to start playback');
|
||||
}
|
||||
};
|
||||
|
||||
const handlePause = async () => {
|
||||
if (!controllerName) return;
|
||||
|
||||
try {
|
||||
await playerService.updatePlayerStateValue(controllerName, PlayerState.paused);
|
||||
showSuccess('Playback paused');
|
||||
} catch (error) {
|
||||
console.error('Failed to pause playback:', error);
|
||||
showError('Failed to pause playback');
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = async () => {
|
||||
if (!controllerName) return;
|
||||
|
||||
try {
|
||||
await playerService.updatePlayerStateValue(controllerName, PlayerState.stopped);
|
||||
showSuccess('Playback stopped');
|
||||
} catch (error) {
|
||||
console.error('Failed to stop playback:', error);
|
||||
showError('Failed to stop playback');
|
||||
}
|
||||
};
|
||||
|
||||
// Only show controls for admin users
|
||||
if (!isAdmin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentState = playerState?.state || PlayerState.stopped;
|
||||
const hasSongsInQueue = Object.keys(queue).length > 0;
|
||||
|
||||
console.log('PlayerControls - currentState:', currentState);
|
||||
console.log('PlayerControls - hasSongsInQueue:', hasSongsInQueue);
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-lg shadow p-4 ${className}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<h3 className="text-lg font-medium text-gray-900">Player Controls</h3>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${
|
||||
currentState === PlayerState.playing
|
||||
? 'bg-green-100 text-green-800'
|
||||
: currentState === PlayerState.paused
|
||||
? 'bg-yellow-100 text-yellow-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{currentState}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center justify-center space-x-3">
|
||||
{currentState === PlayerState.playing ? (
|
||||
<ActionButton
|
||||
onClick={handlePause}
|
||||
variant="primary"
|
||||
size="lg"
|
||||
>
|
||||
⏸️ Pause
|
||||
</ActionButton>
|
||||
) : (
|
||||
<ActionButton
|
||||
onClick={handlePlay}
|
||||
variant="primary"
|
||||
size="lg"
|
||||
disabled={!hasSongsInQueue}
|
||||
>
|
||||
▶️ Play
|
||||
</ActionButton>
|
||||
)}
|
||||
|
||||
{currentState !== PlayerState.stopped && (
|
||||
<ActionButton
|
||||
onClick={handleStop}
|
||||
variant="danger"
|
||||
size="sm"
|
||||
>
|
||||
⏹️ Stop
|
||||
</ActionButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-xs text-gray-500 text-center">
|
||||
Admin controls - Only visible to admin users
|
||||
{!hasSongsInQueue && (
|
||||
<div className="mt-1 text-orange-600">
|
||||
Add songs to queue to enable playback controls
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlayerControls;
|
||||
@ -37,9 +37,9 @@ const SongItem: React.FC<SongItemProps> = ({
|
||||
case 'queue':
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
{isAdmin && (
|
||||
{isAdmin && onRemoveFromQueue && (
|
||||
<ActionButton
|
||||
onClick={onRemoveFromQueue || (() => {})}
|
||||
onClick={onRemoveFromQueue}
|
||||
variant="danger"
|
||||
size="sm"
|
||||
>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
export { default as ActionButton } from './ActionButton';
|
||||
export { default as EmptyState } from './EmptyState';
|
||||
export { default as Toast } from './Toast';
|
||||
export { default as ActionButton } from './ActionButton';
|
||||
export { default as ErrorBoundary } from './ErrorBoundary';
|
||||
export { default as InfiniteScrollList } from './InfiniteScrollList';
|
||||
export { default as SongItem } from './SongItem';
|
||||
export { default as ErrorBoundary } from './ErrorBoundary';
|
||||
export { default as PlayerControls } from './PlayerControls';
|
||||
@ -1,16 +1,19 @@
|
||||
import React, { useState } from 'react';
|
||||
import { InfiniteScrollList, ActionButton } from '../../components/common';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { ActionButton } from '../../components/common';
|
||||
import { useArtists } from '../../hooks';
|
||||
import { useAppSelector } from '../../redux';
|
||||
import { selectSongs } from '../../redux';
|
||||
|
||||
|
||||
const Artists: React.FC = () => {
|
||||
const {
|
||||
artists,
|
||||
allArtists,
|
||||
searchTerm,
|
||||
hasMore,
|
||||
loadMore,
|
||||
handleSearchChange,
|
||||
getSongsByArtist,
|
||||
getSongCountByArtist,
|
||||
handleAddToQueue,
|
||||
handleToggleFavorite,
|
||||
} = useArtists();
|
||||
@ -18,10 +21,40 @@ const Artists: React.FC = () => {
|
||||
const songs = useAppSelector(selectSongs);
|
||||
const songsCount = Object.keys(songs).length;
|
||||
const [selectedArtist, setSelectedArtist] = useState<string | null>(null);
|
||||
const observerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Intersection Observer for infinite scrolling
|
||||
useEffect(() => {
|
||||
console.log('Artists - Setting up observer:', { hasMore, songsCount, itemsLength: artists.length });
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
console.log('Artists - Intersection detected:', {
|
||||
isIntersecting: entries[0].isIntersecting,
|
||||
hasMore,
|
||||
songsCount
|
||||
});
|
||||
|
||||
if (entries[0].isIntersecting && hasMore && songsCount > 0) {
|
||||
console.log('Artists - Loading more items');
|
||||
loadMore();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
|
||||
if (observerRef.current) {
|
||||
observer.observe(observerRef.current);
|
||||
}
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [loadMore, hasMore, songsCount, artists.length]);
|
||||
|
||||
// Debug logging
|
||||
console.log('Artists component - artists count:', artists.length);
|
||||
console.log('Artists component - selected artist:', selectedArtist);
|
||||
useEffect(() => {
|
||||
console.log('Artists component - artists count:', artists.length);
|
||||
console.log('Artists component - selected artist:', selectedArtist);
|
||||
}, [artists.length, selectedArtist]);
|
||||
|
||||
const handleArtistClick = (artist: string) => {
|
||||
setSelectedArtist(artist);
|
||||
@ -56,7 +89,7 @@ const Artists: React.FC = () => {
|
||||
|
||||
{/* Debug info */}
|
||||
<div className="mt-2 text-sm text-gray-500">
|
||||
Total songs loaded: {songsCount} | Showing: {artists.length} artists | Search term: "{searchTerm}"
|
||||
Total songs loaded: {songsCount} | Showing: {artists.length} of {allArtists.length} artists | Search term: "{searchTerm}"
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -95,7 +128,7 @@ const Artists: React.FC = () => {
|
||||
{artist}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{getSongsByArtist(artist).length} song{getSongsByArtist(artist).length !== 1 ? 's' : ''}
|
||||
{getSongCountByArtist(artist)} song{getSongCountByArtist(artist) !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0 ml-4">
|
||||
@ -109,14 +142,30 @@ const Artists: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Infinite scroll trigger */}
|
||||
{hasMore && (
|
||||
<div
|
||||
ref={observerRef}
|
||||
className="py-4 text-center text-gray-500"
|
||||
>
|
||||
<div className="inline-flex items-center">
|
||||
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Loading more artists...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Artist Songs Modal */}
|
||||
{selectedArtist && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[80vh] overflow-hidden">
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[9999]" style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0 }}>
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[80vh] overflow-hidden" style={{ backgroundColor: 'white', zIndex: 10000 }}>
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold text-gray-900">
|
||||
@ -133,19 +182,38 @@ const Artists: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto max-h-[60vh]">
|
||||
<InfiniteScrollList
|
||||
items={selectedArtistSongs}
|
||||
isLoading={false}
|
||||
hasMore={false}
|
||||
onLoadMore={() => {}}
|
||||
onAddToQueue={handleAddToQueue}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
context="search"
|
||||
title=""
|
||||
emptyTitle="No songs found"
|
||||
emptyMessage="No songs found for this artist"
|
||||
debugInfo=""
|
||||
/>
|
||||
<div className="divide-y divide-gray-200">
|
||||
{selectedArtistSongs.map((song) => (
|
||||
<div key={song.key} className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-medium text-gray-900">
|
||||
{song.title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{song.artist}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0 ml-4 flex items-center space-x-2">
|
||||
<ActionButton
|
||||
onClick={() => handleAddToQueue(song)}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
Add to Queue
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
onClick={() => handleToggleFavorite(song)}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
{song.favorite ? 'Remove from Favorites' : 'Add to Favorites'}
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import React from 'react';
|
||||
import { SongItem, EmptyState, ActionButton } from '../../components/common';
|
||||
import { SongItem, EmptyState, ActionButton, PlayerControls } from '../../components/common';
|
||||
import { useQueue } from '../../hooks';
|
||||
import { useAppSelector } from '../../redux';
|
||||
import { selectQueue } from '../../redux';
|
||||
import { selectQueue, selectPlayerState } from '../../redux';
|
||||
import { PlayerState } from '../../types';
|
||||
|
||||
const Queue: React.FC = () => {
|
||||
const {
|
||||
@ -16,11 +17,19 @@ const Queue: React.FC = () => {
|
||||
} = useQueue();
|
||||
|
||||
const queue = useAppSelector(selectQueue);
|
||||
const playerState = useAppSelector(selectPlayerState);
|
||||
const queueCount = Object.keys(queue).length;
|
||||
|
||||
// Debug logging
|
||||
console.log('Queue component - queue count:', queueCount);
|
||||
console.log('Queue component - queue items:', queueItems);
|
||||
console.log('Queue component - player state:', playerState);
|
||||
|
||||
// Check if first item can be deleted (only when stopped or paused)
|
||||
const canDeleteFirstItem = playerState?.state === PlayerState.stopped || playerState?.state === PlayerState.paused;
|
||||
|
||||
console.log('Queue component - canDeleteFirstItem:', canDeleteFirstItem);
|
||||
console.log('Queue component - canReorder:', canReorder);
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
@ -36,6 +45,11 @@ const Queue: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Player Controls - Only visible to admin users */}
|
||||
<div className="mb-6">
|
||||
<PlayerControls />
|
||||
</div>
|
||||
|
||||
{/* Queue List */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
{queueCount === 0 ? (
|
||||
@ -60,7 +74,9 @@ const Queue: React.FC = () => {
|
||||
/>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-200">
|
||||
{queueItems.map((queueItem, index) => (
|
||||
{queueItems.map((queueItem, index) => {
|
||||
console.log(`Queue item ${index}: order=${queueItem.order}, key=${queueItem.key}`);
|
||||
return (
|
||||
<div key={queueItem.key} className="flex items-center">
|
||||
{/* Order Number */}
|
||||
<div className="flex-shrink-0 w-12 h-12 flex items-center justify-center bg-gray-100 text-gray-600 font-medium">
|
||||
@ -72,7 +88,12 @@ const Queue: React.FC = () => {
|
||||
<SongItem
|
||||
song={queueItem.song}
|
||||
context="queue"
|
||||
onRemoveFromQueue={() => handleRemoveFromQueue(queueItem)}
|
||||
onRemoveFromQueue={
|
||||
// Only allow removal of first item when stopped or paused
|
||||
index === 0 && !canDeleteFirstItem
|
||||
? undefined
|
||||
: () => handleRemoveFromQueue(queueItem)
|
||||
}
|
||||
onToggleFavorite={() => handleToggleFavorite(queueItem.song)}
|
||||
isAdmin={canReorder}
|
||||
/>
|
||||
@ -89,26 +110,29 @@ const Queue: React.FC = () => {
|
||||
{/* Admin Controls */}
|
||||
{canReorder && (
|
||||
<div className="flex-shrink-0 px-4 py-2 flex flex-col gap-1">
|
||||
<ActionButton
|
||||
onClick={() => handleMoveUp(queueItem)}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={index === 0}
|
||||
>
|
||||
↑
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
onClick={() => handleMoveDown(queueItem)}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={index === queueItems.length - 1}
|
||||
>
|
||||
↓
|
||||
</ActionButton>
|
||||
{queueItem.order > 2 && (
|
||||
<ActionButton
|
||||
onClick={() => handleMoveUp(queueItem)}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
↑
|
||||
</ActionButton>
|
||||
)}
|
||||
{queueItem.order > 1 && queueItem.order < queueItems.length && (
|
||||
<ActionButton
|
||||
onClick={() => handleMoveDown(queueItem)}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
↓
|
||||
</ActionButton>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -178,7 +178,7 @@ const SongLists: React.FC = () => {
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold text-gray-900">
|
||||
TEST MODAL - {finalSelectedList.title}
|
||||
{finalSelectedList.title}
|
||||
</h2>
|
||||
<ActionButton
|
||||
onClick={handleCloseSongList}
|
||||
|
||||
@ -49,7 +49,24 @@ export const queueService = {
|
||||
// Add song to queue
|
||||
addToQueue: async (controllerName: string, queueItem: Omit<QueueItem, 'key'>) => {
|
||||
const queueRef = ref(database, `controllers/${controllerName}/player/queue`);
|
||||
return await push(queueRef, queueItem);
|
||||
|
||||
// Get current queue to find the next sequential key
|
||||
const snapshot = await get(queueRef);
|
||||
const currentQueue = snapshot.exists() ? snapshot.val() : {};
|
||||
|
||||
// Find the next available numerical key
|
||||
const existingKeys = Object.keys(currentQueue)
|
||||
.filter(key => /^\d+$/.test(key)) // Only consider numerical keys
|
||||
.map(key => parseInt(key, 10))
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
const nextKey = existingKeys.length > 0 ? Math.max(...existingKeys) + 1 : 0;
|
||||
|
||||
// Add the item with the sequential key
|
||||
const newItemRef = ref(database, `controllers/${controllerName}/player/queue/${nextKey}`);
|
||||
await set(newItemRef, queueItem);
|
||||
|
||||
return { key: nextKey.toString() };
|
||||
},
|
||||
|
||||
// Remove song from queue
|
||||
@ -64,6 +81,46 @@ export const queueService = {
|
||||
await update(queueItemRef, updates);
|
||||
},
|
||||
|
||||
// Clean up queue with inconsistent keys (migrate push ID keys to sequential numerical keys)
|
||||
cleanupQueueKeys: async (controllerName: string) => {
|
||||
const queueRef = ref(database, `controllers/${controllerName}/player/queue`);
|
||||
const snapshot = await get(queueRef);
|
||||
|
||||
if (!snapshot.exists()) return;
|
||||
|
||||
const queue = snapshot.val();
|
||||
const updates: Record<string, QueueItem | null> = {};
|
||||
let hasChanges = false;
|
||||
|
||||
// Find all push ID keys (non-numerical keys)
|
||||
const pushIdKeys = Object.keys(queue).filter(key => !/^\d+$/.test(key));
|
||||
|
||||
if (pushIdKeys.length === 0) return; // No cleanup needed
|
||||
|
||||
// Get existing numerical keys to find the next available key
|
||||
const existingNumericalKeys = Object.keys(queue)
|
||||
.filter(key => /^\d+$/.test(key))
|
||||
.map(key => parseInt(key, 10))
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
let nextKey = existingNumericalKeys.length > 0 ? Math.max(...existingNumericalKeys) + 1 : 0;
|
||||
|
||||
// Migrate push ID items to sequential numerical keys
|
||||
pushIdKeys.forEach(pushIdKey => {
|
||||
const item = queue[pushIdKey];
|
||||
// Remove the old item with push ID
|
||||
updates[pushIdKey] = null;
|
||||
// Add the item with sequential numerical key
|
||||
updates[nextKey.toString()] = item;
|
||||
nextKey++;
|
||||
hasChanges = true;
|
||||
});
|
||||
|
||||
if (hasChanges) {
|
||||
await update(queueRef, updates);
|
||||
}
|
||||
},
|
||||
|
||||
// Listen to queue changes
|
||||
subscribeToQueue: (controllerName: string, callback: (data: Record<string, QueueItem>) => void) => {
|
||||
const queueRef = ref(database, `controllers/${controllerName}/player/queue`);
|
||||
@ -83,6 +140,12 @@ export const playerService = {
|
||||
await update(playerRef, state);
|
||||
},
|
||||
|
||||
// Update just the player state value
|
||||
updatePlayerStateValue: async (controllerName: string, stateValue: string) => {
|
||||
const stateRef = ref(database, `controllers/${controllerName}/player/state`);
|
||||
await set(stateRef, stateValue);
|
||||
},
|
||||
|
||||
// Listen to player state changes
|
||||
subscribeToPlayerState: (controllerName: string, callback: (data: Controller['player']) => void) => {
|
||||
const playerRef = ref(database, `controllers/${controllerName}/player`);
|
||||
|
||||
@ -4,6 +4,8 @@ import { useSongOperations } from './useSongOperations';
|
||||
import { useToast } from './useToast';
|
||||
import type { Song } from '../types';
|
||||
|
||||
const ITEMS_PER_PAGE = 20;
|
||||
|
||||
export const useArtists = () => {
|
||||
const allArtists = useAppSelector(selectArtistsArray);
|
||||
const allSongs = useAppSelector(selectSongsArray);
|
||||
@ -11,6 +13,25 @@ export const useArtists = () => {
|
||||
const { showSuccess, showError } = useToast();
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
// Pre-compute songs by artist and song counts for performance
|
||||
const songsByArtist = useMemo(() => {
|
||||
const songsMap = new Map<string, Song[]>();
|
||||
const countsMap = new Map<string, number>();
|
||||
|
||||
allSongs.forEach(song => {
|
||||
const artist = song.artist.toLowerCase();
|
||||
if (!songsMap.has(artist)) {
|
||||
songsMap.set(artist, []);
|
||||
countsMap.set(artist, 0);
|
||||
}
|
||||
songsMap.get(artist)!.push(song);
|
||||
countsMap.set(artist, countsMap.get(artist)! + 1);
|
||||
});
|
||||
|
||||
return { songsMap, countsMap };
|
||||
}, [allSongs]);
|
||||
|
||||
// Filter artists by search term
|
||||
const filteredArtists = useMemo(() => {
|
||||
@ -22,15 +43,45 @@ export const useArtists = () => {
|
||||
);
|
||||
}, [allArtists, searchTerm]);
|
||||
|
||||
// Get songs by artist
|
||||
// Paginate the filtered artists - show all items up to current page
|
||||
const artists = useMemo(() => {
|
||||
const endIndex = currentPage * ITEMS_PER_PAGE;
|
||||
return filteredArtists.slice(0, endIndex);
|
||||
}, [filteredArtists, currentPage]);
|
||||
|
||||
const hasMore = useMemo(() => {
|
||||
// Show "hasMore" if there are more items than currently loaded
|
||||
return filteredArtists.length > ITEMS_PER_PAGE && artists.length < filteredArtists.length;
|
||||
}, [artists.length, filteredArtists.length]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
console.log('useArtists - loadMore called:', {
|
||||
hasMore,
|
||||
currentPage,
|
||||
filteredArtistsLength: filteredArtists.length,
|
||||
artistsLength: artists.length
|
||||
});
|
||||
if (hasMore) {
|
||||
console.log('useArtists - Incrementing page from', currentPage, 'to', currentPage + 1);
|
||||
setCurrentPage(prev => prev + 1);
|
||||
} else {
|
||||
console.log('useArtists - Not loading more because hasMore is false');
|
||||
}
|
||||
}, [hasMore, currentPage, filteredArtists.length, artists.length]);
|
||||
|
||||
// Get songs by artist (now using cached data)
|
||||
const getSongsByArtist = useCallback((artistName: string) => {
|
||||
return allSongs.filter(song =>
|
||||
song.artist.toLowerCase() === artistName.toLowerCase()
|
||||
);
|
||||
}, [allSongs]);
|
||||
return songsByArtist.songsMap.get(artistName.toLowerCase()) || [];
|
||||
}, [songsByArtist.songsMap]);
|
||||
|
||||
// Get song count by artist (now using cached data)
|
||||
const getSongCountByArtist = useCallback((artistName: string) => {
|
||||
return songsByArtist.countsMap.get(artistName.toLowerCase()) || 0;
|
||||
}, [songsByArtist.countsMap]);
|
||||
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearchTerm(value);
|
||||
setCurrentPage(1); // Reset to first page when searching
|
||||
}, []);
|
||||
|
||||
const handleAddToQueue = useCallback(async (song: Song) => {
|
||||
@ -52,11 +103,16 @@ export const useArtists = () => {
|
||||
}, [toggleFavorite, showSuccess, showError]);
|
||||
|
||||
return {
|
||||
artists: filteredArtists,
|
||||
allArtists,
|
||||
artists,
|
||||
allArtists: filteredArtists,
|
||||
searchTerm,
|
||||
hasMore,
|
||||
loadMore,
|
||||
currentPage,
|
||||
totalPages: Math.ceil(filteredArtists.length / ITEMS_PER_PAGE),
|
||||
handleSearchChange,
|
||||
getSongsByArtist,
|
||||
getSongCountByArtist,
|
||||
handleAddToQueue,
|
||||
handleToggleFavorite,
|
||||
};
|
||||
|
||||
@ -1,16 +1,67 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useAppSelector, selectQueueWithUserInfo, selectQueueStats, selectCanReorderQueue } from '../redux';
|
||||
import { useSongOperations } from './useSongOperations';
|
||||
import { useToast } from './useToast';
|
||||
import { queueService } from '../firebase/services';
|
||||
import { selectControllerName } from '../redux';
|
||||
import type { QueueItem } from '../types';
|
||||
|
||||
export const useQueue = () => {
|
||||
const queueItems = useAppSelector(selectQueueWithUserInfo);
|
||||
const queueStats = useAppSelector(selectQueueStats);
|
||||
const canReorder = useAppSelector(selectCanReorderQueue);
|
||||
const controllerName = useAppSelector(selectControllerName);
|
||||
const { removeFromQueue, toggleFavorite } = useSongOperations();
|
||||
const { showSuccess, showError } = useToast();
|
||||
|
||||
// Fix queue order if needed
|
||||
const fixQueueOrder = useCallback(async () => {
|
||||
if (!controllerName || queueItems.length === 0) return;
|
||||
|
||||
// Check if any items are missing order or have inconsistent order
|
||||
const needsFix = queueItems.some((item, index) => {
|
||||
const expectedOrder = index + 1;
|
||||
return !item.order || item.order !== expectedOrder;
|
||||
});
|
||||
|
||||
if (needsFix) {
|
||||
console.log('Fixing queue order...');
|
||||
try {
|
||||
// Update all items with sequential order
|
||||
const updatePromises = queueItems.map((item, index) => {
|
||||
const newOrder = index + 1;
|
||||
if (item.key && item.order !== newOrder) {
|
||||
return queueService.updateQueueItem(controllerName, item.key, { order: newOrder });
|
||||
}
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
await Promise.all(updatePromises);
|
||||
console.log('Queue order fixed successfully');
|
||||
} catch (error) {
|
||||
console.error('Failed to fix queue order:', error);
|
||||
}
|
||||
}
|
||||
}, [controllerName, queueItems]);
|
||||
|
||||
// Fix queue order and cleanup keys on mount if needed
|
||||
useEffect(() => {
|
||||
const initializeQueue = async () => {
|
||||
if (controllerName) {
|
||||
try {
|
||||
// First cleanup any inconsistent keys
|
||||
await queueService.cleanupQueueKeys(controllerName);
|
||||
// Then fix the order
|
||||
await fixQueueOrder();
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize queue:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
initializeQueue();
|
||||
}, [controllerName, fixQueueOrder]);
|
||||
|
||||
const handleRemoveFromQueue = useCallback(async (queueItem: QueueItem) => {
|
||||
if (!queueItem.key) return;
|
||||
|
||||
@ -32,14 +83,93 @@ export const useQueue = () => {
|
||||
}, [toggleFavorite, showSuccess, showError]);
|
||||
|
||||
const handleMoveUp = useCallback(async (queueItem: QueueItem) => {
|
||||
// TODO: Implement move up logic
|
||||
console.log('Move up:', queueItem);
|
||||
}, []);
|
||||
console.log('handleMoveUp called with:', queueItem);
|
||||
console.log('Current queueItems:', queueItems);
|
||||
console.log('Controller name:', controllerName);
|
||||
|
||||
if (!controllerName || !queueItem.key || queueItem.order <= 1) {
|
||||
console.log('Early return - conditions not met:', {
|
||||
controllerName: !!controllerName,
|
||||
queueItemKey: !!queueItem.key,
|
||||
order: queueItem.order
|
||||
});
|
||||
return; // Can't move up if already at the top
|
||||
}
|
||||
|
||||
try {
|
||||
// Find the item above this one
|
||||
const itemAbove = queueItems.find(item => item.order === queueItem.order - 1);
|
||||
console.log('Item above:', itemAbove);
|
||||
|
||||
if (!itemAbove || !itemAbove.key) {
|
||||
console.log('No item above found');
|
||||
showError('Cannot move item up');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Swapping orders:', {
|
||||
currentItem: { key: queueItem.key, order: queueItem.order },
|
||||
itemAbove: { key: itemAbove.key, order: itemAbove.order }
|
||||
});
|
||||
|
||||
// Swap the order values
|
||||
await Promise.all([
|
||||
queueService.updateQueueItem(controllerName, queueItem.key, { order: queueItem.order - 1 }),
|
||||
queueService.updateQueueItem(controllerName, itemAbove.key, { order: queueItem.order })
|
||||
]);
|
||||
|
||||
console.log('Move up completed successfully');
|
||||
showSuccess('Song moved up in queue');
|
||||
} catch (error) {
|
||||
console.error('Failed to move song up:', error);
|
||||
showError('Failed to move song up');
|
||||
}
|
||||
}, [controllerName, queueItems, showSuccess, showError]);
|
||||
|
||||
const handleMoveDown = useCallback(async (queueItem: QueueItem) => {
|
||||
// TODO: Implement move down logic
|
||||
console.log('Move down:', queueItem);
|
||||
}, []);
|
||||
console.log('handleMoveDown called with:', queueItem);
|
||||
console.log('Current queueItems:', queueItems);
|
||||
console.log('Controller name:', controllerName);
|
||||
|
||||
if (!controllerName || !queueItem.key || queueItem.order >= queueItems.length) {
|
||||
console.log('Early return - conditions not met:', {
|
||||
controllerName: !!controllerName,
|
||||
queueItemKey: !!queueItem.key,
|
||||
order: queueItem.order,
|
||||
queueLength: queueItems.length
|
||||
});
|
||||
return; // Can't move down if already at the bottom
|
||||
}
|
||||
|
||||
try {
|
||||
// Find the item below this one
|
||||
const itemBelow = queueItems.find(item => item.order === queueItem.order + 1);
|
||||
console.log('Item below:', itemBelow);
|
||||
|
||||
if (!itemBelow || !itemBelow.key) {
|
||||
console.log('No item below found');
|
||||
showError('Cannot move item down');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Swapping orders:', {
|
||||
currentItem: { key: queueItem.key, order: queueItem.order },
|
||||
itemBelow: { key: itemBelow.key, order: itemBelow.order }
|
||||
});
|
||||
|
||||
// Swap the order values
|
||||
await Promise.all([
|
||||
queueService.updateQueueItem(controllerName, queueItem.key, { order: queueItem.order + 1 }),
|
||||
queueService.updateQueueItem(controllerName, itemBelow.key, { order: queueItem.order })
|
||||
]);
|
||||
|
||||
console.log('Move down completed successfully');
|
||||
showSuccess('Song moved down in queue');
|
||||
} catch (error) {
|
||||
console.error('Failed to move song down:', error);
|
||||
showError('Failed to move song down');
|
||||
}
|
||||
}, [controllerName, queueItems, showSuccess, showError]);
|
||||
|
||||
return {
|
||||
queueItems,
|
||||
|
||||
@ -15,7 +15,12 @@ export const useSongOperations = () => {
|
||||
}
|
||||
|
||||
try {
|
||||
const nextOrder = Object.keys(currentQueue).length + 1;
|
||||
// Calculate the next order by finding the highest order value and adding 1
|
||||
const queueItems = Object.values(currentQueue);
|
||||
const maxOrder = queueItems.length > 0
|
||||
? Math.max(...queueItems.map(item => item.order || 0))
|
||||
: 0;
|
||||
const nextOrder = maxOrder + 1;
|
||||
|
||||
const queueItem: Omit<QueueItem, 'key'> = {
|
||||
order: nextOrder,
|
||||
|
||||
@ -159,7 +159,19 @@ export const selectHistory = (state: { controller: ControllerState }) => state.c
|
||||
export const selectTopPlayed = (state: { controller: ControllerState }) => state.controller.data?.topPlayed || {};
|
||||
export const selectNewSongs = (state: { controller: ControllerState }) => state.controller.data?.newSongs || {};
|
||||
export const selectSongList = (state: { controller: ControllerState }) => state.controller.data?.songList || {};
|
||||
export const selectPlayerState = (state: { controller: ControllerState }) => state.controller.data?.player?.state;
|
||||
export const selectPlayerState = (state: { controller: ControllerState }) => {
|
||||
const playerState = state.controller.data?.player?.state;
|
||||
|
||||
// Handle both structures:
|
||||
// 1. player.state: "Playing" (direct string - what's actually in Firebase)
|
||||
// 2. player.state: { state: "Playing" } (nested object - what types expect)
|
||||
|
||||
if (typeof playerState === 'string') {
|
||||
return { state: playerState };
|
||||
}
|
||||
|
||||
return playerState;
|
||||
};
|
||||
export const selectSettings = (state: { controller: ControllerState }) => state.controller.data?.player?.settings;
|
||||
export const selectSingers = (state: { controller: ControllerState }) => state.controller.data?.player?.singers || {};
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user