refactor: add xtream feature stores

This commit is contained in:
4gray committed 2026-01-11 06:24:48 +01:00
1 parent a37192c2ad
commit 97a678a98d
8 files changed
+1150

No files matched your search

@@ -0,0 +1,6 @@
export * from './with-portal.feature';
export * from './with-content.feature';
export * from './with-selection.feature';
export * from './with-search.feature';
export * from './with-epg.feature';
export * from './with-player.feature';
@@ -0,0 +1,317 @@
import { computed, inject } from '@angular/core';
import {
patchState,
signalStoreFeature,
withComputed,
withMethods,
withState,
} from '@ngrx/signals';
import {
XtreamCategory,
XtreamLiveStream,
XtreamSerieItem,
XtreamVodStream,
} from 'shared-interfaces';
import { ContentType } from '../../xtream-state';
import { XtreamCredentials } from '../../services/xtream-api.service';
import {
IXtreamDataSource,
XTREAM_DATA_SOURCE,
XtreamCategoryFromDb,
} from '../../data-sources/xtream-data-source.interface';
/**
* Content state for managing categories and streams
*/
export interface ContentState {
liveCategories: (XtreamCategory | XtreamCategoryFromDb)[];
vodCategories: (XtreamCategory | XtreamCategoryFromDb)[];
serialCategories: (XtreamCategory | XtreamCategoryFromDb)[];
liveStreams: XtreamLiveStream[];
vodStreams: XtreamVodStream[];
serialStreams: XtreamSerieItem[];
isLoadingCategories: boolean;
isLoadingContent: boolean;
isImporting: boolean;
importCount: number;
itemsToImport: number;
}
/**
* Initial content state
*/
const initialContentState: ContentState = {
liveCategories: [],
vodCategories: [],
serialCategories: [],
liveStreams: [],
vodStreams: [],
serialStreams: [],
isLoadingCategories: false,
isLoadingContent: false,
isImporting: false,
importCount: 0,
itemsToImport: 0,
};
/**
* Content feature store for managing Xtream categories and streams.
* Handles:
* - Fetching categories (live, vod, series)
* - Fetching content/streams (live, movies, series)
* - Import progress tracking
*/
export function withContent() {
return signalStoreFeature(
withState<ContentState>(initialContentState),
withComputed((store) => ({
/**
* Get categories by content type
*/
getCategoriesByType: computed(() => {
return (type: ContentType) => {
switch (type) {
case 'live':
return store.liveCategories();
case 'vod':
return store.vodCategories();
case 'series':
return store.serialCategories();
}
};
}),
/**
* Get streams/content by type
*/
getContentByType: computed(() => {
return (type: ContentType) => {
switch (type) {
case 'live':
return store.liveStreams();
case 'vod':
return store.vodStreams();
case 'series':
return store.serialStreams();
}
};
}),
/**
* Get import count
*/
getImportCount: computed(() => store.importCount()),
/**
* Check if content is being imported
*/
isContentImporting: computed(() => store.isImporting()),
})),
withMethods((store) => {
const dataSource = inject(XTREAM_DATA_SOURCE);
/**
* Helper to get credentials from parent store
* This will be provided by the parent store's currentPlaylist
*/
const getCredentialsFromStore = (): {
playlistId: string;
credentials: XtreamCredentials;
} | null => {
// Access parent store state (currentPlaylist is from withPortal)
const storeAny = store as any;
const playlist = storeAny.currentPlaylist?.();
const playlistId = storeAny.playlistId?.();
if (!playlist || !playlistId) {
return null;
}
return {
playlistId,
credentials: {
serverUrl: playlist.serverUrl,
username: playlist.username,
password: playlist.password,
},
};
};
return {
/**
* Fetch all categories in parallel
*/
async fetchAllCategories(): Promise<void> {
const ctx = getCredentialsFromStore();
if (!ctx) return;
patchState(store, { isLoadingCategories: true });
try {
const [live, vod, series] = await Promise.all([
dataSource.getCategories(
ctx.playlistId,
ctx.credentials,
'live'
),
dataSource.getCategories(
ctx.playlistId,
ctx.credentials,
'vod'
),
dataSource.getCategories(
ctx.playlistId,
ctx.credentials,
'series'
),
]);
patchState(store, {
liveCategories: live,
vodCategories: vod,
serialCategories: series,
isLoadingCategories: false,
});
} catch (error) {
console.error('Error fetching categories:', error);
patchState(store, { isLoadingCategories: false });
}
},
/**
* Fetch all content/streams in parallel
*/
async fetchAllContent(): Promise<void> {
const ctx = getCredentialsFromStore();
if (!ctx) return;
patchState(store, { isLoadingContent: true });
try {
const [live, vod, series] = await Promise.all([
dataSource.getContent(
ctx.playlistId,
ctx.credentials,
'live'
),
dataSource.getContent(
ctx.playlistId,
ctx.credentials,
'movie'
),
dataSource.getContent(
ctx.playlistId,
ctx.credentials,
'series'
),
]);
patchState(store, {
liveStreams: live as XtreamLiveStream[],
vodStreams: vod as XtreamVodStream[],
serialStreams: series as XtreamSerieItem[],
isLoadingContent: false,
});
} catch (error) {
console.error('Error fetching content:', error);
patchState(store, { isLoadingContent: false });
}
},
/**
* Initialize content (fetch categories and content)
*/
async initializeContent(): Promise<void> {
const ctx = getCredentialsFromStore();
if (!ctx) return;
patchState(store, { isImporting: true, importCount: 0 });
try {
// Fetch categories first
await this.fetchAllCategories();
// Then fetch content
await this.fetchAllContent();
// Restore user data if needed
const restoreKey = `xtream-restore-${ctx.playlistId}`;
const restoreData = localStorage.getItem(restoreKey);
if (restoreData) {
try {
const { favoritedXtreamIds, recentlyViewedXtreamIds } =
JSON.parse(restoreData);
await dataSource.restoreUserData(
ctx.playlistId,
favoritedXtreamIds,
recentlyViewedXtreamIds
);
localStorage.removeItem(restoreKey);
} catch (err) {
console.error('Error restoring user data:', err);
}
}
} finally {
patchState(store, { isImporting: false });
}
},
/**
* Reload categories from database (after visibility changes)
*/
async reloadCategories(): Promise<void> {
const ctx = getCredentialsFromStore();
if (!ctx) return;
try {
const [live, vod, series] = await Promise.all([
dataSource.getCategories(
ctx.playlistId,
ctx.credentials,
'live'
),
dataSource.getCategories(
ctx.playlistId,
ctx.credentials,
'vod'
),
dataSource.getCategories(
ctx.playlistId,
ctx.credentials,
'series'
),
]);
patchState(store, {
liveCategories: live,
vodCategories: vod,
serialCategories: series,
});
} catch (error) {
console.error('Error reloading categories:', error);
}
},
/**
* Update import progress
*/
setImportProgress(count: number, total?: number): void {
const updates: Partial<ContentState> = { importCount: count };
if (total !== undefined) {
updates.itemsToImport = total;
}
patchState(store, updates);
},
/**
* Reset content state
*/
resetContent(): void {
patchState(store, initialContentState);
},
};
})
);
}
@@ -0,0 +1,124 @@
import { inject } from '@angular/core';
import { patchState, signalStoreFeature, withMethods, withState } from '@ngrx/signals';
import { EpgItem } from 'shared-interfaces';
import { XtreamApiService, XtreamCredentials } from '../../services/xtream-api.service';
/**
* EPG state for managing Electronic Program Guide data
*/
export interface EpgState {
epgItems: EpgItem[];
isLoadingEpg: boolean;
}
/**
* Initial EPG state
*/
const initialEpgState: EpgState = {
epgItems: [],
isLoadingEpg: false,
};
/**
* EPG feature store for managing Electronic Program Guide data.
* Handles:
* - Loading EPG for selected stream
* - Loading channel EPG for preview
*/
export function withEpg() {
return signalStoreFeature(
withState<EpgState>(initialEpgState),
withMethods((store) => {
const apiService = inject(XtreamApiService);
/**
* Helper to get credentials from parent store
*/
const getCredentialsFromStore = (): XtreamCredentials | null => {
const storeAny = store as any;
const playlist = storeAny.currentPlaylist?.();
if (!playlist) {
return null;
}
return {
serverUrl: playlist.serverUrl,
username: playlist.username,
password: playlist.password,
};
};
return {
/**
* Load EPG for the currently selected item
*/
async loadEpg(): Promise<EpgItem[]> {
const credentials = getCredentialsFromStore();
if (!credentials) {
patchState(store, { epgItems: [] });
return [];
}
// Access selected item from parent store (from withSelection)
const storeAny = store as any;
const selectedItem = storeAny.selectedItem?.();
if (!selectedItem?.xtream_id) {
patchState(store, { epgItems: [] });
return [];
}
patchState(store, { isLoadingEpg: true });
try {
const epgItems = await apiService.getShortEpg(
credentials,
selectedItem.xtream_id,
10
);
patchState(store, {
epgItems,
isLoadingEpg: false,
});
return epgItems;
} catch (error) {
console.error('Error loading EPG:', error);
patchState(store, {
epgItems: [],
isLoadingEpg: false,
});
return [];
}
},
/**
* Load EPG for a specific channel (for preview)
*/
async loadChannelEpg(streamId: number): Promise<EpgItem[]> {
const credentials = getCredentialsFromStore();
if (!credentials) {
return [];
}
try {
return await apiService.getShortEpg(credentials, streamId, 1);
} catch (error) {
console.error('Error loading channel EPG:', error);
return [];
}
},
/**
* Clear EPG data
*/
clearEpg(): void {
patchState(store, initialEpgState);
},
};
})
);
}
@@ -0,0 +1,170 @@
import { inject } from '@angular/core';
import { patchState, signalStoreFeature, withMethods, withState } from '@ngrx/signals';
import { XtreamSerieEpisode, XtreamVodDetails } from 'shared-interfaces';
import { PlayerService } from '../../../services/player.service';
import { XtreamUrlService } from '../../services/xtream-url.service';
import { XtreamCredentials } from '../../services/xtream-api.service';
/**
* Player state for managing stream URLs and player settings
*/
export interface PlayerState {
streamUrl: string | null;
hideExternalInfoDialog: boolean;
}
/**
* Initial player state
*/
const initialPlayerState: PlayerState = {
streamUrl: null,
hideExternalInfoDialog:
localStorage.getItem('hideExternalInfoDialog') === 'true',
};
/**
* Player feature store for managing stream playback.
* Handles:
* - Stream URL construction
* - External player integration
* - Player settings
*/
export function withPlayer() {
return signalStoreFeature(
withState<PlayerState>(initialPlayerState),
withMethods((store) => {
const urlService = inject(XtreamUrlService);
const playerService = inject(PlayerService);
/**
* Helper to get credentials from parent store
*/
const getCredentialsFromStore = (): XtreamCredentials | null => {
const storeAny = store as any;
const playlist = storeAny.currentPlaylist?.();
if (!playlist) {
return null;
}
return {
serverUrl: playlist.serverUrl,
username: playlist.username,
password: playlist.password,
};
};
/**
* Helper to get playlist with headers from parent store
*/
const getPlaylistFromStore = () => {
const storeAny = store as any;
return storeAny.currentPlaylist?.();
};
return {
/**
* Construct and return live stream URL
*/
constructStreamUrl(item: any): string {
const credentials = getCredentialsFromStore();
if (!credentials) {
return '';
}
const streamUrl = urlService.constructLiveUrl(
credentials,
item.xtream_id
);
// Set selected item in parent store and load EPG
const storeAny = store as any;
if (storeAny.setSelectedItem) {
storeAny.setSelectedItem(item);
}
if (storeAny.loadEpg) {
storeAny.loadEpg();
}
patchState(store, { streamUrl });
return streamUrl;
},
/**
* Construct and return VOD stream URL
*/
constructVodStreamUrl(vodItem: XtreamVodDetails): string {
const credentials = getCredentialsFromStore();
if (!credentials) {
return '';
}
const streamUrl = urlService.constructVodUrl(credentials, vodItem);
patchState(store, { streamUrl });
return streamUrl;
},
/**
* Construct and return series episode stream URL
*/
constructEpisodeStreamUrl(episode: XtreamSerieEpisode): string {
const credentials = getCredentialsFromStore();
if (!credentials) {
return '';
}
const streamUrl = urlService.constructEpisodeUrl(
credentials,
episode
);
patchState(store, { streamUrl });
return streamUrl;
},
/**
* Open external player with stream
*/
openPlayer(
streamUrl: string,
title: string,
thumbnail: string | null = null
): void {
const playlist = getPlaylistFromStore();
const storeAny = store as any;
const contentType = storeAny.selectedContentType?.() || 'vod';
playerService.openPlayer(
streamUrl,
title,
thumbnail,
localStorage.getItem('hideExternalInfoDialog') === 'true',
contentType === 'live',
playlist?.userAgent,
playlist?.referrer,
playlist?.origin
);
},
/**
* Set hide external info dialog preference
*/
setHideExternalInfoDialog(hide: boolean): void {
localStorage.setItem('hideExternalInfoDialog', String(hide));
patchState(store, { hideExternalInfoDialog: hide });
},
/**
* Reset player state
*/
resetPlayer(): void {
patchState(store, {
streamUrl: null,
hideExternalInfoDialog:
localStorage.getItem('hideExternalInfoDialog') === 'true',
});
},
};
})
);
}
@@ -0,0 +1,158 @@
import { inject } from '@angular/core';
import { patchState, signalStoreFeature, withMethods, withState } from '@ngrx/signals';
import { Store } from '@ngrx/store';
import { selectActivePlaylist } from 'm3u-state';
import { Playlist } from 'shared-interfaces';
import { PortalStatusType } from '../../xtream-state';
import { XtreamApiService, XtreamCredentials } from '../../services/xtream-api.service';
import {
IXtreamDataSource,
XTREAM_DATA_SOURCE,
XtreamPlaylistData,
} from '../../data-sources/xtream-data-source.interface';
/**
* Portal state for managing playlist and portal status
*/
export interface PortalState {
playlistId: string | null;
currentPlaylist: XtreamPlaylistData | null;
portalStatus: PortalStatusType;
}
/**
* Initial portal state
*/
const initialPortalState: PortalState = {
playlistId: null,
currentPlaylist: null,
portalStatus: 'unavailable',
};
/**
* Portal feature store for managing the current Xtream playlist and status.
* Handles:
* - Setting/clearing playlist ID
* - Fetching playlist details
* - Checking portal status (active, inactive, expired, unavailable)
*/
export function withPortal() {
return signalStoreFeature(
withState<PortalState>(initialPortalState),
withMethods((store) => {
const apiService = inject(XtreamApiService);
const dataSource = inject(XTREAM_DATA_SOURCE);
const ngrxStore = inject(Store);
return {
/**
* Set the current playlist ID
*/
setPlaylistId(playlistId: string): void {
patchState(store, { playlistId });
},
/**
* Fetch playlist details from data source
*/
async fetchPlaylist(): Promise<void> {
const playlistId = store.playlistId();
if (!playlistId) {
return;
}
try {
const playlist = await dataSource.getPlaylist(playlistId);
if (playlist) {
patchState(store, { currentPlaylist: playlist });
} else {
// Fallback to NgRx store for M3U playlists that might not be in DB yet
const activePlaylist = ngrxStore.selectSignal(
selectActivePlaylist
)() as Playlist;
if (activePlaylist) {
const newPlaylist: XtreamPlaylistData = {
id: activePlaylist._id,
name: activePlaylist.title,
serverUrl: activePlaylist.serverUrl,
username: activePlaylist.username,
password: activePlaylist.password,
type: 'xtream',
};
await dataSource.createPlaylist(newPlaylist);
patchState(store, { currentPlaylist: newPlaylist });
}
}
} catch (error) {
console.error('Error fetching playlist:', error);
}
},
/**
* Check portal status via API
*/
async checkPortalStatus(): Promise<void> {
const playlist = store.currentPlaylist();
if (!playlist) {
patchState(store, { portalStatus: 'unavailable' });
return;
}
const credentials: XtreamCredentials = {
serverUrl: playlist.serverUrl,
username: playlist.username,
password: playlist.password,
};
try {
const response = await apiService.getAccountInfo(credentials);
if (!response?.user_info?.status) {
patchState(store, { portalStatus: 'unavailable' });
return;
}
if (response.user_info.status === 'Active') {
const expDate = new Date(
parseInt(response.user_info.exp_date) * 1000
);
if (expDate < new Date()) {
patchState(store, { portalStatus: 'expired' });
} else {
patchState(store, { portalStatus: 'active' });
}
} else {
patchState(store, { portalStatus: 'inactive' });
}
} catch (error) {
console.error('Error checking portal status:', error);
patchState(store, { portalStatus: 'unavailable' });
}
},
/**
* Update playlist details
*/
updatePlaylist(updates: Partial<XtreamPlaylistData>): void {
const current = store.currentPlaylist();
if (current) {
patchState(store, {
currentPlaylist: { ...current, ...updates },
});
}
},
/**
* Reset portal state
*/
resetPortal(): void {
patchState(store, initialPortalState);
},
};
})
);
}
@@ -0,0 +1,102 @@
import { inject } from '@angular/core';
import { patchState, signalStoreFeature, withMethods, withState } from '@ngrx/signals';
import { GlobalSearchResult } from 'services';
import {
IXtreamDataSource,
XTREAM_DATA_SOURCE,
XtreamContentItem,
} from '../../data-sources/xtream-data-source.interface';
/**
* Search state for managing search results
*/
export interface SearchState {
searchResults: XtreamContentItem[];
globalSearchResults: GlobalSearchResult[];
isSearching: boolean;
}
/**
* Initial search state
*/
const initialSearchState: SearchState = {
searchResults: [],
globalSearchResults: [],
isSearching: false,
};
/**
* Search feature store for managing content search.
* Handles:
* - Local playlist search
* - Global search results from external sources
*/
export function withSearch() {
return signalStoreFeature(
withState<SearchState>(initialSearchState),
withMethods((store) => {
const dataSource = inject(XTREAM_DATA_SOURCE);
return {
/**
* Search content within the current playlist
*/
async searchContent(
searchTerm: string,
types: string[]
): Promise<XtreamContentItem[]> {
// Access parent store's playlistId (from withPortal)
const storeAny = store as any;
const playlistId = storeAny.playlistId?.();
if (!playlistId || !searchTerm.trim()) {
patchState(store, { searchResults: [] });
return [];
}
patchState(store, { isSearching: true });
try {
const results = await dataSource.searchContent(
playlistId,
searchTerm,
types
);
patchState(store, {
searchResults: results,
isSearching: false,
});
return results;
} catch (error) {
console.error('Error searching content:', error);
patchState(store, {
searchResults: [],
isSearching: false,
});
return [];
}
},
/**
* Set global search results (from external search)
*/
setGlobalSearchResults(results: GlobalSearchResult[]): void {
patchState(store, {
searchResults: results as any,
globalSearchResults: results,
});
},
/**
* Clear search results
*/
resetSearchResults(): void {
patchState(store, initialSearchState);
},
};
})
);
}
@@ -0,0 +1,271 @@
import { computed } from '@angular/core';
import {
patchState,
signalStoreFeature,
withComputed,
withMethods,
withState,
} from '@ngrx/signals';
import { ContentType } from '../../xtream-state';
/**
* Selection state for managing UI selection and pagination
*/
export interface SelectionState {
selectedContentType: ContentType;
selectedCategoryId: number | null;
selectedItem: any | null;
page: number;
limit: number;
}
/**
* Initial selection state
*/
const initialSelectionState: SelectionState = {
selectedContentType: 'vod',
selectedCategoryId: null,
selectedItem: null,
page: 0,
limit: Number(localStorage.getItem('xtream-page-size') ?? 25),
};
/**
* Selection feature store for managing UI selection and pagination.
* Handles:
* - Content type selection (live, vod, series)
* - Category selection
* - Item selection
* - Pagination (page, limit)
*/
export function withSelection() {
return signalStoreFeature(
withState<SelectionState>(initialSelectionState),
withComputed((store) => ({
/**
* Get the selected category from the parent store's categories
*/
getSelectedCategory: computed(() => {
const categoryId = store.selectedCategoryId();
if (!categoryId) return null;
// Access parent store categories (from withContent)
const storeAny = store as any;
const allCategories = [
...(storeAny.vodCategories?.() || []),
...(storeAny.liveCategories?.() || []),
...(storeAny.serialCategories?.() || []),
];
return allCategories.find(
(c: any) => c.id === categoryId || c.category_id === String(categoryId)
);
}),
/**
* Get the selected item by ID from content
*/
getSelectedItemById: computed(() => {
const categoryType = store.selectedContentType();
const selectedItem = store.selectedItem();
if (!selectedItem) return null;
// Access parent store content (from withContent)
const storeAny = store as any;
const content =
categoryType === 'live'
? storeAny.liveStreams?.() || []
: categoryType === 'vod'
? storeAny.vodStreams?.() || []
: storeAny.serialStreams?.() || [];
return content.find(
(item: any) =>
item.stream_id === selectedItem.stream_id ||
item.id === selectedItem.id ||
item.series_id === selectedItem.series_id
);
}),
/**
* Get paginated content for the selected category
*/
getPaginatedContent: computed(() => {
const startIndex = store.page() * store.limit();
const endIndex = startIndex + store.limit();
const categoryId = store.selectedCategoryId();
if (!categoryId) return [];
const categoryType = store.selectedContentType();
// Access parent store content (from withContent)
const storeAny = store as any;
const content =
categoryType === 'live'
? storeAny.liveStreams?.() || []
: categoryType === 'vod'
? storeAny.vodStreams?.() || []
: storeAny.serialStreams?.() || [];
return content
.filter((item: any) => Number(item.category_id) === categoryId)
.slice(startIndex, endIndex);
}),
/**
* Get all items from the selected category (without pagination)
*/
selectItemsFromSelectedCategory: computed(() => {
const categoryId = store.selectedCategoryId();
const categoryType = store.selectedContentType();
if (!categoryId) return [];
// Access parent store content (from withContent)
const storeAny = store as any;
const content =
categoryType === 'live'
? storeAny.liveStreams?.() || []
: categoryType === 'vod'
? storeAny.vodStreams?.() || []
: storeAny.serialStreams?.() || [];
return content.filter(
(item: any) => Number(item.category_id) === categoryId
);
}),
/**
* Get total pages for the selected category
*/
getTotalPages: computed(() => {
const categoryId = store.selectedCategoryId();
if (!categoryId) return 0;
const categoryType = store.selectedContentType();
// Access parent store content (from withContent)
const storeAny = store as any;
const content =
categoryType === 'live'
? storeAny.liveStreams?.() || []
: categoryType === 'vod'
? storeAny.vodStreams?.() || []
: storeAny.serialStreams?.() || [];
const totalItems = content.filter(
(item: any) => Number(item.category_id) === categoryId
).length;
return Math.ceil(totalItems / store.limit());
}),
/**
* Check if paginated content is loading
*/
isPaginatedContentLoading: computed(() => {
// Access parent store loading state (from withContent)
const storeAny = store as any;
return storeAny.isLoadingContent?.() || false;
}),
/**
* Memoized category item counts map
*/
getCategoryItemCounts: computed(() => {
const type = store.selectedContentType();
// Access parent store content (from withContent)
const storeAny = store as any;
const streams =
type === 'live'
? storeAny.liveStreams?.() || []
: type === 'vod'
? storeAny.vodStreams?.() || []
: storeAny.serialStreams?.() || [];
const countMap = new Map<number, number>();
for (const item of streams) {
const catId = Number(item.category_id);
if (!isNaN(catId)) {
countMap.set(catId, (countMap.get(catId) || 0) + 1);
}
}
return countMap;
}),
/**
* Get categories for the currently selected content type
*/
getCategoriesBySelectedType: computed(() => {
const type = store.selectedContentType();
// Access parent store categories (from withContent)
const storeAny = store as any;
return type === 'live'
? storeAny.liveCategories?.() || []
: type === 'vod'
? storeAny.vodCategories?.() || []
: storeAny.serialCategories?.() || [];
}),
})),
withMethods((store) => ({
/**
* Set the content type (live, vod, series)
*/
setSelectedContentType(type: ContentType): void {
patchState(store, {
selectedContentType: type,
selectedCategoryId: null,
page: 0,
});
},
/**
* Set the selected category
*/
setSelectedCategory(categoryId: number): void {
patchState(store, {
selectedCategoryId: Number(categoryId),
page: 0,
});
},
/**
* Set the selected item
*/
setSelectedItem(item: any): void {
patchState(store, { selectedItem: item });
},
/**
* Set the current page
*/
setPage(page: number): void {
patchState(store, { page });
},
/**
* Set the page limit (items per page)
*/
setLimit(limit: number): void {
patchState(store, { limit });
localStorage.setItem('xtream-page-size', String(limit));
},
/**
* Reset selection state
*/
resetSelection(): void {
patchState(store, {
...initialSelectionState,
limit: Number(localStorage.getItem('xtream-page-size') ?? 25),
});
},
}))
);
}
@@ -0,0 +1,2 @@
export * from './features';
export * from './xtream.store';