Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
8663a414d8
commit
d4e5c4d5ae
6
database.rules.json
Normal file
6
database.rules.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"rules": {
|
||||
".read": true,
|
||||
".write": true
|
||||
}
|
||||
}
|
||||
11
firebase.json
Normal file
11
firebase.json
Normal 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
160
functions/lib/index.js
Normal 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
|
||||
1
functions/lib/index.js.map
Normal file
1
functions/lib/index.js.map
Normal file
File diff suppressed because one or more lines are too long
3428
functions/package-lock.json
generated
Normal file
3428
functions/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
functions/package.json
Normal file
25
functions/package.json
Normal 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
211
functions/src/index.ts
Normal 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
16
functions/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
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 { FirebaseProvider } from './firebase/FirebaseProvider';
|
||||
import { ErrorBoundary } from './components/common';
|
||||
@ -24,6 +24,7 @@ function App() {
|
||||
<Route path="/history" element={<History />} />
|
||||
<Route path="/top-played" element={<TopPlayed />} />
|
||||
<Route path="/singers" element={<Singers />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="*" element={<Navigate to="/queue" replace />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
|
||||
@ -79,8 +79,9 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
style={{
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
height: 'calc(100vh - 56px)', // Subtract header height
|
||||
overflow: 'hidden' // Prevent main content from scrolling
|
||||
height: 'calc(100vh - 64px)', // Adjust header height
|
||||
overflow: 'auto', // Allow scrolling
|
||||
paddingBottom: '40px' // Add more bottom padding
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@ -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 { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { PlayerControls } from '../common';
|
||||
import { useAppSelector } from '../../redux';
|
||||
import { selectIsAdmin } from '../../redux';
|
||||
|
||||
const Navigation: React.FC = () => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [isLargeScreen, setIsLargeScreen] = useState(false);
|
||||
const isAdmin = useAppSelector(selectIsAdmin);
|
||||
|
||||
const navItems = [
|
||||
const allNavItems = [
|
||||
{ path: '/search', label: 'Search', icon: searchOutline },
|
||||
{ path: '/queue', label: 'Queue', icon: musicalNotesOutline },
|
||||
{ path: '/singers', label: 'Singers', icon: peopleCircleOutline },
|
||||
@ -19,9 +22,12 @@ const Navigation: React.FC = () => {
|
||||
{ path: '/history', label: 'History', icon: timeOutline },
|
||||
{ path: '/new-songs', label: 'New Songs', 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
|
||||
useEffect(() => {
|
||||
const checkScreenSize = () => {
|
||||
|
||||
@ -44,7 +44,6 @@ const History: React.FC = () => {
|
||||
subtitle={`${historyCount} items loaded`}
|
||||
/>
|
||||
|
||||
<div style={{ height: '100%', overflowY: 'auto' }}>
|
||||
<InfiniteScrollList<Song>
|
||||
items={historyItems}
|
||||
isLoading={historyCount === 0}
|
||||
@ -68,7 +67,6 @@ const History: React.FC = () => {
|
||||
loadingTitle="Loading history..."
|
||||
loadingMessage="Please wait while history data is being loaded"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -42,8 +42,6 @@ const Queue: React.FC = () => {
|
||||
console.log('Queue component - canDeleteItems:', canDeleteItems);
|
||||
console.log('Queue component - canReorder:', canReorder);
|
||||
|
||||
// Determine if currently playing
|
||||
const isPlaying = playerState?.state === PlayerState.playing;
|
||||
|
||||
// Update list items when queue changes
|
||||
useEffect(() => {
|
||||
|
||||
103
src/features/Settings/Settings.tsx
Normal file
103
src/features/Settings/Settings.tsx
Normal 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;
|
||||
@ -16,6 +16,8 @@ const Top100: React.FC = () => {
|
||||
const {
|
||||
topPlayedItems,
|
||||
loadMore,
|
||||
hasMore,
|
||||
isLoading,
|
||||
} = useTopPlayed();
|
||||
|
||||
const topPlayed = useAppSelector(selectTopPlayed);
|
||||
@ -76,80 +78,19 @@ const Top100: React.FC = () => {
|
||||
}
|
||||
}, [toggleFavorite, showSuccess, showError]);
|
||||
|
||||
// Mock data for testing - these are artist/title combinations, not individual songs
|
||||
const mockTopPlayedItems: TopPlayed[] = [
|
||||
{
|
||||
key: 'mock-1',
|
||||
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 real Firebase data from the hook
|
||||
const displayItems = topPlayedItems;
|
||||
const displayCount = topPlayedItems.length;
|
||||
const displayHasMore = hasMore;
|
||||
|
||||
// Use mock data for now
|
||||
const displayItems = mockTopPlayedItems;
|
||||
const displayCount = displayItems.length;
|
||||
const displayHasMore = false; // No more mock data to load
|
||||
|
||||
console.log('Top100 component - Mock data:', {
|
||||
console.log('Top100 component - Real Firebase data:', {
|
||||
displayItems: displayItems.length,
|
||||
displayCount,
|
||||
displayHasMore,
|
||||
firstItem: displayItems[0]
|
||||
firstItem: displayItems[0],
|
||||
totalTopPlayedCount: topPlayedCount,
|
||||
hasMore,
|
||||
isLoading
|
||||
});
|
||||
|
||||
console.log('Top100 component - About to render JSX');
|
||||
@ -158,12 +99,12 @@ const Top100: React.FC = () => {
|
||||
<>
|
||||
<PageHeader
|
||||
title="Top 100 Played"
|
||||
subtitle={`${displayCount} items loaded (Mock Data)`}
|
||||
subtitle={`${displayCount} items loaded`}
|
||||
/>
|
||||
|
||||
<InfiniteScrollList<TopPlayed>
|
||||
items={displayItems}
|
||||
isLoading={false}
|
||||
isLoading={isLoading}
|
||||
hasMore={displayHasMore}
|
||||
onLoadMore={loadMore}
|
||||
renderItem={(item, index) => (
|
||||
|
||||
@ -7,3 +7,4 @@ export { default as NewSongs } from './NewSongs/NewSongs';
|
||||
export { default as Artists } from './Artists/Artists';
|
||||
export { default as Singers } from './Singers/Singers';
|
||||
export { default as SongLists } from './SongLists/SongLists';
|
||||
export { default as Settings } from './Settings/Settings';
|
||||
@ -287,3 +287,35 @@ export const singerService = {
|
||||
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);
|
||||
}
|
||||
};
|
||||
@ -12,24 +12,44 @@ export const useTopPlayed = () => {
|
||||
const { showSuccess, showError } = useToast();
|
||||
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Paginate the top played items - show all items up to current page
|
||||
const topPlayedItems = useMemo(() => {
|
||||
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]);
|
||||
|
||||
const hasMore = useMemo(() => {
|
||||
// Only show "hasMore" if there are more items than currently loaded
|
||||
return allTopPlayedItems.length > ITEMS_PER_PAGE && topPlayedItems.length < allTopPlayedItems.length;
|
||||
// Show "hasMore" if there are more items than currently loaded
|
||||
const result = topPlayedItems.length < allTopPlayedItems.length;
|
||||
console.log('useTopPlayed - hasMore calculation:', {
|
||||
topPlayedItemsLength: topPlayedItems.length,
|
||||
allTopPlayedItemsLength: allTopPlayedItems.length,
|
||||
result
|
||||
});
|
||||
return result;
|
||||
}, [topPlayedItems.length, allTopPlayedItems.length]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
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);
|
||||
setIsLoading(false);
|
||||
}, 100);
|
||||
}
|
||||
}, [hasMore, currentPage, allTopPlayedItems.length]);
|
||||
}, [hasMore, currentPage, allTopPlayedItems.length, isLoading]);
|
||||
|
||||
const handleAddToQueue = useCallback(async (song: TopPlayed) => {
|
||||
try {
|
||||
@ -68,6 +88,7 @@ export const useTopPlayed = () => {
|
||||
allTopPlayedItems,
|
||||
hasMore,
|
||||
loadMore,
|
||||
isLoading,
|
||||
currentPage,
|
||||
totalPages: Math.ceil(allTopPlayedItems.length / ITEMS_PER_PAGE),
|
||||
handleAddToQueue,
|
||||
|
||||
@ -85,7 +85,7 @@ export const selectArtistsArray = createSelector(
|
||||
|
||||
export const selectTopPlayedArray = createSelector(
|
||||
[selectTopPlayed],
|
||||
(topPlayed) => limitArray(sortTopPlayedByCount(objectToArray(topPlayed)), UI_CONSTANTS.TOP_PLAYED.MAX_ITEMS)
|
||||
(topPlayed) => sortTopPlayedByCount(objectToArray(topPlayed))
|
||||
);
|
||||
|
||||
// User-specific selectors
|
||||
|
||||
Loading…
Reference in New Issue
Block a user