refactor: enhance live stream layout with loading state and caching

- Added `XtreamCachedContentScope` type to manage content loading states.
- Updated live stream layout to show a loading skeleton when content is being fetched.
- Introduced styles for loading content in the live stream layout.
- Enhanced unit tests to cover loading states and caching behavior.
- Modified `XtreamWorkspaceRouteSession` to handle cached content loading and state management.
- Improved handling of optional connection values in playlist data.
- Updated workspace context panel to reflect import states and loading conditions.

Entire-Checkpoint: c2cea9c530e6
This commit is contained in:
4gray committed 2026-04-30 18:54:58 +02:00
1 parent a6e886116c
commit cbedb22698
15 files changed
+1211 -120

No files matched your search

+6 -1
View File
@@ -54,7 +54,12 @@ Provider route integration:
3. Xtream and Stalker parent routes attach route-scoped session providers that
bootstrap the active playlist, sync provider section state, and clean up
provider-local state when the route is destroyed.
4. Workspace routes no longer rely on nested provider shell components for
4. Xtream route bootstrap is DB-first for already imported Electron playlists:
if the requested section has persisted categories and content, the route
hydrates from SQLite even when the portal status probe reports unavailable,
expired, or inactive. Fresh/no-cache Xtream routes still use the status probe
to block remote imports before the loading overlay starts.
5. Workspace routes no longer rely on nested provider shell components for
hidden local chrome.
## Shell Structure
@@ -223,6 +223,16 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
return this.dbService.getAllXtreamCategories(playlistId, type);
}
async getCachedCategories(
playlistId: string,
type: CategoryType
): Promise<XtreamCategoryFromDb[]> {
return this.dbService.getXtreamCategories(
playlistId,
mapCategoryTypeToDbType(type)
);
}
async saveCategories(
playlistId: string,
categories: XtreamCategory[],
@@ -330,6 +340,13 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
return this.dbService.getXtreamContent(playlistId, type);
}
async getCachedContent(
playlistId: string,
type: StreamType
): Promise<XtreamContentItem[]> {
return this.dbService.getXtreamContent(playlistId, type);
}
async saveContent(
playlistId: string,
streams:
@@ -171,6 +171,15 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
return [];
}
async getCachedCategories(
playlistId: string,
type: CategoryType
): Promise<XtreamCategoryFromDb[]> {
void playlistId;
void type;
return [];
}
async saveCategories(
playlistId: string,
categories: XtreamCategory[],
@@ -240,6 +249,15 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
return content;
}
async getCachedContent(
playlistId: string,
type: StreamType
): Promise<XtreamContentItem[]> {
void playlistId;
void type;
return [];
}
async saveContent(
playlistId: string,
streams:
@@ -182,6 +182,15 @@ export interface IXtreamDataSource {
options?: XtreamOperationOptions
): Promise<XtreamCategory[] | XtreamCategoryFromDb[]>;
/**
* Get persisted categories without contacting the Xtream API.
* Electron reads SQLite; PWA has no persisted DB cache and returns [].
*/
getCachedCategories(
playlistId: string,
type: CategoryType
): Promise<XtreamCategoryFromDb[]>;
/**
* Get all categories including hidden (for management)
*/
@@ -238,6 +247,15 @@ export interface IXtreamDataSource {
| XtreamContentItem[]
>;
/**
* Get persisted content without contacting the Xtream API.
* Electron reads SQLite; PWA has no persisted DB cache and returns [].
*/
getCachedContent(
playlistId: string,
type: StreamType
): Promise<XtreamContentItem[]>;
/**
* Save content in bulk
*/
@@ -36,6 +36,7 @@ const TestContentStore = signalStore(
playlistId: PLAYLIST.id,
currentPlaylist: PLAYLIST,
portalStatus: 'active' as PortalStatusType,
selectedContentType: 'vod' as const,
}),
withMethods((store) => ({
async checkPortalStatus(): Promise<PortalStatusType> {
@@ -84,7 +85,9 @@ describe('withContent import state', () => {
let store: InstanceType<typeof TestContentStore>;
let dataSource: {
getCategories: jest.Mock;
getCachedCategories: jest.Mock;
getContent: jest.Mock;
getCachedContent: jest.Mock;
hasCategories: jest.Mock;
hasContent: jest.Mock;
restoreUserData: jest.Mock;
@@ -107,9 +110,11 @@ describe('withContent import state', () => {
let operationCounter = 0;
dataSource = {
getCategories: jest.fn().mockResolvedValue([]),
getCachedCategories: jest.fn().mockResolvedValue([]),
getContent: jest.fn(),
getCachedContent: jest.fn().mockResolvedValue([]),
hasCategories: jest.fn().mockResolvedValue(true),
hasContent: jest.fn().mockResolvedValue(true),
hasContent: jest.fn().mockResolvedValue(false),
restoreUserData: jest.fn().mockResolvedValue(undefined),
};
databaseService = {
@@ -456,54 +461,283 @@ describe('withContent import state', () => {
);
});
it('reports offline cache availability only when every import type is completed and cached', async () => {
databaseService.getXtreamImportStatus
.mockResolvedValueOnce('completed')
.mockResolvedValueOnce('completed')
.mockResolvedValueOnce('completed');
dataSource.hasCategories
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(true);
dataSource.hasContent
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(true);
it('reports section cache availability from persisted categories and content', async () => {
dataSource.hasCategories.mockResolvedValueOnce(true);
dataSource.hasContent.mockResolvedValueOnce(true);
await expect(store.hasUsableOfflineCache()).resolves.toBe(true);
await expect(store.hasUsableOfflineCache('vod')).resolves.toBe(true);
expect(databaseService.getXtreamImportStatus).toHaveBeenNthCalledWith(
1,
expect(dataSource.hasCategories).toHaveBeenCalledWith(
PLAYLIST.id,
'live'
'movies'
);
expect(databaseService.getXtreamImportStatus).toHaveBeenNthCalledWith(
2,
expect(dataSource.hasContent).toHaveBeenCalledWith(
PLAYLIST.id,
'movie'
);
expect(databaseService.getXtreamImportStatus).toHaveBeenNthCalledWith(
3,
PLAYLIST.id,
'series'
);
expect(databaseService.getXtreamImportStatus).not.toHaveBeenCalled();
});
it('treats partial cache as unusable for offline initialization', async () => {
databaseService.getXtreamImportStatus
.mockResolvedValueOnce('completed')
.mockResolvedValueOnce('completed')
.mockResolvedValueOnce('completed');
dataSource.hasCategories
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(true);
it('does not require every content type for aggregate cached sections', async () => {
dataSource.hasContent
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(true);
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(false);
await expect(store.hasUsableOfflineCache()).resolves.toBe(false);
await expect(
store.hasUsableOfflineCache('recently-added')
).resolves.toBe(true);
expect(dataSource.hasCategories).not.toHaveBeenCalled();
});
it('treats missing category or content cache as unusable for a concrete section', async () => {
dataSource.hasCategories.mockResolvedValueOnce(true);
dataSource.hasContent.mockResolvedValueOnce(false);
await expect(store.hasUsableOfflineCache('live')).resolves.toBe(false);
});
it('hydrates persisted section content without remote API loading', async () => {
dataSource.getCachedCategories.mockResolvedValueOnce([
{
id: 1,
name: 'Movies',
playlist_id: PLAYLIST.id,
type: 'movies',
xtream_id: 10,
hidden: false,
},
]);
dataSource.getCachedContent.mockResolvedValueOnce([
{
id: 100,
category_id: 1,
title: 'Cached Movie',
rating: '',
added: '1',
poster_url: '',
xtream_id: 1000,
type: 'movie',
},
]);
await store.hydrateCachedContent('vod');
expect(dataSource.getCachedCategories).toHaveBeenCalledWith(
PLAYLIST.id,
'vod'
);
expect(dataSource.getCachedContent).toHaveBeenCalledWith(
PLAYLIST.id,
'movie'
);
expect(dataSource.getCategories).not.toHaveBeenCalled();
expect(dataSource.getContent).not.toHaveBeenCalled();
expect(store.vodCategories()).toHaveLength(1);
expect(store.vodStreams()).toHaveLength(1);
expect(store.contentLoadStateByType().vod).toBe('ready');
expect(store.isCachedContentScopeReady('vod')).toBe(true);
expect(store.isContentInitialized()).toBe(true);
expect(store.contentInitBlockReason()).toBeNull();
});
it('exposes loading state while cached section content is hydrating', async () => {
const cachedCategories = createDeferred<any[]>();
const cachedContent = createDeferred<any[]>();
dataSource.getCachedCategories.mockReturnValueOnce(
cachedCategories.promise
);
dataSource.getCachedContent.mockReturnValueOnce(cachedContent.promise);
const hydration = store.hydrateCachedContent('vod');
await Promise.resolve();
expect(store.isLoadingCategories()).toBe(true);
expect(store.isLoadingContent()).toBe(true);
expect(store.contentLoadStateByType().vod).toBe('loading');
expect(store.isContentInitialized()).toBe(false);
cachedCategories.resolve([
{
id: 1,
name: 'Movies',
playlist_id: PLAYLIST.id,
type: 'movies',
xtream_id: 10,
hidden: false,
},
]);
cachedContent.resolve([
{
id: 100,
category_id: 1,
title: 'Cached Movie',
rating: '',
added: '1',
poster_url: '',
xtream_id: 1000,
type: 'movie',
},
]);
await hydration;
expect(store.isLoadingCategories()).toBe(false);
expect(store.isLoadingContent()).toBe(false);
expect(store.contentLoadStateByType().vod).toBe('ready');
expect(store.vodStreams()).toHaveLength(1);
});
it('coalesces concurrent cached hydration calls for the same scope', async () => {
const cachedCategories = createDeferred<any[]>();
const cachedContent = createDeferred<any[]>();
dataSource.getCachedCategories.mockReturnValueOnce(
cachedCategories.promise
);
dataSource.getCachedContent.mockReturnValueOnce(cachedContent.promise);
const firstHydration = store.hydrateCachedContent('vod');
await Promise.resolve();
const secondHydration = store.hydrateCachedContent('vod');
expect(dataSource.getCachedCategories).toHaveBeenCalledTimes(1);
expect(dataSource.getCachedContent).toHaveBeenCalledTimes(1);
cachedCategories.resolve([
{
id: 1,
name: 'Movies',
playlist_id: PLAYLIST.id,
type: 'movies',
xtream_id: 10,
hidden: false,
},
]);
cachedContent.resolve([
{
id: 100,
category_id: 1,
title: 'Cached Movie',
rating: '',
added: '1',
poster_url: '',
xtream_id: 1000,
type: 'movie',
},
]);
await Promise.all([firstHydration, secondHydration]);
expect(store.contentLoadStateByType().vod).toBe('ready');
expect(store.vodStreams()).toHaveLength(1);
});
it('keeps initialized offline content visible while hydrating another cached type', async () => {
dataSource.getCachedCategories.mockResolvedValueOnce([
{
id: 1,
name: 'Movies',
playlist_id: PLAYLIST.id,
type: 'movies',
xtream_id: 10,
hidden: false,
},
]);
dataSource.getCachedContent.mockResolvedValueOnce([
{
id: 100,
category_id: 1,
title: 'Cached Movie',
rating: '',
added: '1',
poster_url: '',
xtream_id: 1000,
type: 'movie',
},
]);
await store.hydrateCachedContent('vod');
expect(store.isContentInitialized()).toBe(true);
dataSource.getCachedCategories.mockClear();
dataSource.getCachedContent.mockClear();
const cachedSeriesCategories = createDeferred<any[]>();
const cachedSeriesContent = createDeferred<any[]>();
dataSource.getCachedCategories.mockReturnValueOnce(
cachedSeriesCategories.promise
);
dataSource.getCachedContent.mockReturnValueOnce(
cachedSeriesContent.promise
);
const hydration = store.hydrateCachedContent('series');
await Promise.resolve();
expect(store.isContentInitialized()).toBe(true);
expect(store.contentLoadStateByType()).toEqual({
live: 'idle',
vod: 'ready',
series: 'loading',
});
cachedSeriesCategories.resolve([
{
id: 2,
name: 'Series',
playlist_id: PLAYLIST.id,
type: 'series',
xtream_id: 20,
hidden: false,
},
]);
cachedSeriesContent.resolve([
{
id: 200,
category_id: 2,
title: 'Cached Series',
rating: '',
added: '1',
poster_url: '',
xtream_id: 2000,
type: 'series',
},
]);
await hydration;
expect(store.isContentInitialized()).toBe(true);
expect(store.contentLoadStateByType().series).toBe('ready');
expect(store.serialStreams()).toHaveLength(1);
});
it('marks aggregate cached hydration scopes ready even when some types are empty', async () => {
await store.hydrateCachedContent('recently-added');
expect(dataSource.getCachedCategories).toHaveBeenCalledTimes(3);
expect(dataSource.getCachedContent).toHaveBeenCalledTimes(3);
expect(store.contentLoadStateByType()).toEqual({
live: 'ready',
vod: 'ready',
series: 'ready',
});
expect(store.isCachedContentScopeReady('recently-added')).toBe(true);
expect(store.isContentInitialized()).toBe(true);
});
it('can mark a routed section as loading before async bootstrap starts', () => {
store.prepareContentLoading('series');
expect(store.isLoadingCategories()).toBe(true);
expect(store.isLoadingContent()).toBe(true);
expect(store.isContentInitialized()).toBe(false);
expect(store.contentInitBlockReason()).toBeNull();
expect(store.contentLoadStateByType()).toEqual({
live: 'idle',
vod: 'idle',
series: 'loading',
});
});
it('ignores concurrent initializeContent calls while an import is already running', async () => {
@@ -645,6 +879,46 @@ describe('withContent import state', () => {
expect(dataSource.getContent).not.toHaveBeenCalled();
});
it('hydrates cached content on retry when status remains unavailable', async () => {
checkPortalStatusMock.mockResolvedValue('unavailable');
dataSource.hasCategories.mockResolvedValueOnce(true);
dataSource.hasContent.mockResolvedValueOnce(true);
dataSource.getCachedCategories.mockResolvedValueOnce([
{
id: 1,
name: 'Movies',
playlist_id: PLAYLIST.id,
type: 'movies',
xtream_id: 10,
hidden: false,
},
]);
dataSource.getCachedContent.mockResolvedValueOnce([
{
id: 100,
category_id: 1,
title: 'Cached Movie',
rating: '',
added: '1',
poster_url: '',
xtream_id: 1000,
type: 'movie',
},
]);
store.setContentInitBlockReason('unavailable');
await store.retryContentInitialization();
expect(checkPortalStatusMock).toHaveBeenCalledTimes(1);
expect(dataSource.getCachedContent).toHaveBeenCalledWith(
PLAYLIST.id,
'movie'
);
expect(store.contentInitBlockReason()).toBeNull();
expect(store.vodStreams()).toHaveLength(1);
expect(dataSource.getContent).not.toHaveBeenCalled();
});
it('keeps a cancelled block sticky until retry or reset clears it', async () => {
store.setContentInitBlockReason('cancelled');
@@ -32,6 +32,7 @@ import {
import {
ContentType,
PortalStatusType,
XtreamCachedContentScope,
XtreamContentInitBlockReason,
XtreamContentLoadState,
XtreamContentLoadStateByType,
@@ -146,6 +147,7 @@ export function withContent() {
const logger = createLogger('withContent');
type ParentPortalStoreLike = {
currentPlaylist?: () => {
id?: string;
password: string;
serverUrl: string;
username: string;
@@ -153,6 +155,7 @@ export function withContent() {
playlistId?: () => string | null;
portalStatus?: () => PortalStatusType;
checkPortalStatus?: () => Promise<PortalStatusType>;
selectedContentType?: () => ContentType | undefined;
};
return signalStoreFeature(
@@ -214,6 +217,83 @@ export function withContent() {
const xtreamApiService = inject(XtreamApiService);
const importTypes: ContentType[] = ['live', 'vod', 'series'];
let activeInitializationPromise: Promise<void> | null = null;
let cachedHydrationGeneration = 0;
const activeCachedHydrationPromises = new Map<
string,
Promise<void>
>();
const getCachedHydrationKey = (
playlistId: string,
scope?: XtreamCachedContentScope | null
): string => `${playlistId}:${scope ?? 'all'}`;
const toDbCategoryType = (type: ContentType): DbCategoryType => {
switch (type) {
case 'live':
return 'live';
case 'vod':
return 'movies';
case 'series':
return 'series';
}
};
const toCategoryType = (
type: ContentType
): 'live' | 'vod' | 'series' => type;
const toStreamType = (
type: ContentType
): 'live' | 'movie' | 'series' => {
return type === 'vod' ? 'movie' : type;
};
const getTypesForCacheScope = (
scope?: XtreamCachedContentScope | null
): ContentType[] => {
switch (scope) {
case 'live':
case 'vod':
case 'series':
return [scope];
case 'search':
case 'recently-added':
default:
return importTypes;
}
};
const asCachedContent = <T>(content: unknown): T[] =>
content as T[];
const markContentScopeLoading = (
scope?: XtreamCachedContentScope | null,
options?: { preserveInitialized?: boolean }
): void => {
const types = getTypesForCacheScope(scope);
patchState(store, (state) => {
const nextLoadStates = {
...state.contentLoadStateByType,
};
for (const type of types) {
nextLoadStates[type] = 'loading';
}
return {
isLoadingCategories: true,
isLoadingContent: true,
isImporting: false,
isContentInitialized: options?.preserveInitialized
? state.isContentInitialized
: false,
contentInitBlockReason: null,
contentLoadStateByType: nextLoadStates,
};
});
};
const updateContentTypeLoadState = (
type: ContentType,
@@ -301,42 +381,224 @@ export function withContent() {
};
};
const hasCompletedOfflineCache = async (
playlistId: string
const hasCachedContentForType = async (
playlistId: string,
type: ContentType
): Promise<boolean> => {
const contentTypes: Array<{
categoryType: DbCategoryType;
contentType: 'live' | 'movie' | 'series';
}> = [
{ categoryType: 'live', contentType: 'live' },
{ categoryType: 'movies', contentType: 'movie' },
{ categoryType: 'series', contentType: 'series' },
];
const [hasCategories, hasContent] = await Promise.all([
dataSource.hasCategories(playlistId, toDbCategoryType(type)),
dataSource.hasContent(playlistId, toStreamType(type)),
]);
const cacheChecks = await Promise.all(
contentTypes.map(async ({ categoryType, contentType }) => {
const [importStatus, hasCategories, hasContent] =
await Promise.all([
databaseService.getXtreamImportStatus(
return hasCategories && hasContent;
};
const hasCachedContentForScope = async (
playlistId: string,
scope?: XtreamCachedContentScope | null
): Promise<boolean> => {
const types = getTypesForCacheScope(scope);
if (scope === 'search' || scope === 'recently-added' || !scope) {
const checks = await Promise.all(
types.map((type) =>
dataSource.hasContent(playlistId, toStreamType(type))
)
);
return checks.some(Boolean);
}
return hasCachedContentForType(playlistId, scope);
};
const isCurrentCachedHydrationContext = (
playlistId: string,
generation: number
): boolean => {
if (cachedHydrationGeneration !== generation) {
return false;
}
const storeAny = getPortalStore();
const currentPlaylist = storeAny.currentPlaylist?.();
const currentPlaylistId = storeAny.playlistId?.();
return (
currentPlaylistId === playlistId &&
(!currentPlaylist?.id || currentPlaylist.id === playlistId)
);
};
const isCachedContentScopeReady = (
scope?: XtreamCachedContentScope | null
): boolean => {
const types = getTypesForCacheScope(scope);
const loadStates = store.contentLoadStateByType();
return types.every((type) => loadStates[type] === 'ready');
};
const executeCachedContentHydration = async (
playlistId: string,
scope: XtreamCachedContentScope | null | undefined,
generation: number
): Promise<void> => {
const types = getTypesForCacheScope(scope);
markContentScopeLoading(scope, {
preserveInitialized: store.isContentInitialized(),
});
let cachedEntries: Array<{
type: ContentType;
categories: Awaited<
ReturnType<typeof dataSource.getCachedCategories>
>;
content: Awaited<
ReturnType<typeof dataSource.getCachedContent>
>;
}>;
try {
cachedEntries = await Promise.all(
types.map(async (type) => {
const [categories, content] = await Promise.all([
dataSource.getCachedCategories(
playlistId,
contentType
toCategoryType(type)
),
dataSource.hasCategories(
dataSource.getCachedContent(
playlistId,
categoryType
toStreamType(type)
),
dataSource.hasContent(playlistId, contentType),
]);
return (
importStatus === 'completed' &&
hasCategories &&
hasContent
);
})
);
return { type, categories, content };
})
);
} catch (error) {
if (
!isCurrentCachedHydrationContext(
playlistId,
generation
)
) {
return;
}
return cacheChecks.every(Boolean);
const errorBlockReason: XtreamContentInitBlockReason =
'error';
patchState(store, (state) => {
const nextLoadStates = {
...state.contentLoadStateByType,
};
for (const type of types) {
nextLoadStates[type] = 'error';
}
return {
isLoadingCategories: false,
isLoadingContent: false,
contentInitBlockReason: errorBlockReason,
contentLoadStateByType: nextLoadStates,
};
});
throw error;
}
if (
!isCurrentCachedHydrationContext(playlistId, generation)
) {
return;
}
patchState(store, (state) => {
const nextLoadStates = {
...state.contentLoadStateByType,
};
const updates: Partial<ContentState> = {
isLoadingCategories: false,
isLoadingContent: false,
isImporting: false,
isContentInitialized: true,
contentInitBlockReason: null,
};
for (const entry of cachedEntries) {
nextLoadStates[entry.type] = 'ready';
switch (entry.type) {
case 'live':
updates.liveCategories = entry.categories;
updates.liveStreams =
asCachedContent<XtreamLiveStream>(
entry.content
);
break;
case 'vod':
updates.vodCategories = entry.categories;
updates.vodStreams =
asCachedContent<XtreamVodStream>(
entry.content
);
break;
case 'series':
updates.serialCategories = entry.categories;
updates.serialStreams =
asCachedContent<XtreamSerieItem>(
entry.content
);
break;
}
}
updates.contentLoadStateByType = nextLoadStates;
return updates;
});
};
const hydrateCachedContentForScope = async (
scope?: XtreamCachedContentScope | null
): Promise<void> => {
const ctx = getCredentialsFromStore();
if (!ctx) return;
if (isCachedContentScopeReady(scope)) {
patchState(store, {
isLoadingCategories: false,
isLoadingContent: false,
isContentInitialized: true,
contentInitBlockReason: null,
});
return;
}
const requestKey = getCachedHydrationKey(
ctx.playlistId,
scope
);
const inFlightRequest =
activeCachedHydrationPromises.get(requestKey);
if (inFlightRequest) {
return inFlightRequest;
}
const generation = cachedHydrationGeneration;
const request = executeCachedContentHydration(
ctx.playlistId,
scope,
generation
).finally(() => {
if (
activeCachedHydrationPromises.get(requestKey) ===
request
) {
activeCachedHydrationPromises.delete(requestKey);
}
});
activeCachedHydrationPromises.set(requestKey, request);
return request;
};
const trackImportEvent = (event: DbOperationEvent): void => {
@@ -877,13 +1139,33 @@ export function withContent() {
await runContentInitialization();
},
async hasUsableOfflineCache(): Promise<boolean> {
async hasUsableOfflineCache(
scope?: XtreamCachedContentScope | null
): Promise<boolean> {
const ctx = getCredentialsFromStore();
if (!ctx) {
return false;
}
return hasCompletedOfflineCache(ctx.playlistId);
return hasCachedContentForScope(ctx.playlistId, scope);
},
prepareContentLoading(
scope?: XtreamCachedContentScope | null
): void {
markContentScopeLoading(scope);
},
isCachedContentScopeReady(
scope?: XtreamCachedContentScope | null
): boolean {
return isCachedContentScopeReady(scope);
},
async hydrateCachedContent(
scope?: XtreamCachedContentScope | null
): Promise<void> {
await hydrateCachedContentForScope(scope);
},
async retryContentInitialization(): Promise<void> {
@@ -892,8 +1174,25 @@ export function withContent() {
getPortalStore().portalStatus?.() ??
'unavailable';
const blockReason = resolveInitBlockReason(portalStatus);
const ctx = getCredentialsFromStore();
const cacheScope =
getPortalStore().selectedContentType?.() ?? null;
if (blockReason) {
if (
ctx &&
(await hasCachedContentForScope(
ctx.playlistId,
cacheScope
))
) {
clearCancelledPlaylistInitializationLock(
ctx.playlistId
);
await hydrateCachedContentForScope(cacheScope);
return;
}
patchState(store, {
contentInitBlockReason: blockReason,
});
@@ -904,7 +1203,6 @@ export function withContent() {
contentInitBlockReason: null,
isContentInitialized: false,
});
const ctx = getCredentialsFromStore();
if (ctx) {
clearCancelledPlaylistInitializationLock(
ctx.playlistId
@@ -998,6 +1296,8 @@ export function withContent() {
* Reset content state
*/
resetContent(): void {
cachedHydrationGeneration += 1;
activeCachedHydrationPromises.clear();
patchState(store, initialContentState);
},
};
@@ -12,6 +12,10 @@ import {
} from './data-sources/xtream-data-source.interface';
export type ContentType = 'live' | 'vod' | 'series';
export type XtreamCachedContentScope =
| ContentType
| 'search'
| 'recently-added';
export type XtreamContentLoadState = 'idle' | 'loading' | 'ready' | 'error';
export type XtreamContentLoadStateByType = Record<
ContentType,
@@ -68,10 +68,20 @@
}
<div class="content-container">
@if (!selectedCategoryId()) {
<app-portal-empty-state
icon="live_tv"
[message]="'PORTALS.SELECT_CATEGORY' | translate"
/>
@if (isSelectedTypeContentLoading()) {
<div class="live-loading-content">
<app-portal-channels-list
[sortMode]="liveChannelSortMode()"
[searchTermInput]="workspaceSearchTerm()"
(playClicked)="playLive($event)"
/>
</div>
} @else {
<app-portal-empty-state
icon="live_tv"
[message]="'PORTALS.SELECT_CATEGORY' | translate"
/>
}
} @else if (streamUrl) {
@if (isEmbeddedPlayer) {
<div class="video-player">
@@ -19,6 +19,17 @@
}
}
.live-loading-content {
align-self: stretch;
width: min(520px, 100%);
height: 100%;
}
.live-loading-content app-portal-channels-list {
display: block;
height: 100%;
}
.loading-indicator {
display: flex;
justify-content: center;
@@ -31,7 +31,7 @@ const LIVE_CHANNEL_SORT_STORAGE_KEY = 'xtream-live-channel-sort-mode';
@Component({
selector: 'app-portal-channels-list',
standalone: true,
template: '',
template: '<div data-test-id="portal-channels-list-stub"></div>',
})
class StubPortalChannelsListComponent {
readonly sortMode = input<'server' | 'name-asc' | 'name-desc'>('server');
@@ -120,6 +120,7 @@ describe('LiveStreamLayoutComponent', () => {
const epgItems = signal<EpgItem[]>([]);
const currentEpgItem = signal<EpgItem | null>(null);
const isLoadingEpg = signal(false);
const selectedTypeContentLoading = signal(false);
const selectedCategoryId = signal<number | null>(1);
const selectedContentType = signal<'live' | 'vod' | 'series'>('live');
const selectedItem = signal<unknown>(sampleChannel);
@@ -131,6 +132,7 @@ describe('LiveStreamLayoutComponent', () => {
epgItems,
currentEpgItem,
isLoadingEpg,
selectedTypeContentLoading,
selectedCategoryId,
selectedContentType,
selectedItem,
@@ -176,6 +178,7 @@ describe('LiveStreamLayoutComponent', () => {
epgItems.set([]);
currentEpgItem.set(null);
isLoadingEpg.set(false);
selectedTypeContentLoading.set(false);
selectedCategoryId.set(1);
selectedContentType.set('live');
selectedItem.set(sampleChannel);
@@ -315,6 +318,22 @@ describe('LiveStreamLayoutComponent', () => {
).toBe(false);
});
it('shows the channel loading skeleton surface while live content loads without a selected category', () => {
selectedCategoryId.set(null);
selectedTypeContentLoading.set(true);
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector(
'[data-test-id="portal-channels-list-stub"]'
)
).not.toBeNull();
expect(
fixture.nativeElement.querySelector('app-portal-empty-state')
).toBeNull();
});
it('renders the current EPG program in the collapsible panel summary', () => {
epgItems.set([
buildEpgItem(
@@ -99,6 +99,8 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
readonly categoryItemCounts = this.xtreamStore.getCategoryItemCounts;
readonly epgItems = this.xtreamStore.epgItems;
readonly currentEpgItem = this.xtreamStore.currentEpgItem;
readonly isSelectedTypeContentLoading =
this.xtreamStore.selectedTypeContentLoading;
readonly isLoadingEpg = this.xtreamStore.isLoadingEpg;
readonly selectedCategoryId = this.xtreamStore.selectedCategoryId;
readonly liveChannelSortMode = signal<PortalChannelSortMode>('server');
@@ -1,6 +1,6 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { NavigationEnd, Router } from '@angular/router';
import { NavigationEnd, NavigationStart, Router } from '@angular/router';
import { Subject } from 'rxjs';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import {
@@ -13,6 +13,7 @@ import { PlaylistMeta } from 'shared-interfaces';
import { XtreamWorkspaceRouteSession } from './xtream-workspace-route-session.service';
const PLAYLIST_ID = 'playlist-1';
const NEXT_PLAYLIST_ID = 'playlist-2';
const ACTIVE_PLAYLIST: PlaylistMeta = {
_id: PLAYLIST_ID,
filename: 'xtream.m3u',
@@ -41,10 +42,12 @@ const XTREAM_PLAYLIST_WITH_NULL_OPTIONALS = {
referrer: null,
userAgent: null,
} as XtreamPlaylistData;
type CachedScope = 'live' | 'vod' | 'series' | 'search' | 'recently-added';
async function flushEffects(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
for (let index = 0; index < 6; index += 1) {
await Promise.resolve();
}
}
function getXtreamSectionFromUrl(url: string): string | null {
@@ -55,8 +58,19 @@ function getXtreamSectionFromUrl(url: string): string | null {
return match?.[1] ?? null;
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
describe('XtreamWorkspaceRouteSession', () => {
const routerEvents = new Subject<NavigationEnd>();
const routerEvents = new Subject<NavigationEnd | NavigationStart>();
const routeProvider = signal<'xtreams' | null>('xtreams');
const routePlaylistId = signal<string | null>(PLAYLIST_ID);
const activePlaylist = signal<PlaylistMeta | null>(ACTIVE_PLAYLIST);
@@ -66,6 +80,13 @@ describe('XtreamWorkspaceRouteSession', () => {
const selectedContentType = signal<'live' | 'vod' | 'series'>('vod');
const selectedCategoryId = signal<number | null>(null);
const isContentInitialized = signal(false);
const contentLoadStateByType = signal<
Record<'live' | 'vod' | 'series', 'idle' | 'loading' | 'ready' | 'error'>
>({
live: 'idle',
vod: 'idle',
series: 'idle',
});
const contentInitBlockReason =
signal<XtreamContentInitBlockReason | null>(null);
let hasUsableOfflineCache = false;
@@ -86,6 +107,11 @@ describe('XtreamWorkspaceRouteSession', () => {
currentPlaylist.set(null);
selectedContentType.set('vod');
isContentInitialized.set(false);
contentLoadStateByType.set({
live: 'idle',
vod: 'idle',
series: 'idle',
});
}),
setCurrentPlaylist: jest.fn((playlist: XtreamPlaylistData | null) => {
currentPlaylist.set(playlist);
@@ -95,11 +121,66 @@ describe('XtreamWorkspaceRouteSession', () => {
hasUsableOfflineCache: jest.fn().mockImplementation(async () => {
return hasUsableOfflineCache;
}),
isCachedContentScopeReady: jest.fn(
(scope?: CachedScope | null): boolean => {
const loadStates = contentLoadStateByType();
if (scope === 'live' || scope === 'vod' || scope === 'series') {
return loadStates[scope] === 'ready';
}
return (
loadStates.live === 'ready' &&
loadStates.vod === 'ready' &&
loadStates.series === 'ready'
);
}
),
isContentInitialized,
contentLoadStateByType,
contentInitBlockReason,
initializeContent: jest.fn().mockImplementation(async () => {
isContentInitialized.set(true);
contentLoadStateByType.set({
live: 'ready',
vod: 'ready',
series: 'ready',
});
}),
hydrateCachedContent: jest.fn().mockImplementation(
async (scope?: CachedScope | null) => {
isContentInitialized.set(true);
contentInitBlockReason.set(null);
if (scope === 'live' || scope === 'vod' || scope === 'series') {
contentLoadStateByType.update((state) => ({
...state,
[scope]: 'ready',
}));
} else {
contentLoadStateByType.set({
live: 'ready',
vod: 'ready',
series: 'ready',
});
}
}
),
prepareContentLoading: jest.fn(
(scope?: CachedScope | null) => {
isContentInitialized.set(false);
if (scope === 'live' || scope === 'vod' || scope === 'series') {
contentLoadStateByType.update((state) => ({
...state,
[scope]: 'loading',
}));
} else {
contentLoadStateByType.set({
live: 'loading',
vod: 'loading',
series: 'loading',
});
}
}
),
setSelectedContentType: jest.fn(
(type: 'live' | 'vod' | 'series') => {
selectedContentType.set(type);
@@ -131,6 +212,11 @@ describe('XtreamWorkspaceRouteSession', () => {
selectedContentType.set('vod');
selectedCategoryId.set(null);
isContentInitialized.set(false);
contentLoadStateByType.set({
live: 'idle',
vod: 'idle',
series: 'idle',
});
contentInitBlockReason.set(null);
hasUsableOfflineCache = false;
@@ -153,7 +239,10 @@ describe('XtreamWorkspaceRouteSession', () => {
xtreamStore.fetchXtreamPlaylist.mockClear();
xtreamStore.checkPortalStatus.mockReset();
xtreamStore.hasUsableOfflineCache.mockClear();
xtreamStore.isCachedContentScopeReady.mockClear();
xtreamStore.initializeContent.mockClear();
xtreamStore.hydrateCachedContent.mockClear();
xtreamStore.prepareContentLoading.mockClear();
xtreamStore.setSelectedContentType.mockClear();
xtreamStore.setSelectedCategory.mockClear();
xtreamStore.setContentInitBlockReason.mockClear();
@@ -189,9 +278,78 @@ describe('XtreamWorkspaceRouteSession', () => {
expect(xtreamStore.checkPortalStatus).toHaveBeenCalled();
expect(xtreamStore.setContentInitBlockReason).toHaveBeenCalledWith(null);
expect(xtreamStore.setSelectedContentType).toHaveBeenCalledWith('vod');
expect(xtreamStore.prepareContentLoading).toHaveBeenCalledWith('vod');
expect(xtreamStore.initializeContent).toHaveBeenCalled();
});
it('marks the routed section loading before status checks complete during playlist switches', async () => {
const portalStatusCheck = createDeferred<PortalStatusType>();
xtreamStore.checkPortalStatus.mockReturnValue(
portalStatusCheck.promise
);
TestBed.inject(XtreamWorkspaceRouteSession);
await flushEffects();
expect(xtreamStore.resetStore).toHaveBeenCalledWith(PLAYLIST_ID);
expect(xtreamStore.prepareContentLoading).toHaveBeenCalledWith('vod');
expect(contentLoadStateByType().vod).toBe('loading');
expect(xtreamStore.initializeContent).not.toHaveBeenCalled();
portalStatus.set('active');
portalStatusCheck.resolve('active');
await flushEffects();
expect(xtreamStore.initializeContent).toHaveBeenCalled();
});
it('prepares loading on navigation start when switching Xtream playlists', async () => {
currentPlaylist.set(XTREAM_PLAYLIST);
playlistId.set(PLAYLIST_ID);
isContentInitialized.set(true);
contentLoadStateByType.set({
live: 'ready',
vod: 'ready',
series: 'ready',
});
TestBed.inject(XtreamWorkspaceRouteSession);
await flushEffects();
xtreamStore.prepareContentLoading.mockClear();
routerEvents.next(
new NavigationStart(
1,
`/workspace/xtreams/${NEXT_PLAYLIST_ID}/live`
)
);
expect(xtreamStore.prepareContentLoading).toHaveBeenCalledWith('live');
expect(contentLoadStateByType().live).toBe('loading');
});
it('does not prepare loading on navigation start for category changes in the current playlist', async () => {
currentPlaylist.set(XTREAM_PLAYLIST);
playlistId.set(PLAYLIST_ID);
isContentInitialized.set(true);
contentLoadStateByType.set({
live: 'ready',
vod: 'ready',
series: 'ready',
});
TestBed.inject(XtreamWorkspaceRouteSession);
await flushEffects();
xtreamStore.prepareContentLoading.mockClear();
routerEvents.next(
new NavigationStart(1, `/workspace/xtreams/${PLAYLIST_ID}/vod/202`)
);
expect(xtreamStore.prepareContentLoading).not.toHaveBeenCalled();
expect(contentLoadStateByType().vod).toBe('ready');
});
it('reapplies a live route section after resetStore restores the default selection', async () => {
router.url = `/workspace/xtreams/${PLAYLIST_ID}/live`;
xtreamStore.checkPortalStatus.mockImplementation(async () => {
@@ -204,6 +362,7 @@ describe('XtreamWorkspaceRouteSession', () => {
expect(xtreamStore.resetStore).toHaveBeenCalledWith(PLAYLIST_ID);
expect(xtreamStore.setSelectedContentType).toHaveBeenCalledWith('live');
expect(xtreamStore.prepareContentLoading).toHaveBeenCalledWith('live');
expect(selectedContentType()).toBe('live');
expect(
xtreamStore.setSelectedContentType.mock.invocationCallOrder[0]
@@ -215,6 +374,11 @@ describe('XtreamWorkspaceRouteSession', () => {
currentPlaylist.set(XTREAM_PLAYLIST);
playlistId.set(PLAYLIST_ID);
isContentInitialized.set(true);
contentLoadStateByType.set({
live: 'ready',
vod: 'ready',
series: 'ready',
});
TestBed.inject(XtreamWorkspaceRouteSession);
await flushEffects();
@@ -227,11 +391,22 @@ describe('XtreamWorkspaceRouteSession', () => {
expect(xtreamStore.initializeContent).not.toHaveBeenCalled();
});
it('does not treat null and undefined Xtream connection metadata as a playlist change', async () => {
it('does not treat blank and null Xtream connection metadata as a playlist change', async () => {
router.url = `/workspace/xtreams/${PLAYLIST_ID}/vod/101`;
activePlaylist.set({
...ACTIVE_PLAYLIST,
origin: '',
referrer: '',
userAgent: '',
} as PlaylistMeta);
currentPlaylist.set(XTREAM_PLAYLIST_WITH_NULL_OPTIONALS);
playlistId.set(PLAYLIST_ID);
isContentInitialized.set(true);
contentLoadStateByType.set({
live: 'ready',
vod: 'ready',
series: 'ready',
});
TestBed.inject(XtreamWorkspaceRouteSession);
await flushEffects();
@@ -277,6 +452,67 @@ describe('XtreamWorkspaceRouteSession', () => {
expect(xtreamStore.initializeContent).not.toHaveBeenCalled();
});
it('does not rehydrate cached offline content when switching categories in a ready section', async () => {
router.url = `/workspace/xtreams/${PLAYLIST_ID}/vod/101`;
currentPlaylist.set(XTREAM_PLAYLIST);
playlistId.set(PLAYLIST_ID);
portalStatus.set('unavailable');
isContentInitialized.set(true);
contentLoadStateByType.set({
live: 'idle',
vod: 'ready',
series: 'idle',
});
TestBed.inject(XtreamWorkspaceRouteSession);
await flushEffects();
expect(xtreamStore.setSelectedContentType).toHaveBeenCalledWith('vod');
expect(xtreamStore.setSelectedCategory).toHaveBeenCalledWith(101);
expect(selectedCategoryId()).toBe(101);
expect(xtreamStore.hasUsableOfflineCache).not.toHaveBeenCalled();
expect(xtreamStore.hydrateCachedContent).not.toHaveBeenCalled();
expect(xtreamStore.initializeContent).not.toHaveBeenCalled();
});
it('hydrates an offline cached type once and skips later category hydrations', async () => {
router.url = `/workspace/xtreams/${PLAYLIST_ID}/series`;
currentPlaylist.set(XTREAM_PLAYLIST);
playlistId.set(PLAYLIST_ID);
portalStatus.set('unavailable');
isContentInitialized.set(true);
hasUsableOfflineCache = true;
contentLoadStateByType.set({
live: 'idle',
vod: 'ready',
series: 'idle',
});
TestBed.inject(XtreamWorkspaceRouteSession);
await flushEffects();
expect(xtreamStore.hasUsableOfflineCache).toHaveBeenCalledWith(
'series'
);
expect(xtreamStore.hydrateCachedContent).toHaveBeenCalledWith(
'series'
);
expect(contentLoadStateByType().series).toBe('ready');
xtreamStore.hasUsableOfflineCache.mockClear();
xtreamStore.hydrateCachedContent.mockClear();
xtreamStore.setSelectedCategory.mockClear();
router.url = `/workspace/xtreams/${PLAYLIST_ID}/series/303`;
routerEvents.next(new NavigationEnd(1, router.url, router.url));
await flushEffects();
expect(xtreamStore.setSelectedCategory).toHaveBeenCalledWith(303);
expect(xtreamStore.hasUsableOfflineCache).not.toHaveBeenCalled();
expect(xtreamStore.hydrateCachedContent).not.toHaveBeenCalled();
expect(xtreamStore.initializeContent).not.toHaveBeenCalled();
});
it.each(['expired', 'inactive', 'unavailable'] as const)(
'blocks %s portals before import-driven initialization starts',
async (status) => {
@@ -296,7 +532,54 @@ describe('XtreamWorkspaceRouteSession', () => {
}
);
it('allows unavailable portals to initialize cached content', async () => {
it.each(['expired', 'inactive', 'unavailable'] as const)(
'hydrates cached content for %s portals',
async (status) => {
hasUsableOfflineCache = true;
activePlaylist.set(UPDATED_ACTIVE_PLAYLIST);
xtreamStore.checkPortalStatus.mockImplementation(async () => {
portalStatus.set(status);
return status;
});
TestBed.inject(XtreamWorkspaceRouteSession);
await flushEffects();
await flushEffects();
expect(xtreamStore.checkPortalStatus).toHaveBeenCalled();
expect(xtreamStore.hasUsableOfflineCache).toHaveBeenCalledWith(
'vod'
);
expect(xtreamStore.setContentInitBlockReason).toHaveBeenCalledWith(
null
);
expect(xtreamStore.hydrateCachedContent).toHaveBeenCalledWith(
'vod'
);
expect(xtreamStore.initializeContent).not.toHaveBeenCalled();
}
);
it('keeps active cached playlists on the normal initialization path', async () => {
hasUsableOfflineCache = true;
activePlaylist.set(UPDATED_ACTIVE_PLAYLIST);
xtreamStore.checkPortalStatus.mockImplementation(async () => {
portalStatus.set('active');
return 'active';
});
TestBed.inject(XtreamWorkspaceRouteSession);
await flushEffects();
await flushEffects();
expect(xtreamStore.checkPortalStatus).toHaveBeenCalled();
expect(xtreamStore.hasUsableOfflineCache).not.toHaveBeenCalled();
expect(xtreamStore.initializeContent).toHaveBeenCalled();
expect(xtreamStore.hydrateCachedContent).not.toHaveBeenCalled();
});
it('hydrates cached search routes when any Xtream content is persisted', async () => {
router.url = `/workspace/xtreams/${PLAYLIST_ID}/search`;
hasUsableOfflineCache = true;
activePlaylist.set(UPDATED_ACTIVE_PLAYLIST);
xtreamStore.checkPortalStatus.mockImplementation(async () => {
@@ -309,11 +592,16 @@ describe('XtreamWorkspaceRouteSession', () => {
await flushEffects();
expect(xtreamStore.checkPortalStatus).toHaveBeenCalled();
expect(xtreamStore.hasUsableOfflineCache).toHaveBeenCalled();
expect(xtreamStore.hasUsableOfflineCache).toHaveBeenCalledWith(
'search'
);
expect(xtreamStore.setContentInitBlockReason).toHaveBeenCalledWith(
null
);
expect(xtreamStore.initializeContent).toHaveBeenCalled();
expect(xtreamStore.hydrateCachedContent).toHaveBeenCalledWith(
'search'
);
expect(xtreamStore.initializeContent).not.toHaveBeenCalled();
});
it('rebootstraps the current Xtream playlist when its connection details change', async () => {
@@ -7,17 +7,24 @@ import {
Provider,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { NavigationEnd, Router } from '@angular/router';
import { filter } from 'rxjs';
import { NavigationEnd, NavigationStart, Router } from '@angular/router';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import { PortalRailSection } from '@iptvnator/portal/shared/util';
import {
XtreamCachedContentScope,
PortalStatusType,
XtreamPlaylistData,
XtreamStore,
} from '@iptvnator/portal/xtream/data-access';
import { PlaylistMeta } from 'shared-interfaces';
function normalizeOptionalConnectionValue(
value: string | null | undefined
): string | null {
const normalized = value?.trim();
return normalized ? normalized : null;
}
function toXtreamPlaylistData(
playlist: PlaylistMeta | null
): XtreamPlaylistData | null {
@@ -30,6 +37,10 @@ function toXtreamPlaylistData(
return null;
}
const userAgent = normalizeOptionalConnectionValue(playlist.userAgent);
const referrer = normalizeOptionalConnectionValue(playlist.referrer);
const origin = normalizeOptionalConnectionValue(playlist.origin);
return {
id: playlist._id,
name: playlist.title || playlist.filename || 'Untitled playlist',
@@ -39,9 +50,9 @@ function toXtreamPlaylistData(
username: playlist.username,
password: playlist.password,
type: 'xtream',
userAgent: playlist.userAgent,
referrer: playlist.referrer,
origin: playlist.origin,
...(userAgent ? { userAgent } : {}),
...(referrer ? { referrer } : {}),
...(origin ? { origin } : {}),
};
}
@@ -68,6 +79,35 @@ function toContentInitBlockReason(
}
}
function toCachedContentScope(
section: PortalRailSection | null
): XtreamCachedContentScope | null {
switch (section) {
case 'live':
case 'vod':
case 'series':
case 'search':
case 'recently-added':
return section;
default:
return null;
}
}
function getXtreamRouteTarget(url: string): {
playlistId: string | null;
section: PortalRailSection | null;
} {
const match = url.match(
/^\/workspace\/xtreams\/([^/?]+)(?:\/([^/?]+))?/
);
return {
playlistId: match?.[1] ?? null,
section: (match?.[2] as PortalRailSection | undefined) ?? null,
};
}
function hasPlaylistConnectionChanges(
currentPlaylist: XtreamPlaylistData | null,
nextPlaylist: XtreamPlaylistData | null
@@ -76,12 +116,20 @@ function hasPlaylistConnectionChanges(
return false;
}
const currentUserAgent = currentPlaylist.userAgent ?? null;
const nextUserAgent = nextPlaylist.userAgent ?? null;
const currentReferrer = currentPlaylist.referrer ?? null;
const nextReferrer = nextPlaylist.referrer ?? null;
const currentOrigin = currentPlaylist.origin ?? null;
const nextOrigin = nextPlaylist.origin ?? null;
const currentUserAgent = normalizeOptionalConnectionValue(
currentPlaylist.userAgent
);
const nextUserAgent = normalizeOptionalConnectionValue(
nextPlaylist.userAgent
);
const currentReferrer = normalizeOptionalConnectionValue(
currentPlaylist.referrer
);
const nextReferrer = normalizeOptionalConnectionValue(nextPlaylist.referrer);
const currentOrigin = normalizeOptionalConnectionValue(
currentPlaylist.origin
);
const nextOrigin = normalizeOptionalConnectionValue(nextPlaylist.origin);
return (
currentPlaylist.serverUrl !== nextPlaylist.serverUrl ||
@@ -164,20 +212,36 @@ export class XtreamWorkspaceRouteSession {
});
this.router.events
.pipe(
filter(
(event): event is NavigationEnd =>
event instanceof NavigationEnd
),
takeUntilDestroyed(this.destroyRef)
)
.subscribe(() => {
this.scheduleSyncRouteContext();
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((event) => {
if (event instanceof NavigationStart) {
this.prepareTargetPlaylistLoading(event.url);
}
if (event instanceof NavigationEnd) {
this.scheduleSyncRouteContext();
}
});
this.scheduleSyncRouteContext();
}
private prepareTargetPlaylistLoading(url: string): void {
const target = getXtreamRouteTarget(url);
if (
!target.playlistId ||
target.playlistId === this.xtreamStore.playlistId() ||
!isImportDrivenSection(target.section)
) {
return;
}
this.xtreamStore.prepareContentLoading(
toCachedContentScope(target.section)
);
}
private scheduleSyncRouteContext(): void {
if (this.syncInFlight) {
this.syncPending = true;
@@ -218,23 +282,31 @@ export class XtreamWorkspaceRouteSession {
storePlaylistId,
currentPlaylist
);
const cacheScope = toCachedContentScope(routeSection);
let portalStatus = this.xtreamStore.portalStatus();
let didBootstrapPlaylist = false;
let canUseCachedContent = false;
let section = routeSection;
if (playlistId && shouldBootstrapPlaylist) {
this.xtreamStore.resetStore(playlistId);
didBootstrapPlaylist = true;
this.xtreamStore.setCurrentPlaylist(routePlaylist);
section = this.syncRouteState(routeSection);
if (isImportDrivenSection(section)) {
this.xtreamStore.prepareContentLoading(cacheScope);
}
await this.xtreamStore.fetchXtreamPlaylist();
portalStatus = await this.xtreamStore.checkPortalStatus();
const hasUsableOfflineCache =
portalStatus === 'unavailable'
? await this.xtreamStore.hasUsableOfflineCache()
canUseCachedContent =
portalStatus !== 'active' && cacheScope
? this.xtreamStore.isCachedContentScopeReady(cacheScope) ||
(await this.xtreamStore.hasUsableOfflineCache(cacheScope))
: false;
const nextBlockReason =
portalStatus === 'unavailable' && hasUsableOfflineCache
canUseCachedContent
? null
: toContentInitBlockReason(portalStatus);
const currentBlockReason =
@@ -248,24 +320,33 @@ export class XtreamWorkspaceRouteSession {
}
}
const section = this.syncRouteState(routeSection);
if (!didBootstrapPlaylist) {
section = this.syncRouteState(routeSection);
}
const canUseOfflineCache =
portalStatus === 'unavailable'
? await this.xtreamStore.hasUsableOfflineCache()
: false;
if (
portalStatus !== 'active' &&
!canUseCachedContent &&
cacheScope
) {
canUseCachedContent =
this.xtreamStore.isCachedContentScopeReady(cacheScope) ||
(await this.xtreamStore.hasUsableOfflineCache(cacheScope));
}
if (
isImportDrivenSection(section) &&
portalStatus !== 'active' &&
!canUseOfflineCache
!canUseCachedContent
) {
return;
}
await this.initializeCurrentSectionContent(
section,
didBootstrapPlaylist
didBootstrapPlaylist,
canUseCachedContent,
cacheScope
);
}
@@ -289,7 +370,9 @@ export class XtreamWorkspaceRouteSession {
private async initializeCurrentSectionContent(
section: PortalRailSection | null,
didBootstrapPlaylist: boolean
didBootstrapPlaylist: boolean,
canUseCachedContent: boolean,
cacheScope: XtreamCachedContentScope | null
): Promise<void> {
const playlist = this.xtreamStore.currentPlaylist();
const playlistId = this.xtreamStore.playlistId();
@@ -298,9 +381,26 @@ export class XtreamWorkspaceRouteSession {
return;
}
if (isImportDrivenSection(section) && canUseCachedContent) {
if (
cacheScope &&
!this.xtreamStore.isCachedContentScopeReady(cacheScope)
) {
await this.xtreamStore.hydrateCachedContent(cacheScope);
}
return;
}
const sectionLoadState =
section === 'live' || section === 'vod' || section === 'series'
? this.xtreamStore.contentLoadStateByType()[section]
: null;
if (
isImportDrivenSection(section) &&
(didBootstrapPlaylist || !this.xtreamStore.isContentInitialized())
(didBootstrapPlaylist ||
!this.xtreamStore.isContentInitialized() ||
sectionLoadState !== 'ready')
) {
await this.xtreamStore.initializeContent();
}
@@ -31,6 +31,7 @@ describe('WorkspaceContextPanelComponent', () => {
const xtreamSelectedTypeContentState =
signal<XtreamContentLoadState>('loading');
const xtreamImportPhase = signal<string | null>('loading-live');
const xtreamIsImporting = signal(true);
const xtreamIsLoadingCategories = signal(false);
const xtreamStore = {
@@ -44,6 +45,7 @@ describe('WorkspaceContextPanelComponent', () => {
selectedTypeCountsReady: computed(
() => xtreamSelectedTypeContentState() === 'ready'
),
isImporting: xtreamIsImporting,
currentImportPhase: xtreamImportPhase,
isLoadingCategories: xtreamIsLoadingCategories,
setSelectedItem: jest.fn(),
@@ -75,6 +77,7 @@ describe('WorkspaceContextPanelComponent', () => {
xtreamSelectedCategoryId.set(null);
xtreamSelectedTypeContentState.set('loading');
xtreamImportPhase.set('loading-live');
xtreamIsImporting.set(true);
xtreamIsLoadingCategories.set(false);
xtreamStore.setSelectedItem.mockClear();
xtreamStore.setSelectedCategory.mockClear();
@@ -137,9 +140,9 @@ describe('WorkspaceContextPanelComponent', () => {
const status = fixture.nativeElement.querySelector(
'.context-inline-status'
) as HTMLElement | null;
const manageButton = fixture.nativeElement.querySelector(
'.context-header__action'
) as HTMLButtonElement | null;
const manageButton = Array.from(
fixture.nativeElement.querySelectorAll('.context-header__action')
).at(-1) as HTMLButtonElement | undefined;
expect(countPlaceholders).toHaveLength(2);
expect(categoryButtons.every((button) => button.disabled)).toBe(true);
@@ -156,6 +159,26 @@ describe('WorkspaceContextPanelComponent', () => {
expect(router.navigate).not.toHaveBeenCalled();
});
it('keeps local xtream loading states quiet when no import is running', () => {
fixture.componentRef.setInput('section', 'vod');
xtreamIsImporting.set(false);
fixture.detectChanges();
const countPlaceholders = fixture.nativeElement.querySelectorAll(
'.item-count--loading'
);
const categoryButtons = Array.from(
fixture.nativeElement.querySelectorAll('.category-item')
) as HTMLButtonElement[];
const status = fixture.nativeElement.querySelector(
'.context-inline-status'
) as HTMLElement | null;
expect(countPlaceholders).toHaveLength(2);
expect(categoryButtons.every((button) => button.disabled)).toBe(true);
expect(status).toBeNull();
});
it('shows real counts and enables navigation once the selected xtream type is ready', () => {
fixture.componentRef.setInput('section', 'vod');
xtreamSelectedTypeContentState.set('ready');
@@ -174,9 +197,9 @@ describe('WorkspaceContextPanelComponent', () => {
const categoryButtons = Array.from(
fixture.nativeElement.querySelectorAll('.category-item')
) as HTMLButtonElement[];
const manageButton = fixture.nativeElement.querySelector(
'.context-header__action'
) as HTMLButtonElement | null;
const manageButton = Array.from(
fixture.nativeElement.querySelectorAll('.context-header__action')
).at(-1) as HTMLButtonElement | undefined;
expect(countTexts).toEqual(['3', '0']);
expect(categoryButtons.every((button) => !button.disabled)).toBe(true);
@@ -81,6 +81,7 @@ export class WorkspaceContextPanelComponent {
this.xtreamStore.selectedTypeContentReady;
readonly xtreamSelectedTypeCountsReady =
this.xtreamStore.selectedTypeCountsReady;
readonly isXtreamImporting = this.xtreamStore.isImporting;
readonly xtreamImportPhase = this.xtreamStore.currentImportPhase;
readonly isXtreamCategoryLoading = computed(
() =>
@@ -106,6 +107,7 @@ export class WorkspaceContextPanelComponent {
if (
!this.isXtreamCategories() ||
this.isXtreamCategoryLoading() ||
!this.isXtreamImporting() ||
this.xtreamSelectedTypeContentState() !== 'loading'
) {
return '';