Files
iptvnator/apps/web/src/app/global-collection-route.component.ts
T
4grayandClaude Opus 5 4e575a810e feat(dashboard): say where you left off instead of which provider it came from (#1646)
Every dashboard title carried a "Xtream · Series" / "Stalker · Movie" subtitle. Provider kind and content kind are the app's own taxonomy, not a property of the title, and they are identical on every card in a rail — so the one line that could tell two cards apart said nothing.

The hero now shows the source name alone, through `playlistDisplayLabel` (a stored playlist name is routinely the pasted URL with credentials, or a MAC). Continue Watching cards show what actually varies: the "S1·E5" chip plus "12 min left". Favorites keep the title alone, Recently Added keeps the source name, and a meta row with nothing in it is no longer rendered.

Stalker shows filed under Movies had no badge, no progress and no resume. An embedded-VOD row announces its episodes through a `series[]` array and carries no `is_series` flag, so `extractStalkerItemType` reports `movie` on purpose — the item must keep routing to the VOD catalog — while its progress is a set of episode positions keyed by the parent id, which the dashboard was looking up as a single `vod` row and never finding.

Split the two questions: `PortalActivityItem.watch_kind` records the progress model when it differs from the routing type, and every reader that has to choose goes through `resolvePortalActivityWatchKind` instead of `type`. That makes the resume handoff reachable for Stalker, so wire it through `STALKER_SERIES_RESUME_TARGET`: consumed once after the series positions are read, hydrating a lazy Ministra season first with a bounded two-attempt retry, and playing nothing at all when the position read failed rather than restarting the episode from zero.

Also fixes the global-recent route template, which bound `[seriesResume]` only on its Xtream branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 22:19:47 +02:00

247 lines
8.0 KiB
TypeScript

import {
ChangeDetectionStrategy,
Component,
EnvironmentInjector,
OnDestroy,
Type,
ViewContainerRef,
effect,
computed,
inject,
input,
output,
untracked,
} from '@angular/core';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { selectAllPlaylistsMeta } from '@iptvnator/m3u-state';
import { EmptyStateComponent } from '@iptvnator/playlist/shared/ui';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import {
UnifiedCollectionDetailDirective,
UnifiedCollectionPageComponent,
} from '@iptvnator/portal/shared/ui';
import {
CollectionScope,
PortalProvider,
SeriesResumeTarget,
UnifiedCollectionItem,
} from '@iptvnator/portal/shared/util';
import { WORKSPACE_SHELL_ACTIONS } from '@iptvnator/workspace/shell/util';
type UnifiedPortalType = 'm3u' | 'xtream' | 'stalker';
type CollectionMode = 'favorites' | 'recent';
interface ComponentOutputRef {
subscribe(callback: () => void): { unsubscribe(): void };
}
interface CollectionDetailInstance {
closeRequested?: ComponentOutputRef;
}
const EMPTY_STATE_BY_MODE: Record<
CollectionMode,
{ icon: string; titleKey: string; descriptionKey: string }
> = {
favorites: {
icon: 'favorite_border',
titleKey: 'WORKSPACE.GLOBAL_FAVORITES.NO_PLAYLISTS_TITLE',
descriptionKey: 'WORKSPACE.GLOBAL_FAVORITES.NO_PLAYLISTS_BODY',
},
recent: {
icon: 'history_toggle_off',
titleKey: 'WORKSPACE.GLOBAL_RECENT.NO_PLAYLISTS_TITLE',
descriptionKey: 'WORKSPACE.GLOBAL_RECENT.NO_PLAYLISTS_BODY',
},
};
function providerToPortalType(provider: PortalProvider): UnifiedPortalType {
if (provider === 'playlists') return 'm3u';
if (provider === 'xtreams') return 'xtream';
return 'stalker';
}
@Component({
selector: 'app-global-collection-detail-host',
template: '',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class GlobalCollectionDetailHostComponent implements OnDestroy {
readonly item = input<UnifiedCollectionItem | null>(null);
readonly seriesResume = input<SeriesResumeTarget | null>(null);
readonly closeRequested = output<void>();
private readonly viewContainer = inject(ViewContainerRef);
private readonly environmentInjector = inject(EnvironmentInjector);
private closeSubscription: { unsubscribe(): void } | null = null;
private renderRequestId = 0;
constructor() {
effect(() => {
const item = this.item();
const seriesResume = this.seriesResume();
untracked(() => {
void this.renderDetail(item, seriesResume);
});
});
}
ngOnDestroy(): void {
this.renderRequestId++;
this.clearDetail();
}
private async renderDetail(
item: UnifiedCollectionItem | null,
seriesResume: SeriesResumeTarget | null
): Promise<void> {
const requestId = ++this.renderRequestId;
this.clearDetail();
if (!item) {
return;
}
const componentType = await this.loadDetailComponent(item.sourceType);
if (requestId !== this.renderRequestId || !componentType) {
return;
}
const componentRef = this.viewContainer.createComponent(componentType, {
environmentInjector: this.environmentInjector,
});
componentRef.setInput('item', item);
// Both portal details accept the one-shot resume handoff (Xtream
// series, Stalker embedded-VOD / lazy is_series shows).
componentRef.setInput('seriesResume', seriesResume);
this.subscribeToClose(componentRef.instance);
}
private async loadDetailComponent(
sourceType: UnifiedCollectionItem['sourceType']
): Promise<Type<unknown> | null> {
if (sourceType === 'xtream') {
const component = await import('@iptvnator/portal/xtream/feature');
return component.XtreamCollectionDetailComponent;
}
if (sourceType === 'stalker') {
const component = await import('@iptvnator/portal/stalker/feature');
return component.StalkerCollectionDetailComponent;
}
return null;
}
private subscribeToClose(instance: unknown): void {
const closeRequested = (instance as CollectionDetailInstance)
.closeRequested;
if (!closeRequested) {
return;
}
this.closeSubscription = closeRequested.subscribe(() => {
this.closeRequested.emit();
});
}
private clearDetail(): void {
this.closeSubscription?.unsubscribe();
this.closeSubscription = null;
this.viewContainer.clear();
}
}
@Component({
selector: 'app-global-collection-route',
imports: [
EmptyStateComponent,
GlobalCollectionDetailHostComponent,
UnifiedCollectionDetailDirective,
UnifiedCollectionPageComponent,
],
template: `
@if (hasNoPlaylists()) {
<app-empty-state
type="no-data"
[icon]="emptyState().icon"
[titleKey]="emptyState().titleKey"
[descriptionKey]="emptyState().descriptionKey"
primaryActionLabelKey="HOME.PLAYLISTS.ADD_YOUR_FIRST"
secondaryActionLabelKey="WORKSPACE.SHELL.GO_TO_DASHBOARD"
(primaryActionClicked)="addPlaylist()"
(secondaryActionClicked)="goToDashboard()"
/>
} @else {
<app-unified-collection-page
[mode]="mode()"
[playlistId]="activePlaylistId() ?? undefined"
[portalType]="activePortalType() ?? undefined"
[defaultScope]="effectiveDefaultScope()"
>
<ng-template
unifiedCollectionDetail
let-item
let-seriesResume="seriesResume"
let-close="close"
>
@if (item.sourceType === 'xtream') {
<app-global-collection-detail-host
[item]="item"
[seriesResume]="seriesResume"
/>
} @else if (item.sourceType === 'stalker') {
<app-global-collection-detail-host
[item]="item"
[seriesResume]="seriesResume"
(closeRequested)="close()"
/>
}
</ng-template>
</app-unified-collection-page>
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class GlobalCollectionRouteComponent {
private readonly playlistContext = inject(PlaylistContextFacade);
private readonly store = inject(Store);
private readonly shellActions = inject(WORKSPACE_SHELL_ACTIONS);
private readonly router = inject(Router);
readonly mode = input<CollectionMode>('favorites');
readonly defaultScope = input<CollectionScope | undefined>(undefined);
private readonly playlists = this.store.selectSignal(
selectAllPlaylistsMeta
);
readonly hasNoPlaylists = computed(() => this.playlists().length === 0);
readonly emptyState = computed(() => EMPTY_STATE_BY_MODE[this.mode()]);
readonly activePlaylistId = computed(
() => this.playlistContext.activePlaylist()?._id ?? null
);
readonly activePortalType = computed<UnifiedPortalType | null>(() => {
const provider = this.playlistContext.activeProvider();
return provider ? providerToPortalType(provider) : null;
});
readonly effectiveDefaultScope = computed<CollectionScope | undefined>(
() =>
this.activePlaylistId()
? 'playlist'
: (this.defaultScope() ?? 'all')
);
addPlaylist(): void {
this.shellActions.openAddPlaylistDialog();
}
goToDashboard(): void {
void this.router.navigate(['/workspace', 'dashboard']);
}
}