diff --git a/src/redux/favoritesSlice.ts b/src/redux/favoritesSlice.ts new file mode 100644 index 0000000..9625974 --- /dev/null +++ b/src/redux/favoritesSlice.ts @@ -0,0 +1,177 @@ +import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'; +import type { PayloadAction } from '@reduxjs/toolkit'; +import type { Song } from '../types'; +import { favoritesService, controllerService } from '../firebase/services'; + +// Async thunks for Firebase operations +export const fetchFavorites = createAsyncThunk( + 'favorites/fetchFavorites', + async (controllerName: string) => { + const controller = await controllerService.getController(controllerName); + return controller?.favorites ?? {}; + } +); + +export const addToFavorites = createAsyncThunk( + 'favorites/addToFavorites', + async ({ controllerName, song }: { controllerName: string; song: Song }) => { + await favoritesService.addToFavorites(controllerName, song); + return song; + } +); + +export const removeFromFavorites = createAsyncThunk( + 'favorites/removeFromFavorites', + async ({ controllerName, songPath }: { controllerName: string; songPath: string }) => { + await favoritesService.removeFromFavorites(controllerName, songPath); + return songPath; + } +); + +// Initial state +interface FavoritesState { + data: Record; + loading: boolean; + error: string | null; + lastUpdated: number | null; +} + +const initialState: FavoritesState = { + data: {}, + loading: false, + error: null, + lastUpdated: null, +}; + +// Slice +const favoritesSlice = createSlice({ + name: 'favorites', + initialState, + reducers: { + // Sync actions for real-time updates + setFavorites: (state, action: PayloadAction>) => { + state.data = action.payload; + state.lastUpdated = Date.now(); + state.error = null; + }, + + addFavorite: (state, action: PayloadAction<{ key: string; song: Song }>) => { + const { key, song } = action.payload; + state.data[key] = song; + state.lastUpdated = Date.now(); + }, + + removeFavorite: (state, action: PayloadAction) => { + const key = action.payload; + delete state.data[key]; + state.lastUpdated = Date.now(); + }, + + clearError: (state) => { + state.error = null; + }, + + resetFavorites: (state) => { + state.data = {}; + state.loading = false; + state.error = null; + state.lastUpdated = null; + }, + }, + extraReducers: (builder) => { + builder + // fetchFavorites + .addCase(fetchFavorites.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(fetchFavorites.fulfilled, (state, action) => { + state.loading = false; + state.data = action.payload; + state.lastUpdated = Date.now(); + state.error = null; + }) + .addCase(fetchFavorites.rejected, (state, action) => { + state.loading = false; + state.error = action.error.message || 'Failed to fetch favorites'; + }) + // addToFavorites + .addCase(addToFavorites.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(addToFavorites.fulfilled, (state, action) => { + state.loading = false; + // Find the key for this song by path + const songPath = action.payload.path; + const existingKey = Object.keys(state.data).find(key => + state.data[key].path === songPath + ); + if (existingKey) { + state.data[existingKey] = action.payload; + } else { + // Generate a new key if not found + const newKey = Date.now().toString(); + state.data[newKey] = action.payload; + } + state.lastUpdated = Date.now(); + state.error = null; + }) + .addCase(addToFavorites.rejected, (state, action) => { + state.loading = false; + state.error = action.error.message || 'Failed to add to favorites'; + }) + // removeFromFavorites + .addCase(removeFromFavorites.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(removeFromFavorites.fulfilled, (state, action) => { + state.loading = false; + const songPath = action.payload; + // Find and remove the song by path + const keyToRemove = Object.keys(state.data).find(key => + state.data[key].path === songPath + ); + if (keyToRemove) { + delete state.data[keyToRemove]; + } + state.lastUpdated = Date.now(); + state.error = null; + }) + .addCase(removeFromFavorites.rejected, (state, action) => { + state.loading = false; + state.error = action.error.message || 'Failed to remove from favorites'; + }); + }, +}); + +// Export actions +export const { + setFavorites, + addFavorite, + removeFavorite, + clearError, + resetFavorites, +} = favoritesSlice.actions; + +// Export selectors +export const selectFavorites = (state: { favorites: FavoritesState }) => state.favorites.data; +export const selectFavoritesLoading = (state: { favorites: FavoritesState }) => state.favorites.loading; +export const selectFavoritesError = (state: { favorites: FavoritesState }) => state.favorites.error; +export const selectFavoritesLastUpdated = (state: { favorites: FavoritesState }) => state.favorites.lastUpdated; + +// Helper selectors +export const selectFavoritesArray = (state: { favorites: FavoritesState }) => + Object.entries(state.favorites.data).map(([key, song]) => ({ ...song, key })); + +export const selectFavoriteByKey = (state: { favorites: FavoritesState }, key: string) => + state.favorites.data[key]; + +export const selectFavoriteByPath = (state: { favorites: FavoritesState }, path: string) => + Object.values(state.favorites.data).find(song => song.path === path); + +export const selectFavoritesCount = (state: { favorites: FavoritesState }) => + Object.keys(state.favorites.data).length; + +export default favoritesSlice.reducer; \ No newline at end of file diff --git a/src/redux/historySlice.ts b/src/redux/historySlice.ts new file mode 100644 index 0000000..895cd4c --- /dev/null +++ b/src/redux/historySlice.ts @@ -0,0 +1,174 @@ +import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'; +import type { PayloadAction } from '@reduxjs/toolkit'; +import type { Song } from '../types'; +import { historyService, controllerService } from '../firebase/services'; + +// Async thunks for Firebase operations +export const fetchHistory = createAsyncThunk( + 'history/fetchHistory', + async (controllerName: string) => { + const controller = await controllerService.getController(controllerName); + return controller?.history ?? {}; + } +); + +export const addToHistory = createAsyncThunk( + 'history/addToHistory', + async ({ controllerName, song }: { controllerName: string; song: Song }) => { + await historyService.addToHistory(controllerName, song); + return song; + } +); + +export const removeFromHistory = createAsyncThunk( + 'history/removeFromHistory', + async ({ controllerName, songKey }: { controllerName: string; songKey: string }) => { + await historyService.removeFromHistory(controllerName, songKey); + return songKey; + } +); + + + +// Initial state +interface HistoryState { + data: Record; + loading: boolean; + error: string | null; + lastUpdated: number | null; +} + +const initialState: HistoryState = { + data: {}, + loading: false, + error: null, + lastUpdated: null, +}; + +// Slice +const historySlice = createSlice({ + name: 'history', + initialState, + reducers: { + // Sync actions for real-time updates + setHistory: (state, action: PayloadAction>) => { + state.data = action.payload; + state.lastUpdated = Date.now(); + state.error = null; + }, + + addHistoryItem: (state, action: PayloadAction<{ key: string; song: Song }>) => { + const { key, song } = action.payload; + state.data[key] = song; + state.lastUpdated = Date.now(); + }, + + removeHistoryItem: (state, action: PayloadAction) => { + const key = action.payload; + delete state.data[key]; + state.lastUpdated = Date.now(); + }, + + clearError: (state) => { + state.error = null; + }, + + resetHistory: (state) => { + state.data = {}; + state.loading = false; + state.error = null; + state.lastUpdated = null; + }, + }, + extraReducers: (builder) => { + builder + // fetchHistory + .addCase(fetchHistory.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(fetchHistory.fulfilled, (state, action) => { + state.loading = false; + state.data = action.payload; + state.lastUpdated = Date.now(); + state.error = null; + }) + .addCase(fetchHistory.rejected, (state, action) => { + state.loading = false; + state.error = action.error.message || 'Failed to fetch history'; + }) + // addToHistory + .addCase(addToHistory.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(addToHistory.fulfilled, (state, action) => { + state.loading = false; + // Find the key for this song by path + const songPath = action.payload.path; + const existingKey = Object.keys(state.data).find(key => + state.data[key].path === songPath + ); + if (existingKey) { + state.data[existingKey] = action.payload; + } else { + // Generate a new key if not found + const newKey = Date.now().toString(); + state.data[newKey] = action.payload; + } + state.lastUpdated = Date.now(); + state.error = null; + }) + .addCase(addToHistory.rejected, (state, action) => { + state.loading = false; + state.error = action.error.message || 'Failed to add to history'; + }) + // removeFromHistory + .addCase(removeFromHistory.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(removeFromHistory.fulfilled, (state, action) => { + state.loading = false; + const songKey = action.payload; + delete state.data[songKey]; + state.lastUpdated = Date.now(); + state.error = null; + }) + .addCase(removeFromHistory.rejected, (state, action) => { + state.loading = false; + state.error = action.error.message || 'Failed to remove from history'; + }) + + }, +}); + +// Export actions +export const { + setHistory, + addHistoryItem, + removeHistoryItem, + clearError, + resetHistory, +} = historySlice.actions; + +// Export selectors +export const selectHistory = (state: { history: HistoryState }) => state.history.data; +export const selectHistoryLoading = (state: { history: HistoryState }) => state.history.loading; +export const selectHistoryError = (state: { history: HistoryState }) => state.history.error; +export const selectHistoryLastUpdated = (state: { history: HistoryState }) => state.history.lastUpdated; + +// Helper selectors +export const selectHistoryArray = (state: { history: HistoryState }) => + Object.entries(state.history.data).map(([key, song]) => ({ ...song, key })); + +export const selectHistoryItemByKey = (state: { history: HistoryState }, key: string) => + state.history.data[key]; + +export const selectHistoryItemByPath = (state: { history: HistoryState }, path: string) => + Object.values(state.history.data).find(song => song.path === path); + +export const selectHistoryCount = (state: { history: HistoryState }) => + Object.keys(state.history.data).length; + +export default historySlice.reducer; \ No newline at end of file diff --git a/src/redux/queueSlice.ts b/src/redux/queueSlice.ts index d0335a6..59795e1 100644 --- a/src/redux/queueSlice.ts +++ b/src/redux/queueSlice.ts @@ -1,9 +1,227 @@ -import { createSlice } from '@reduxjs/toolkit'; +import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'; +import type { PayloadAction } from '@reduxjs/toolkit'; +import type { QueueItem } from '../types'; +import { queueService, controllerService } from '../firebase/services'; +// Async thunks for Firebase operations +export const fetchQueue = createAsyncThunk( + 'queue/fetchQueue', + async (controllerName: string) => { + const controller = await controllerService.getController(controllerName); + return controller?.player?.queue ?? {}; + } +); + +export const addToQueue = createAsyncThunk( + 'queue/addToQueue', + async ({ controllerName, queueItem }: { controllerName: string; queueItem: Omit }) => { + const result = await queueService.addToQueue(controllerName, queueItem); + return { key: result.key, queueItem }; + } +); + +export const removeFromQueue = createAsyncThunk( + 'queue/removeFromQueue', + async ({ controllerName, queueItemKey }: { controllerName: string; queueItemKey: string }) => { + await queueService.removeFromQueue(controllerName, queueItemKey); + return queueItemKey; + } +); + +export const updateQueueItem = createAsyncThunk( + 'queue/updateQueueItem', + async ({ controllerName, queueItemKey, updates }: { controllerName: string; queueItemKey: string; updates: Partial }) => { + await queueService.updateQueueItem(controllerName, queueItemKey, updates); + return { key: queueItemKey, updates }; + } +); + +// Initial state +interface QueueState { + data: Record; + loading: boolean; + error: string | null; + lastUpdated: number | null; +} + +const initialState: QueueState = { + data: {}, + loading: false, + error: null, + lastUpdated: null, +}; + +// Slice const queueSlice = createSlice({ name: 'queue', - initialState: {}, - reducers: {}, + initialState, + reducers: { + // Sync actions for real-time updates + setQueue: (state, action: PayloadAction>) => { + state.data = action.payload; + state.lastUpdated = Date.now(); + state.error = null; + }, + + addQueueItem: (state, action: PayloadAction<{ key: string; item: QueueItem }>) => { + const { key, item } = action.payload; + state.data[key] = item; + state.lastUpdated = Date.now(); + }, + + updateQueueItemSync: (state, action: PayloadAction<{ key: string; updates: Partial }>) => { + const { key, updates } = action.payload; + if (state.data[key]) { + state.data[key] = { ...state.data[key], ...updates }; + state.lastUpdated = Date.now(); + } + }, + + removeQueueItem: (state, action: PayloadAction) => { + const key = action.payload; + delete state.data[key]; + state.lastUpdated = Date.now(); + }, + + reorderQueue: (state, action: PayloadAction<{ fromIndex: number; toIndex: number }>) => { + const { fromIndex, toIndex } = action.payload; + const items = Object.values(state.data).sort((a, b) => a.order - b.order); + + if (fromIndex >= 0 && fromIndex < items.length && toIndex >= 0 && toIndex < items.length) { + const [movedItem] = items.splice(fromIndex, 1); + items.splice(toIndex, 0, movedItem); + + // Update order values + items.forEach((item, index) => { + const key = Object.keys(state.data).find(k => state.data[k] === item); + if (key) { + state.data[key].order = index + 1; + } + }); + + state.lastUpdated = Date.now(); + } + }, + + clearError: (state) => { + state.error = null; + }, + + resetQueue: (state) => { + state.data = {}; + state.loading = false; + state.error = null; + state.lastUpdated = null; + }, + }, + extraReducers: (builder) => { + builder + // fetchQueue + .addCase(fetchQueue.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(fetchQueue.fulfilled, (state, action) => { + state.loading = false; + state.data = action.payload; + state.lastUpdated = Date.now(); + state.error = null; + }) + .addCase(fetchQueue.rejected, (state, action) => { + state.loading = false; + state.error = action.error.message || 'Failed to fetch queue'; + }) + // addToQueue + .addCase(addToQueue.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(addToQueue.fulfilled, (state, action) => { + state.loading = false; + const { key, queueItem } = action.payload; + state.data[key] = { ...queueItem, key }; + state.lastUpdated = Date.now(); + state.error = null; + }) + .addCase(addToQueue.rejected, (state, action) => { + state.loading = false; + state.error = action.error.message || 'Failed to add to queue'; + }) + // removeFromQueue + .addCase(removeFromQueue.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(removeFromQueue.fulfilled, (state, action) => { + state.loading = false; + const key = action.payload; + delete state.data[key]; + state.lastUpdated = Date.now(); + state.error = null; + }) + .addCase(removeFromQueue.rejected, (state, action) => { + state.loading = false; + state.error = action.error.message || 'Failed to remove from queue'; + }) + // updateQueueItem + .addCase(updateQueueItem.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(updateQueueItem.fulfilled, (state, action) => { + state.loading = false; + const { key, updates } = action.payload; + if (state.data[key]) { + state.data[key] = { ...state.data[key], ...updates }; + state.lastUpdated = Date.now(); + } + state.error = null; + }) + .addCase(updateQueueItem.rejected, (state, action) => { + state.loading = false; + state.error = action.error.message || 'Failed to update queue item'; + }); + }, }); +// Export actions +export const { + setQueue, + addQueueItem, + updateQueueItemSync, + removeQueueItem, + reorderQueue, + clearError, + resetQueue, +} = queueSlice.actions; + +// Export selectors +export const selectQueue = (state: { queue: QueueState }) => state.queue.data; +export const selectQueueLoading = (state: { queue: QueueState }) => state.queue.loading; +export const selectQueueError = (state: { queue: QueueState }) => state.queue.error; +export const selectQueueLastUpdated = (state: { queue: QueueState }) => state.queue.lastUpdated; + +// Helper selectors +export const selectQueueArray = (state: { queue: QueueState }) => + Object.entries(state.queue.data).map(([key, item]) => ({ ...item, key })); + +export const selectQueueItemByKey = (state: { queue: QueueState }, key: string) => + state.queue.data[key]; + +export const selectQueueLength = (state: { queue: QueueState }) => + Object.keys(state.queue.data).length; + +export const selectQueueStats = (state: { queue: QueueState }) => { + const queueArray = Object.values(state.queue.data); + const totalSongs = queueArray.length; + const singers = [...new Set(queueArray.map(item => item.singer.name))]; + const estimatedDuration = totalSongs * 3; // Rough estimate: 3 minutes per song + + return { + totalSongs, + singers, + estimatedDuration, + }; +}; + export default queueSlice.reducer; \ No newline at end of file diff --git a/src/redux/songsSlice.ts b/src/redux/songsSlice.ts new file mode 100644 index 0000000..ae24387 --- /dev/null +++ b/src/redux/songsSlice.ts @@ -0,0 +1,142 @@ +import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'; +import type { PayloadAction } from '@reduxjs/toolkit'; +import type { Song } from '../types'; +import { controllerService } from '../firebase/services'; + +// Async thunks for Firebase operations +export const fetchSongs = createAsyncThunk( + 'songs/fetchSongs', + async (controllerName: string) => { + const controller = await controllerService.getController(controllerName); + return controller?.songs ?? {}; + } +); + +export const updateSongs = createAsyncThunk( + 'songs/updateSongs', + async ({ controllerName, songs }: { controllerName: string; songs: Record }) => { + await controllerService.updateController(controllerName, { songs }); + return songs; + } +); + +// Initial state +interface SongsState { + data: Record; + loading: boolean; + error: string | null; + lastUpdated: number | null; +} + +const initialState: SongsState = { + data: {}, + loading: false, + error: null, + lastUpdated: null, +}; + +// Slice +const songsSlice = createSlice({ + name: 'songs', + initialState, + reducers: { + // Sync actions for real-time updates + setSongs: (state, action: PayloadAction>) => { + state.data = action.payload; + state.lastUpdated = Date.now(); + state.error = null; + }, + + addSong: (state, action: PayloadAction<{ key: string; song: Song }>) => { + const { key, song } = action.payload; + state.data[key] = song; + state.lastUpdated = Date.now(); + }, + + updateSong: (state, action: PayloadAction<{ key: string; song: Partial }>) => { + const { key, song } = action.payload; + if (state.data[key]) { + state.data[key] = { ...state.data[key], ...song }; + state.lastUpdated = Date.now(); + } + }, + + removeSong: (state, action: PayloadAction) => { + const key = action.payload; + delete state.data[key]; + state.lastUpdated = Date.now(); + }, + + clearError: (state) => { + state.error = null; + }, + + resetSongs: (state) => { + state.data = {}; + state.loading = false; + state.error = null; + state.lastUpdated = null; + }, + }, + extraReducers: (builder) => { + builder + // fetchSongs + .addCase(fetchSongs.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(fetchSongs.fulfilled, (state, action) => { + state.loading = false; + state.data = action.payload; + state.lastUpdated = Date.now(); + state.error = null; + }) + .addCase(fetchSongs.rejected, (state, action) => { + state.loading = false; + state.error = action.error.message || 'Failed to fetch songs'; + }) + // updateSongs + .addCase(updateSongs.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(updateSongs.fulfilled, (state, action) => { + state.loading = false; + state.data = action.payload; + state.lastUpdated = Date.now(); + state.error = null; + }) + .addCase(updateSongs.rejected, (state, action) => { + state.loading = false; + state.error = action.error.message || 'Failed to update songs'; + }); + }, +}); + +// Export actions +export const { + setSongs, + addSong, + updateSong, + removeSong, + clearError, + resetSongs, +} = songsSlice.actions; + +// Export selectors +export const selectSongs = (state: { songs: SongsState }) => state.songs.data; +export const selectSongsLoading = (state: { songs: SongsState }) => state.songs.loading; +export const selectSongsError = (state: { songs: SongsState }) => state.songs.error; +export const selectSongsLastUpdated = (state: { songs: SongsState }) => state.songs.lastUpdated; + +// Helper selectors +export const selectSongsArray = (state: { songs: SongsState }) => + Object.entries(state.songs.data).map(([key, song]) => ({ ...song, key })); + +export const selectSongByKey = (state: { songs: SongsState }, key: string) => + state.songs.data[key]; + +export const selectSongByPath = (state: { songs: SongsState }, path: string) => + Object.values(state.songs.data).find(song => song.path === path); + +export default songsSlice.reducer; \ No newline at end of file