From 97a678a98da6dc2a05427dbe52786add1440755a Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 11 Jan 2026 06:24:48 +0100 Subject: [PATCH] refactor: add xtream feature stores --- .../app/xtream-tauri/stores/features/index.ts | 6 + .../stores/features/with-content.feature.ts | 317 ++++++++++++++++++ .../stores/features/with-epg.feature.ts | 124 +++++++ .../stores/features/with-player.feature.ts | 170 ++++++++++ .../stores/features/with-portal.feature.ts | 158 +++++++++ .../stores/features/with-search.feature.ts | 102 ++++++ .../stores/features/with-selection.feature.ts | 271 +++++++++++++++ apps/web/src/app/xtream-tauri/stores/index.ts | 2 + 8 files changed, 1150 insertions(+) create mode 100644 apps/web/src/app/xtream-tauri/stores/features/index.ts create mode 100644 apps/web/src/app/xtream-tauri/stores/features/with-content.feature.ts create mode 100644 apps/web/src/app/xtream-tauri/stores/features/with-epg.feature.ts create mode 100644 apps/web/src/app/xtream-tauri/stores/features/with-player.feature.ts create mode 100644 apps/web/src/app/xtream-tauri/stores/features/with-portal.feature.ts create mode 100644 apps/web/src/app/xtream-tauri/stores/features/with-search.feature.ts create mode 100644 apps/web/src/app/xtream-tauri/stores/features/with-selection.feature.ts create mode 100644 apps/web/src/app/xtream-tauri/stores/index.ts diff --git a/apps/web/src/app/xtream-tauri/stores/features/index.ts b/apps/web/src/app/xtream-tauri/stores/features/index.ts new file mode 100644 index 000000000..cfa95752e --- /dev/null +++ b/apps/web/src/app/xtream-tauri/stores/features/index.ts @@ -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'; diff --git a/apps/web/src/app/xtream-tauri/stores/features/with-content.feature.ts b/apps/web/src/app/xtream-tauri/stores/features/with-content.feature.ts new file mode 100644 index 000000000..6170b033d --- /dev/null +++ b/apps/web/src/app/xtream-tauri/stores/features/with-content.feature.ts @@ -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(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 { + 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 { + 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 { + 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 { + 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 = { importCount: count }; + if (total !== undefined) { + updates.itemsToImport = total; + } + patchState(store, updates); + }, + + /** + * Reset content state + */ + resetContent(): void { + patchState(store, initialContentState); + }, + }; + }) + ); +} diff --git a/apps/web/src/app/xtream-tauri/stores/features/with-epg.feature.ts b/apps/web/src/app/xtream-tauri/stores/features/with-epg.feature.ts new file mode 100644 index 000000000..2fcc85fd7 --- /dev/null +++ b/apps/web/src/app/xtream-tauri/stores/features/with-epg.feature.ts @@ -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(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 { + 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 { + 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); + }, + }; + }) + ); +} diff --git a/apps/web/src/app/xtream-tauri/stores/features/with-player.feature.ts b/apps/web/src/app/xtream-tauri/stores/features/with-player.feature.ts new file mode 100644 index 000000000..93cf17d00 --- /dev/null +++ b/apps/web/src/app/xtream-tauri/stores/features/with-player.feature.ts @@ -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(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', + }); + }, + }; + }) + ); +} diff --git a/apps/web/src/app/xtream-tauri/stores/features/with-portal.feature.ts b/apps/web/src/app/xtream-tauri/stores/features/with-portal.feature.ts new file mode 100644 index 000000000..a4a07005e --- /dev/null +++ b/apps/web/src/app/xtream-tauri/stores/features/with-portal.feature.ts @@ -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(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 { + 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 { + 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): void { + const current = store.currentPlaylist(); + if (current) { + patchState(store, { + currentPlaylist: { ...current, ...updates }, + }); + } + }, + + /** + * Reset portal state + */ + resetPortal(): void { + patchState(store, initialPortalState); + }, + }; + }) + ); +} diff --git a/apps/web/src/app/xtream-tauri/stores/features/with-search.feature.ts b/apps/web/src/app/xtream-tauri/stores/features/with-search.feature.ts new file mode 100644 index 000000000..b0305f643 --- /dev/null +++ b/apps/web/src/app/xtream-tauri/stores/features/with-search.feature.ts @@ -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(initialSearchState), + + withMethods((store) => { + const dataSource = inject(XTREAM_DATA_SOURCE); + + return { + /** + * Search content within the current playlist + */ + async searchContent( + searchTerm: string, + types: string[] + ): Promise { + // 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); + }, + }; + }) + ); +} diff --git a/apps/web/src/app/xtream-tauri/stores/features/with-selection.feature.ts b/apps/web/src/app/xtream-tauri/stores/features/with-selection.feature.ts new file mode 100644 index 000000000..e9823b972 --- /dev/null +++ b/apps/web/src/app/xtream-tauri/stores/features/with-selection.feature.ts @@ -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(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(); + 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), + }); + }, + })) + ); +} diff --git a/apps/web/src/app/xtream-tauri/stores/index.ts b/apps/web/src/app/xtream-tauri/stores/index.ts new file mode 100644 index 000000000..89f2c09dd --- /dev/null +++ b/apps/web/src/app/xtream-tauri/stores/index.ts @@ -0,0 +1,2 @@ +export * from './features'; +export * from './xtream.store';