mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 13:46:16 -08:00
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>
247 lines
8.0 KiB
TypeScript
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']);
|
|
}
|
|
}
|