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

This commit is contained in:
Matt Bruce 2025-07-18 14:20:06 -05:00
parent 8663a414d8
commit d4e5c4d5ae
19 changed files with 4071 additions and 111 deletions

6
database.rules.json Normal file
View File

@ -0,0 +1,6 @@
{
"rules": {
".read": true,
".write": true
}
}

11
firebase.json Normal file
View File

@ -0,0 +1,11 @@
{
"functions": {
"source": "functions",
"predeploy": [
"npm --prefix \"$RESOURCE_DIR\" run build"
]
},
"database": {
"rules": "database.rules.json"
}
}

160
functions/lib/index.js Normal file
View File

@ -0,0 +1,160 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.recalculateTopPlayed = exports.updateTopPlayedOnHistoryChange = void 0;
const functions = require("firebase-functions");
const admin = require("firebase-admin");
// Initialize Firebase Admin
admin.initializeApp();
// Database reference
const db = admin.database();
/**
* Cloud Function that triggers when a song is added to history
* This function aggregates all history items to create/update the topPlayed collection
* based on unique combinations of artist and title (not path)
*/
exports.updateTopPlayedOnHistoryChange = functions.database
.ref('/controllers/{controllerName}/history/{historyId}')
.onCreate(async (snapshot, context) => {
const { controllerName } = context.params;
console.log(`TopPlayed update triggered for controller: ${controllerName}`);
try {
// Get the controller reference
const controllerRef = db.ref(`/controllers/${controllerName}`);
// Get all history items for this controller
const historySnapshot = await controllerRef.child('history').once('value');
const historyData = historySnapshot.val();
if (!historyData) {
console.log('No history data found, skipping TopPlayed update');
return;
}
// Aggregate history items by artist + title combination
const aggregation = {};
Object.values(historyData).forEach((song) => {
const historySong = song;
if (historySong && historySong.artist && historySong.title) {
// Create a unique key based on artist and title (case-insensitive)
// Replace invalid Firebase key characters with underscores
const sanitizedArtist = historySong.artist.toLowerCase().trim().replace(/[.#$/[\]]/g, '_');
const sanitizedTitle = historySong.title.toLowerCase().trim().replace(/[.#$/[\]]/g, '_');
const key = `${sanitizedArtist}_${sanitizedTitle}`;
if (aggregation[key]) {
// Increment count for existing song
aggregation[key].count += historySong.count || 1;
}
else {
// Create new entry
aggregation[key] = {
artist: historySong.artist,
title: historySong.title,
count: historySong.count || 1
};
}
}
});
// Convert aggregation to array, sort by count (descending), and take top 100
const sortedSongs = Object.entries(aggregation)
.map(([key, songData]) => ({
key,
artist: songData.artist,
title: songData.title,
count: songData.count
}))
.sort((a, b) => b.count - a.count) // Sort by count descending
.slice(0, 100); // Take only top 100
// Convert back to object format for Firebase
const topPlayedData = {};
sortedSongs.forEach((song) => {
topPlayedData[song.key] = {
artist: song.artist,
title: song.title,
count: song.count
};
});
// Update the topPlayed collection
await controllerRef.child('topPlayed').set(topPlayedData);
console.log(`Successfully updated TopPlayed for controller ${controllerName} with ${Object.keys(topPlayedData).length} unique songs`);
}
catch (error) {
console.error('Error updating TopPlayed:', error);
throw error;
}
});
/**
* Alternative function that can be called manually to recalculate TopPlayed
* This is useful for initial setup or data migration
*/
exports.recalculateTopPlayed = functions.https.onCall(async (data, context) => {
const { controllerName } = data;
if (!controllerName) {
throw new functions.https.HttpsError('invalid-argument', 'controllerName is required');
}
console.log(`Manual TopPlayed recalculation requested for controller: ${controllerName}`);
try {
// Get the controller reference
const controllerRef = db.ref(`/controllers/${controllerName}`);
// Get all history items for this controller
const historySnapshot = await controllerRef.child('history').once('value');
const historyData = historySnapshot.val();
if (!historyData) {
console.log('No history data found, returning empty TopPlayed');
await controllerRef.child('topPlayed').set({});
return { success: true, message: 'No history data found, TopPlayed cleared' };
}
// Aggregate history items by artist + title combination
const aggregation = {};
Object.values(historyData).forEach((song) => {
const historySong = song;
if (historySong && historySong.artist && historySong.title) {
// Create a unique key based on artist and title (case-insensitive)
// Replace invalid Firebase key characters with underscores
const sanitizedArtist = historySong.artist.toLowerCase().trim().replace(/[.#$/[\]]/g, '_');
const sanitizedTitle = historySong.title.toLowerCase().trim().replace(/[.#$/[\]]/g, '_');
const key = `${sanitizedArtist}_${sanitizedTitle}`;
if (aggregation[key]) {
// Increment count for existing song
aggregation[key].count += historySong.count || 1;
}
else {
// Create new entry
aggregation[key] = {
artist: historySong.artist,
title: historySong.title,
count: historySong.count || 1
};
}
}
});
// Convert aggregation to array, sort by count (descending), and take top 100
const sortedSongs = Object.entries(aggregation)
.map(([key, songData]) => ({
key,
artist: songData.artist,
title: songData.title,
count: songData.count
}))
.sort((a, b) => b.count - a.count) // Sort by count descending
.slice(0, 100); // Take only top 100
// Convert back to object format for Firebase
const topPlayedData = {};
sortedSongs.forEach((song) => {
topPlayedData[song.key] = {
artist: song.artist,
title: song.title,
count: song.count
};
});
// Update the topPlayed collection
await controllerRef.child('topPlayed').set(topPlayedData);
console.log(`Successfully recalculated TopPlayed for controller ${controllerName} with ${Object.keys(topPlayedData).length} unique songs`);
return {
success: true,
message: `TopPlayed recalculated successfully`,
songCount: Object.keys(topPlayedData).length
};
}
catch (error) {
console.error('Error recalculating TopPlayed:', error);
throw new functions.https.HttpsError('internal', 'Failed to recalculate TopPlayed');
}
});
//# sourceMappingURL=index.js.map

File diff suppressed because one or more lines are too long

3428
functions/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
functions/package.json Normal file
View File

@ -0,0 +1,25 @@
{
"name": "functions",
"scripts": {
"build": "tsc",
"build:watch": "tsc --watch",
"serve": "npm run build && firebase emulators:start --only functions",
"shell": "npm run build && firebase functions:shell",
"start": "npm run shell",
"deploy": "firebase deploy --only functions",
"logs": "firebase functions:log"
},
"engines": {
"node": "18"
},
"main": "lib/index.js",
"dependencies": {
"firebase-admin": "^11.8.0",
"firebase-functions": "^4.3.1"
},
"devDependencies": {
"typescript": "^4.9.0",
"@types/node": "^18.11.9"
},
"private": true
}

211
functions/src/index.ts Normal file
View File

@ -0,0 +1,211 @@
import * as functions from 'firebase-functions';
import * as admin from 'firebase-admin';
// Initialize Firebase Admin
admin.initializeApp();
// Database reference
const db = admin.database();
// Types for our data structures
interface HistorySong {
artist: string;
title: string;
path: string;
count?: number;
disabled?: boolean;
favorite?: boolean;
date?: string;
key?: string;
}
interface TopPlayed {
artist: string;
title: string;
count: number;
key?: string;
}
interface HistoryAggregation {
[key: string]: {
artist: string;
title: string;
count: number;
};
}
/**
* Cloud Function that triggers when a song is added to history
* This function aggregates all history items to create/update the topPlayed collection
* based on unique combinations of artist and title (not path)
*/
export const updateTopPlayedOnHistoryChange = functions.database
.ref('/controllers/{controllerName}/history/{historyId}')
.onCreate(async (snapshot, context) => {
const { controllerName } = context.params;
console.log(`TopPlayed update triggered for controller: ${controllerName}`);
try {
// Get the controller reference
const controllerRef = db.ref(`/controllers/${controllerName}`);
// Get all history items for this controller
const historySnapshot = await controllerRef.child('history').once('value');
const historyData = historySnapshot.val();
if (!historyData) {
console.log('No history data found, skipping TopPlayed update');
return;
}
// Aggregate history items by artist + title combination
const aggregation: HistoryAggregation = {};
Object.values(historyData).forEach((song: unknown) => {
const historySong = song as HistorySong;
if (historySong && historySong.artist && historySong.title) {
// Create a unique key based on artist and title (case-insensitive)
// Replace invalid Firebase key characters with underscores
const sanitizedArtist = historySong.artist.toLowerCase().trim().replace(/[.#$/[\]]/g, '_');
const sanitizedTitle = historySong.title.toLowerCase().trim().replace(/[.#$/[\]]/g, '_');
const key = `${sanitizedArtist}_${sanitizedTitle}`;
if (aggregation[key]) {
// Increment count for existing song
aggregation[key].count += historySong.count || 1;
} else {
// Create new entry
aggregation[key] = {
artist: historySong.artist,
title: historySong.title,
count: historySong.count || 1
};
}
}
});
// Convert aggregation to array, sort by count (descending), and take top 100
const sortedSongs = Object.entries(aggregation)
.map(([key, songData]) => ({
key,
artist: songData.artist,
title: songData.title,
count: songData.count
}))
.sort((a, b) => b.count - a.count) // Sort by count descending
.slice(0, 100); // Take only top 100
// Convert back to object format for Firebase
const topPlayedData: { [key: string]: TopPlayed } = {};
sortedSongs.forEach((song) => {
topPlayedData[song.key] = {
artist: song.artist,
title: song.title,
count: song.count
};
});
// Update the topPlayed collection
await controllerRef.child('topPlayed').set(topPlayedData);
console.log(`Successfully updated TopPlayed for controller ${controllerName} with ${Object.keys(topPlayedData).length} unique songs`);
} catch (error) {
console.error('Error updating TopPlayed:', error);
throw error;
}
});
/**
* Alternative function that can be called manually to recalculate TopPlayed
* This is useful for initial setup or data migration
*/
export const recalculateTopPlayed = functions.https.onCall(async (data, context) => {
const { controllerName } = data;
if (!controllerName) {
throw new functions.https.HttpsError('invalid-argument', 'controllerName is required');
}
console.log(`Manual TopPlayed recalculation requested for controller: ${controllerName}`);
try {
// Get the controller reference
const controllerRef = db.ref(`/controllers/${controllerName}`);
// Get all history items for this controller
const historySnapshot = await controllerRef.child('history').once('value');
const historyData = historySnapshot.val();
if (!historyData) {
console.log('No history data found, returning empty TopPlayed');
await controllerRef.child('topPlayed').set({});
return { success: true, message: 'No history data found, TopPlayed cleared' };
}
// Aggregate history items by artist + title combination
const aggregation: HistoryAggregation = {};
Object.values(historyData).forEach((song: unknown) => {
const historySong = song as HistorySong;
if (historySong && historySong.artist && historySong.title) {
// Create a unique key based on artist and title (case-insensitive)
// Replace invalid Firebase key characters with underscores
const sanitizedArtist = historySong.artist.toLowerCase().trim().replace(/[.#$/[\]]/g, '_');
const sanitizedTitle = historySong.title.toLowerCase().trim().replace(/[.#$/[\]]/g, '_');
const key = `${sanitizedArtist}_${sanitizedTitle}`;
if (aggregation[key]) {
// Increment count for existing song
aggregation[key].count += historySong.count || 1;
} else {
// Create new entry
aggregation[key] = {
artist: historySong.artist,
title: historySong.title,
count: historySong.count || 1
};
}
}
});
// Convert aggregation to array, sort by count (descending), and take top 100
const sortedSongs = Object.entries(aggregation)
.map(([key, songData]) => ({
key,
artist: songData.artist,
title: songData.title,
count: songData.count
}))
.sort((a, b) => b.count - a.count) // Sort by count descending
.slice(0, 100); // Take only top 100
// Convert back to object format for Firebase
const topPlayedData: { [key: string]: TopPlayed } = {};
sortedSongs.forEach((song) => {
topPlayedData[song.key] = {
artist: song.artist,
title: song.title,
count: song.count
};
});
// Update the topPlayed collection
await controllerRef.child('topPlayed').set(topPlayedData);
console.log(`Successfully recalculated TopPlayed for controller ${controllerName} with ${Object.keys(topPlayedData).length} unique songs`);
return {
success: true,
message: `TopPlayed recalculated successfully`,
songCount: Object.keys(topPlayedData).length
};
} catch (error) {
console.error('Error recalculating TopPlayed:', error);
throw new functions.https.HttpsError('internal', 'Failed to recalculate TopPlayed');
}
});

16
functions/tsconfig.json Normal file
View File

@ -0,0 +1,16 @@
{
"compilerOptions": {
"module": "commonjs",
"noImplicitReturns": true,
"noUnusedLocals": true,
"outDir": "lib",
"sourceMap": true,
"strict": true,
"target": "es2017",
"skipLibCheck": true
},
"compileOnSave": true,
"include": [
"src"
]
}

View File

@ -1,6 +1,6 @@
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import Layout from './components/Layout/Layout'; import Layout from './components/Layout/Layout';
import { Search, Queue, History, Favorites, NewSongs, Artists, Singers, SongLists } from './features'; import { Search, Queue, History, Favorites, NewSongs, Artists, Singers, SongLists, Settings } from './features';
import TopPlayed from './features/TopPlayed/Top100'; import TopPlayed from './features/TopPlayed/Top100';
import { FirebaseProvider } from './firebase/FirebaseProvider'; import { FirebaseProvider } from './firebase/FirebaseProvider';
import { ErrorBoundary } from './components/common'; import { ErrorBoundary } from './components/common';
@ -24,6 +24,7 @@ function App() {
<Route path="/history" element={<History />} /> <Route path="/history" element={<History />} />
<Route path="/top-played" element={<TopPlayed />} /> <Route path="/top-played" element={<TopPlayed />} />
<Route path="/singers" element={<Singers />} /> <Route path="/singers" element={<Singers />} />
<Route path="/settings" element={<Settings />} />
<Route path="*" element={<Navigate to="/queue" replace />} /> <Route path="*" element={<Navigate to="/queue" replace />} />
</Routes> </Routes>
</Layout> </Layout>

View File

@ -79,8 +79,9 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
style={{ style={{
position: 'relative', position: 'relative',
zIndex: 1, zIndex: 1,
height: 'calc(100vh - 56px)', // Subtract header height height: 'calc(100vh - 64px)', // Adjust header height
overflow: 'hidden' // Prevent main content from scrolling overflow: 'auto', // Allow scrolling
paddingBottom: '40px' // Add more bottom padding
}} }}
> >
{children} {children}

View File

@ -3,13 +3,16 @@ import { IonMenu, IonHeader, IonToolbar, IonTitle, IonContent, IonList, IonItem,
import { timeOutline, settingsOutline, listOutline, musicalNotesOutline, peopleOutline, peopleCircleOutline, heartOutline, searchOutline, starOutline } from 'ionicons/icons'; import { timeOutline, settingsOutline, listOutline, musicalNotesOutline, peopleOutline, peopleCircleOutline, heartOutline, searchOutline, starOutline } from 'ionicons/icons';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { PlayerControls } from '../common'; import { PlayerControls } from '../common';
import { useAppSelector } from '../../redux';
import { selectIsAdmin } from '../../redux';
const Navigation: React.FC = () => { const Navigation: React.FC = () => {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const [isLargeScreen, setIsLargeScreen] = useState(false); const [isLargeScreen, setIsLargeScreen] = useState(false);
const isAdmin = useAppSelector(selectIsAdmin);
const navItems = [ const allNavItems = [
{ path: '/search', label: 'Search', icon: searchOutline }, { path: '/search', label: 'Search', icon: searchOutline },
{ path: '/queue', label: 'Queue', icon: musicalNotesOutline }, { path: '/queue', label: 'Queue', icon: musicalNotesOutline },
{ path: '/singers', label: 'Singers', icon: peopleCircleOutline }, { path: '/singers', label: 'Singers', icon: peopleCircleOutline },
@ -19,9 +22,12 @@ const Navigation: React.FC = () => {
{ path: '/history', label: 'History', icon: timeOutline }, { path: '/history', label: 'History', icon: timeOutline },
{ path: '/new-songs', label: 'New Songs', icon: listOutline }, { path: '/new-songs', label: 'New Songs', icon: listOutline },
{ path: '/song-lists', label: 'Song Lists', icon: listOutline }, { path: '/song-lists', label: 'Song Lists', icon: listOutline },
{ path: '/settings', label: 'Settings', icon: settingsOutline }, { path: '/settings', label: 'Settings', icon: settingsOutline, adminOnly: true },
]; ];
// Filter navigation items based on admin status
const navItems = allNavItems.filter(item => !item.adminOnly || isAdmin);
// Check screen size for responsive menu behavior // Check screen size for responsive menu behavior
useEffect(() => { useEffect(() => {
const checkScreenSize = () => { const checkScreenSize = () => {

View File

@ -44,7 +44,6 @@ const History: React.FC = () => {
subtitle={`${historyCount} items loaded`} subtitle={`${historyCount} items loaded`}
/> />
<div style={{ height: '100%', overflowY: 'auto' }}>
<InfiniteScrollList<Song> <InfiniteScrollList<Song>
items={historyItems} items={historyItems}
isLoading={historyCount === 0} isLoading={historyCount === 0}
@ -68,7 +67,6 @@ const History: React.FC = () => {
loadingTitle="Loading history..." loadingTitle="Loading history..."
loadingMessage="Please wait while history data is being loaded" loadingMessage="Please wait while history data is being loaded"
/> />
</div>
</> </>
); );
}; };

View File

@ -42,8 +42,6 @@ const Queue: React.FC = () => {
console.log('Queue component - canDeleteItems:', canDeleteItems); console.log('Queue component - canDeleteItems:', canDeleteItems);
console.log('Queue component - canReorder:', canReorder); console.log('Queue component - canReorder:', canReorder);
// Determine if currently playing
const isPlaying = playerState?.state === PlayerState.playing;
// Update list items when queue changes // Update list items when queue changes
useEffect(() => { useEffect(() => {

View File

@ -0,0 +1,103 @@
import React, { useState, useEffect } from 'react';
import { IonToggle, IonItem, IonLabel, IonList } from '@ionic/react';
import { PageHeader } from '../../components/common';
import { useAppSelector } from '../../redux';
import { selectControllerName } from '../../redux';
import { settingsService } from '../../firebase/services';
import { useToast } from '../../hooks';
interface PlayerSettings {
autoadvance: boolean;
userpick: boolean;
}
const Settings: React.FC = () => {
const [settings, setSettings] = useState<PlayerSettings>({
autoadvance: false,
userpick: false
});
const [isLoading, setIsLoading] = useState(true);
const controllerName = useAppSelector(selectControllerName);
const { showSuccess, showError } = useToast();
// Load settings on mount
useEffect(() => {
if (controllerName) {
loadSettings();
}
}, [controllerName]);
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);
}
};
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 }));
}
};
return (
<>
<PageHeader
title="Settings"
subtitle="Configure player behavior"
/>
<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>
<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}
/>
</IonItem>
</IonList>
</div>
</>
);
};
export default Settings;

View File

@ -16,6 +16,8 @@ const Top100: React.FC = () => {
const { const {
topPlayedItems, topPlayedItems,
loadMore, loadMore,
hasMore,
isLoading,
} = useTopPlayed(); } = useTopPlayed();
const topPlayed = useAppSelector(selectTopPlayed); const topPlayed = useAppSelector(selectTopPlayed);
@ -76,80 +78,19 @@ const Top100: React.FC = () => {
} }
}, [toggleFavorite, showSuccess, showError]); }, [toggleFavorite, showSuccess, showError]);
// Mock data for testing - these are artist/title combinations, not individual songs // Use real Firebase data from the hook
const mockTopPlayedItems: TopPlayed[] = [ const displayItems = topPlayedItems;
{ const displayCount = topPlayedItems.length;
key: 'mock-1', const displayHasMore = hasMore;
title: 'CAN T STOP THE FEELING',
artist: 'Justin Timberlake',
count: 63
},
{
key: 'mock-2',
title: 'SWEET CAROLINE',
artist: 'Neil Diamond',
count: 58
},
{
key: 'mock-3',
title: 'DON\'T STOP BELIEVIN\'',
artist: 'Journey',
count: 52
},
{
key: 'mock-4',
title: 'LIVIN\' ON A PRAYER',
artist: 'Bon Jovi',
count: 47
},
{
key: 'mock-5',
title: 'WONDERWALL',
artist: 'Oasis',
count: 41
},
{
key: 'mock-6',
title: 'HOTEL CALIFORNIA',
artist: 'Eagles',
count: 38
},
{
key: 'mock-7',
title: 'STAIRWAY TO HEAVEN',
artist: 'Led Zeppelin',
count: 35
},
{
key: 'mock-8',
title: 'IMAGINE',
artist: 'John Lennon',
count: 32
},
{
key: 'mock-9',
title: 'HEY JUDE',
artist: 'The Beatles',
count: 29
},
{
key: 'mock-10',
title: 'YESTERDAY',
artist: 'The Beatles',
count: 26
}
];
// Use mock data for now console.log('Top100 component - Real Firebase data:', {
const displayItems = mockTopPlayedItems;
const displayCount = displayItems.length;
const displayHasMore = false; // No more mock data to load
console.log('Top100 component - Mock data:', {
displayItems: displayItems.length, displayItems: displayItems.length,
displayCount, displayCount,
displayHasMore, displayHasMore,
firstItem: displayItems[0] firstItem: displayItems[0],
totalTopPlayedCount: topPlayedCount,
hasMore,
isLoading
}); });
console.log('Top100 component - About to render JSX'); console.log('Top100 component - About to render JSX');
@ -158,12 +99,12 @@ const Top100: React.FC = () => {
<> <>
<PageHeader <PageHeader
title="Top 100 Played" title="Top 100 Played"
subtitle={`${displayCount} items loaded (Mock Data)`} subtitle={`${displayCount} items loaded`}
/> />
<InfiniteScrollList<TopPlayed> <InfiniteScrollList<TopPlayed>
items={displayItems} items={displayItems}
isLoading={false} isLoading={isLoading}
hasMore={displayHasMore} hasMore={displayHasMore}
onLoadMore={loadMore} onLoadMore={loadMore}
renderItem={(item, index) => ( renderItem={(item, index) => (

View File

@ -7,3 +7,4 @@ export { default as NewSongs } from './NewSongs/NewSongs';
export { default as Artists } from './Artists/Artists'; export { default as Artists } from './Artists/Artists';
export { default as Singers } from './Singers/Singers'; export { default as Singers } from './Singers/Singers';
export { default as SongLists } from './SongLists/SongLists'; export { default as SongLists } from './SongLists/SongLists';
export { default as Settings } from './Settings/Settings';

View File

@ -287,3 +287,35 @@ export const singerService = {
return () => off(singersRef); return () => off(singersRef);
} }
}; };
// Settings operations
export const settingsService = {
// Get current settings
getSettings: async (controllerName: string) => {
const settingsRef = ref(database, `controllers/${controllerName}/player/settings`);
const snapshot = await get(settingsRef);
return snapshot.exists() ? snapshot.val() : null;
},
// Update a specific setting
updateSetting: async (controllerName: string, setting: string, value: boolean) => {
const settingRef = ref(database, `controllers/${controllerName}/player/settings/${setting}`);
await set(settingRef, value);
},
// Update multiple settings at once
updateSettings: async (controllerName: string, settings: Record<string, boolean>) => {
const settingsRef = ref(database, `controllers/${controllerName}/player/settings`);
await update(settingsRef, settings);
},
// Listen to settings changes
subscribeToSettings: (controllerName: string, callback: (data: Record<string, boolean>) => void) => {
const settingsRef = ref(database, `controllers/${controllerName}/player/settings`);
onValue(settingsRef, (snapshot) => {
callback(snapshot.exists() ? snapshot.val() : {});
});
return () => off(settingsRef);
}
};

View File

@ -12,24 +12,44 @@ export const useTopPlayed = () => {
const { showSuccess, showError } = useToast(); const { showSuccess, showError } = useToast();
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);
// Paginate the top played items - show all items up to current page // Paginate the top played items - show all items up to current page
const topPlayedItems = useMemo(() => { const topPlayedItems = useMemo(() => {
const endIndex = currentPage * ITEMS_PER_PAGE; const endIndex = currentPage * ITEMS_PER_PAGE;
return allTopPlayedItems.slice(0, endIndex); const result = allTopPlayedItems.slice(0, endIndex);
console.log('useTopPlayed - pagination:', {
currentPage,
ITEMS_PER_PAGE,
endIndex,
allTopPlayedItemsLength: allTopPlayedItems.length,
resultLength: result.length
});
return result;
}, [allTopPlayedItems, currentPage]); }, [allTopPlayedItems, currentPage]);
const hasMore = useMemo(() => { const hasMore = useMemo(() => {
// Only show "hasMore" if there are more items than currently loaded // Show "hasMore" if there are more items than currently loaded
return allTopPlayedItems.length > ITEMS_PER_PAGE && topPlayedItems.length < allTopPlayedItems.length; const result = topPlayedItems.length < allTopPlayedItems.length;
console.log('useTopPlayed - hasMore calculation:', {
topPlayedItemsLength: topPlayedItems.length,
allTopPlayedItemsLength: allTopPlayedItems.length,
result
});
return result;
}, [topPlayedItems.length, allTopPlayedItems.length]); }, [topPlayedItems.length, allTopPlayedItems.length]);
const loadMore = useCallback(() => { const loadMore = useCallback(() => {
console.log('useTopPlayed - loadMore called:', { hasMore, currentPage, allTopPlayedItemsLength: allTopPlayedItems.length }); console.log('useTopPlayed - loadMore called:', { hasMore, currentPage, allTopPlayedItemsLength: allTopPlayedItems.length });
if (hasMore) { if (hasMore && !isLoading) {
setIsLoading(true);
// Simulate a small delay to show loading state
setTimeout(() => {
setCurrentPage(prev => prev + 1); setCurrentPage(prev => prev + 1);
setIsLoading(false);
}, 100);
} }
}, [hasMore, currentPage, allTopPlayedItems.length]); }, [hasMore, currentPage, allTopPlayedItems.length, isLoading]);
const handleAddToQueue = useCallback(async (song: TopPlayed) => { const handleAddToQueue = useCallback(async (song: TopPlayed) => {
try { try {
@ -68,6 +88,7 @@ export const useTopPlayed = () => {
allTopPlayedItems, allTopPlayedItems,
hasMore, hasMore,
loadMore, loadMore,
isLoading,
currentPage, currentPage,
totalPages: Math.ceil(allTopPlayedItems.length / ITEMS_PER_PAGE), totalPages: Math.ceil(allTopPlayedItems.length / ITEMS_PER_PAGE),
handleAddToQueue, handleAddToQueue,

View File

@ -85,7 +85,7 @@ export const selectArtistsArray = createSelector(
export const selectTopPlayedArray = createSelector( export const selectTopPlayedArray = createSelector(
[selectTopPlayed], [selectTopPlayed],
(topPlayed) => limitArray(sortTopPlayedByCount(objectToArray(topPlayed)), UI_CONSTANTS.TOP_PLAYED.MAX_ITEMS) (topPlayed) => sortTopPlayedByCount(objectToArray(topPlayed))
); );
// User-specific selectors // User-specific selectors