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 { 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>
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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 = () => {
|
||||||
|
|||||||
@ -44,31 +44,29 @@ 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}
|
hasMore={hasMore}
|
||||||
hasMore={hasMore}
|
onLoadMore={loadMore}
|
||||||
onLoadMore={loadMore}
|
renderItem={(song) => (
|
||||||
renderItem={(song) => (
|
<div className="flex items-center">
|
||||||
<div className="flex items-center">
|
<div className="flex-1">
|
||||||
<div className="flex-1">
|
<SongItem
|
||||||
<SongItem
|
song={song}
|
||||||
song={song}
|
context="history"
|
||||||
context="history"
|
onAddToQueue={() => handleAddToQueue(song)}
|
||||||
onAddToQueue={() => handleAddToQueue(song)}
|
onToggleFavorite={() => handleToggleFavorite(song)}
|
||||||
onToggleFavorite={() => handleToggleFavorite(song)}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{renderExtraContent(song)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
{renderExtraContent(song)}
|
||||||
emptyTitle="No history yet"
|
</div>
|
||||||
emptyMessage="Songs will appear here after they've been played"
|
)}
|
||||||
loadingTitle="Loading history..."
|
emptyTitle="No history yet"
|
||||||
loadingMessage="Please wait while history data is being loaded"
|
emptyMessage="Songs will appear here after they've been played"
|
||||||
/>
|
loadingTitle="Loading history..."
|
||||||
</div>
|
loadingMessage="Please wait while history data is being loaded"
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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(() => {
|
||||||
|
|||||||
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 {
|
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) => (
|
||||||
|
|||||||
@ -6,4 +6,5 @@ export { default as Favorites } from './Favorites/Favorites';
|
|||||||
export { default as NewSongs } from './NewSongs/NewSongs';
|
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';
|
||||||
@ -286,4 +286,36 @@ 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);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
@ -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) {
|
||||||
setCurrentPage(prev => prev + 1);
|
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) => {
|
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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user