mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 05:36:17 -08:00
Merge branch 'master' into perf/j3-epg-ribbon-window
This commit is contained in:
171 files changed
+10425
-4483
No files matched your search
@@ -29,8 +29,7 @@ export interface ConfirmDialogData {
|
||||
|
||||
@Component({
|
||||
imports: [MatButtonModule, MatDialogModule, TranslateModule],
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade.
|
||||
changeDetection: ChangeDetectionStrategy.Eager,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<h2 mat-dialog-title>
|
||||
{{ dialogData.title }}
|
||||
|
||||
@@ -34,8 +34,7 @@ export type ContentHeroLayout = 'stage' | 'compact';
|
||||
TranslateModule,
|
||||
],
|
||||
templateUrl: './content-hero.component.html',
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade.
|
||||
changeDetection: ChangeDetectionStrategy.Eager,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styleUrls: ['./content-hero.component.scss'],
|
||||
})
|
||||
export class ContentHeroComponent {
|
||||
|
||||
@@ -47,8 +47,7 @@ import { TranslateModule } from '@ngx-translate/core';
|
||||
</button>
|
||||
}
|
||||
`,
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade.
|
||||
changeDetection: ChangeDetectionStrategy.Eager,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
|
||||
@@ -56,8 +56,7 @@ import { TranslateModule } from '@ngx-translate/core';
|
||||
</div>
|
||||
</section>
|
||||
`,
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade.
|
||||
changeDetection: ChangeDetectionStrategy.Eager,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styleUrls: ['./content-about.component.scss'],
|
||||
})
|
||||
export class ContentAboutComponent {
|
||||
|
||||
@@ -12,8 +12,7 @@ import {
|
||||
output,
|
||||
ChangeDetectionStrategy,
|
||||
} from '@angular/core';
|
||||
import { WorkspaceBackNavigationService } from '@iptvnator/portal/shared/data-access';
|
||||
import { WorkspaceBackTarget } from '@iptvnator/portal/shared/util';
|
||||
import { registerWorkspaceBack } from '@iptvnator/portal/shared/data-access';
|
||||
import { ContentHeroComponent } from '../content-hero/content-hero.component';
|
||||
import { ContentAboutComponent } from './content-about.component';
|
||||
import {
|
||||
@@ -44,8 +43,7 @@ import {
|
||||
imports: [ContentHeroComponent, ContentAboutComponent, NgTemplateOutlet],
|
||||
templateUrl: './portal-detail-shell.component.html',
|
||||
styleUrls: ['./portal-detail-shell.component.scss'],
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade.
|
||||
changeDetection: ChangeDetectionStrategy.Eager,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: {
|
||||
tabindex: '0',
|
||||
role: 'region',
|
||||
@@ -61,7 +59,6 @@ import {
|
||||
export class PortalDetailShellComponent {
|
||||
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
private readonly injector = inject(Injector);
|
||||
private readonly backNavigation = inject(WorkspaceBackNavigationService);
|
||||
|
||||
readonly title = input<string>();
|
||||
/** "Movie · playlist name" eyebrow above the hero title. */
|
||||
@@ -96,13 +93,13 @@ export class PortalDetailShellComponent {
|
||||
|
||||
readonly isWatch = computed(() => this.playbackActive());
|
||||
|
||||
private readonly backTarget: WorkspaceBackTarget = {
|
||||
label: computed(() => this.backLabel() || null),
|
||||
escapeShortcut: computed(() => !this.isWatch()),
|
||||
run: () => this.backClicked.emit(),
|
||||
};
|
||||
|
||||
constructor() {
|
||||
registerWorkspaceBack({
|
||||
available: this.backAvailable,
|
||||
label: computed(() => this.backLabel() || null),
|
||||
escapeShortcut: computed(() => !this.isWatch()),
|
||||
run: () => this.backClicked.emit(),
|
||||
});
|
||||
afterNextRender(() => {
|
||||
const element = this.host.nativeElement;
|
||||
const active = element.ownerDocument.activeElement;
|
||||
@@ -115,10 +112,6 @@ export class PortalDetailShellComponent {
|
||||
element.focus({ preventScroll: true });
|
||||
}
|
||||
});
|
||||
effect((onCleanup) => {
|
||||
if (!this.backAvailable()) return;
|
||||
onCleanup(this.backNavigation.register(this.backTarget));
|
||||
});
|
||||
let wasWatch = false;
|
||||
effect(() => {
|
||||
const watch = this.isWatch();
|
||||
|
||||
@@ -19,8 +19,7 @@ import {
|
||||
></div>
|
||||
</div>
|
||||
`,
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade.
|
||||
changeDetection: ChangeDetectionStrategy.Eager,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styles: [
|
||||
`
|
||||
.progress-capsule {
|
||||
|
||||
@@ -105,8 +105,7 @@ export const EPISODE_INFO_PLAY = 'play' as const;
|
||||
</button>
|
||||
</div>
|
||||
`,
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade.
|
||||
changeDetection: ChangeDetectionStrategy.Eager,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { TranslateService } from '@ngx-translate/core';
|
||||
import {
|
||||
formatDurationLabel,
|
||||
formatRemainingLabel,
|
||||
isPortalPlaybackWatched,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
PlaybackPositionData,
|
||||
XtreamSerieEpisodeInfo,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
episodeRuntimeSeconds,
|
||||
formatEpisodePositionText,
|
||||
} from './episode-progress.util';
|
||||
|
||||
/**
|
||||
* The line under an episode card's title, built from the episode's runtime
|
||||
* and its saved playback position:
|
||||
* "42 min · 18m left", "42 min · watched", "42 min" — or null.
|
||||
*/
|
||||
export function buildEpisodeSubline(
|
||||
info: XtreamSerieEpisodeInfo | undefined,
|
||||
position: PlaybackPositionData | undefined,
|
||||
translate: Pick<TranslateService, 'instant'>
|
||||
): string | null {
|
||||
const duration = formatDurationLabel(episodeRuntimeSeconds(info));
|
||||
const remaining = formatRemainingLabel(position);
|
||||
const parts = [
|
||||
duration ? translate.instant(duration.key, duration.params) : null,
|
||||
isPortalPlaybackWatched(position)
|
||||
? translate.instant('PORTALS.DETAIL.WATCHED')
|
||||
: remaining
|
||||
? translate.instant(remaining.key, remaining.params)
|
||||
: formatEpisodePositionText(position),
|
||||
].filter((part): part is string => !!part);
|
||||
return parts.length ? parts.join(' · ') : null;
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import {
|
||||
type Signal,
|
||||
type WritableSignal,
|
||||
computed,
|
||||
effect,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
PlaybackPositionData,
|
||||
XtreamSerieEpisode,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { resolveAutoSelectedSeason } from './season-auto-select.util';
|
||||
|
||||
export interface SeasonAutoSelectSources {
|
||||
/**
|
||||
* The container's selection (see `SeasonContainerComponent.selectedSeason`).
|
||||
* Auto-select writes to it; user tab clicks write to it too and stick
|
||||
* until the auto-select key changes.
|
||||
*/
|
||||
readonly selectedSeason: WritableSignal<string | undefined>;
|
||||
readonly seasons: Signal<Record<string, XtreamSerieEpisode[]>>;
|
||||
/** Season keys in display order. */
|
||||
readonly sortedSeasonKeys: Signal<readonly string[]>;
|
||||
/** Season key of the inline-playing episode, if it is in the loaded set. */
|
||||
readonly playingSeasonKey: Signal<string | null>;
|
||||
readonly playbackPositions: Signal<
|
||||
ReadonlyMap<number, PlaybackPositionData>
|
||||
>;
|
||||
readonly positionOf: (
|
||||
episode: XtreamSerieEpisode
|
||||
) => PlaybackPositionData | undefined;
|
||||
/** Stalker lazy-VOD: some seasons' episode lists are not loaded yet. */
|
||||
readonly hasUnloadedSeasons: Signal<boolean>;
|
||||
readonly episodeCounts: Signal<Record<string, number>>;
|
||||
readonly watchedCounts: Signal<Record<string, number>>;
|
||||
readonly emitSeasonSelected: (seasonKey: string) => void;
|
||||
}
|
||||
|
||||
export interface SeasonAutoSelectState {
|
||||
/**
|
||||
* Record that this session toggled watched state itself: the positions
|
||||
* flip that follows is then not treated as an initial load.
|
||||
*/
|
||||
markLocalWatchedMutation(): void;
|
||||
}
|
||||
|
||||
/** Key of the loaded season that holds the episode with this content id. */
|
||||
export function findSeasonOfEpisode(
|
||||
seasons: Record<string, XtreamSerieEpisode[]>,
|
||||
episodeId: number
|
||||
): string | null {
|
||||
for (const [key, episodes] of Object.entries(seasons)) {
|
||||
if (episodes?.some((episode) => Number(episode.id) === episodeId)) {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Season auto-selection and `seasonSelected` emission of
|
||||
* `SeasonContainerComponent`. The selection re-resolves when the season key
|
||||
* set changes or when playback positions first arrive; ongoing position
|
||||
* saves do not reset it — only the empty→loaded transition of the positions
|
||||
* map does, and even that is ignored once this session toggled watched state
|
||||
* itself.
|
||||
*
|
||||
* Registers two effects, so it must be called in an injection context (the
|
||||
* container's constructor).
|
||||
*/
|
||||
export function createSeasonAutoSelectState(
|
||||
sources: SeasonAutoSelectSources
|
||||
): SeasonAutoSelectState {
|
||||
const autoSelectKey = computed(
|
||||
() =>
|
||||
`${sources.sortedSeasonKeys().join('|')}::${
|
||||
sources.playbackPositions().size > 0 ? '1' : '0'
|
||||
}`
|
||||
);
|
||||
let lastAutoSelectKey: string | null = null;
|
||||
let lastAutoSelectSeasonSet: string | null = null;
|
||||
/**
|
||||
* True once this session toggled watched state itself. From then on an
|
||||
* empty↔loaded flip of the positions map is the echo of that action, not
|
||||
* an async initial load — re-resolving on it would yank the user off the
|
||||
* season they just marked (e.g. all-watched season 1 → jump to season 2).
|
||||
*/
|
||||
let hasLocalWatchedMutation = false;
|
||||
let lastEmittedSeason: string | undefined;
|
||||
|
||||
/** Auto-select rules live in season-auto-select.util.ts. */
|
||||
const resolveAutoSeason = (): string | undefined =>
|
||||
resolveAutoSelectedSeason({
|
||||
keys: sources.sortedSeasonKeys(),
|
||||
playingSeasonKey: sources.playingSeasonKey(),
|
||||
seasons: sources.seasons(),
|
||||
positionOf: sources.positionOf,
|
||||
hasUnloadedSeasons: sources.hasUnloadedSeasons(),
|
||||
episodeCounts: sources.episodeCounts(),
|
||||
watchedCounts: sources.watchedCounts(),
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const key = autoSelectKey();
|
||||
if (key === lastAutoSelectKey) {
|
||||
return;
|
||||
}
|
||||
const seasonSet = untracked(() => sources.sortedSeasonKeys().join('|'));
|
||||
const seasonSetUnchanged = seasonSet === lastAutoSelectSeasonSet;
|
||||
lastAutoSelectKey = key;
|
||||
lastAutoSelectSeasonSet = seasonSet;
|
||||
// A positions-emptiness flip after a local watched toggle keeps
|
||||
// the current selection; only the async initial positions load
|
||||
// (or a season-set change) re-resolves the season.
|
||||
if (seasonSetUnchanged && hasLocalWatchedMutation) {
|
||||
return;
|
||||
}
|
||||
sources.selectedSeason.set(untracked(() => resolveAutoSeason()));
|
||||
});
|
||||
|
||||
// Fire the lazy-load/enrichment hooks for auto-selected seasons too —
|
||||
// with tabs there is no initial "pick a season" click anymore.
|
||||
effect(() => {
|
||||
const selected = sources.selectedSeason();
|
||||
if (selected && selected !== lastEmittedSeason) {
|
||||
lastEmittedSeason = selected;
|
||||
sources.emitSeasonSelected(selected);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
markLocalWatchedMutation: () => {
|
||||
hasLocalWatchedMutation = true;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -4,12 +4,10 @@ import {
|
||||
ElementRef,
|
||||
OnInit,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatButtonToggleModule } from '@angular/material/button-toggle';
|
||||
@@ -21,8 +19,6 @@ import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import { type SeasonEpisodeDownloadAdapter } from '@iptvnator/portal/shared/data-access';
|
||||
import {
|
||||
createLogger,
|
||||
formatDurationLabel,
|
||||
formatRemainingLabel,
|
||||
getPortalPlaybackProgressPercent,
|
||||
isPortalPlaybackInProgress,
|
||||
isPortalPlaybackWatched,
|
||||
@@ -38,11 +34,13 @@ import {
|
||||
EpisodeInfoDialogComponent,
|
||||
buildEpisodeInfoDialogData,
|
||||
} from './episode-info-dialog.component';
|
||||
import { formatEpisodePositionText } from './episode-progress.util';
|
||||
import { buildEpisodeSubline } from './episode-subline.util';
|
||||
import {
|
||||
formatEpisodePositionText,
|
||||
episodeRuntimeSeconds,
|
||||
} from './episode-progress.util';
|
||||
import { resolveAutoSelectedSeason } from './season-auto-select.util';
|
||||
type SeasonAutoSelectState,
|
||||
createSeasonAutoSelectState,
|
||||
findSeasonOfEpisode,
|
||||
} from './season-auto-select.state';
|
||||
import { SeasonDownloadPresenter } from './season-download-presenter';
|
||||
import { SeasonTabsComponent } from './season-tabs.component';
|
||||
import { SeasonWatchPresenter } from './season-watch-presenter';
|
||||
@@ -80,7 +78,8 @@ export class SeasonContainerComponent implements OnInit {
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
private readonly logger = createLogger('SeasonContainer');
|
||||
private lastEmittedSeason: string | undefined;
|
||||
/** Auto-select + `seasonSelected` emission; wired in the constructor. */
|
||||
private readonly autoSelect: SeasonAutoSelectState;
|
||||
readonly downloadPresenter = inject(SeasonDownloadPresenter);
|
||||
readonly watchPresenter = inject(SeasonWatchPresenter);
|
||||
|
||||
@@ -148,9 +147,12 @@ export class SeasonContainerComponent implements OnInit {
|
||||
});
|
||||
|
||||
/** Season key of the inline-playing episode, if it is in the loaded set. */
|
||||
readonly playingSeasonKey = computed(() =>
|
||||
this.findSeasonOfEpisode(this.playingEpisodeId())
|
||||
);
|
||||
readonly playingSeasonKey = computed(() => {
|
||||
const episodeId = this.playingEpisodeId();
|
||||
return episodeId === null
|
||||
? null
|
||||
: findSeasonOfEpisode(this.seasons(), episodeId);
|
||||
});
|
||||
|
||||
/**
|
||||
* Selected season. Auto-resolves when the season key set changes or when
|
||||
@@ -171,22 +173,6 @@ export class SeasonContainerComponent implements OnInit {
|
||||
return selected ? (this.seasons()[selected] ?? []) : [];
|
||||
});
|
||||
|
||||
private readonly autoSelectKey = computed(
|
||||
() =>
|
||||
`${this.sortedSeasonKeys().join('|')}::${
|
||||
this.playbackPositions().size > 0 ? '1' : '0'
|
||||
}`
|
||||
);
|
||||
private lastAutoSelectKey: string | null = null;
|
||||
private lastAutoSelectSeasonSet: string | null = null;
|
||||
/**
|
||||
* True once this session toggled watched state itself. From then on an
|
||||
* empty↔loaded flip of the positions map is the echo of that action, not
|
||||
* an async initial load — re-resolving on it would yank the user off the
|
||||
* season they just marked (e.g. all-watched season 1 → jump to season 2).
|
||||
*/
|
||||
private hasLocalWatchedMutation = false;
|
||||
|
||||
/**
|
||||
* Show thumbnails in the list view only when episodes have genuinely
|
||||
* distinct stills (TMDB or per-episode provider art). When every episode
|
||||
@@ -254,44 +240,29 @@ export class SeasonContainerComponent implements OnInit {
|
||||
openingEpisodeId: this.openingEpisodeId,
|
||||
isEpisodeWatched: (episode) => this.isEpisodeWatched(episode),
|
||||
emitSeasonToggle: (request) => {
|
||||
this.hasLocalWatchedMutation = true;
|
||||
this.autoSelect.markLocalWatchedMutation();
|
||||
this.seasonPlaybackToggleRequested.emit(request);
|
||||
},
|
||||
emitSeriesToggle: (request) => {
|
||||
this.hasLocalWatchedMutation = true;
|
||||
this.autoSelect.markLocalWatchedMutation();
|
||||
this.seriesPlaybackToggleRequested.emit(request);
|
||||
},
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const key = this.autoSelectKey();
|
||||
if (key === this.lastAutoSelectKey) {
|
||||
return;
|
||||
}
|
||||
const seasonSet = untracked(() =>
|
||||
this.sortedSeasonKeys().join('|')
|
||||
);
|
||||
const seasonSetUnchanged =
|
||||
seasonSet === this.lastAutoSelectSeasonSet;
|
||||
this.lastAutoSelectKey = key;
|
||||
this.lastAutoSelectSeasonSet = seasonSet;
|
||||
// A positions-emptiness flip after a local watched toggle keeps
|
||||
// the current selection; only the async initial positions load
|
||||
// (or a season-set change) re-resolves the season.
|
||||
if (seasonSetUnchanged && this.hasLocalWatchedMutation) {
|
||||
return;
|
||||
}
|
||||
this.selectedSeason.set(untracked(() => this.resolveAutoSeason()));
|
||||
});
|
||||
|
||||
// Fire the lazy-load/enrichment hooks for auto-selected seasons too —
|
||||
// with tabs there is no initial "pick a season" click anymore.
|
||||
effect(() => {
|
||||
const selected = this.selectedSeason();
|
||||
if (selected && selected !== this.lastEmittedSeason) {
|
||||
this.lastEmittedSeason = selected;
|
||||
this.seasonSelected.emit(selected);
|
||||
}
|
||||
// Auto-select rules and the effects driving them live in
|
||||
// season-auto-select.state.ts / season-auto-select.util.ts.
|
||||
this.autoSelect = createSeasonAutoSelectState({
|
||||
selectedSeason: this.selectedSeason,
|
||||
seasons: this.seasons,
|
||||
sortedSeasonKeys: this.sortedSeasonKeys,
|
||||
playingSeasonKey: this.playingSeasonKey,
|
||||
playbackPositions: this.playbackPositions,
|
||||
positionOf: (episode) => this.getEpisodePosition(episode),
|
||||
hasUnloadedSeasons: this.hasUnloadedSeasons,
|
||||
episodeCounts: this.episodeCounts,
|
||||
watchedCounts: this.watchedCounts,
|
||||
emitSeasonSelected: (seasonKey) =>
|
||||
this.seasonSelected.emit(seasonKey),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -376,7 +347,7 @@ export class SeasonContainerComponent implements OnInit {
|
||||
this.logger.warn('Cannot toggle watched: no playlist ID');
|
||||
return;
|
||||
}
|
||||
this.hasLocalWatchedMutation = true;
|
||||
this.autoSelect.markLocalWatchedMutation();
|
||||
|
||||
const contentXtreamId = this.getEpisodeContentId(episode);
|
||||
const currentPosition = this.getEpisodePosition(episode);
|
||||
@@ -434,21 +405,11 @@ export class SeasonContainerComponent implements OnInit {
|
||||
|
||||
/** "42 min · 18m left", "42 min · watched", "42 min" — or null. */
|
||||
getEpisodeSubline(episode: XtreamSerieEpisode): string | null {
|
||||
const info = this.getEpisodeInfo(episode);
|
||||
const duration = formatDurationLabel(episodeRuntimeSeconds(info));
|
||||
const position = this.playbackPositions().get(Number(episode.id));
|
||||
const remaining = formatRemainingLabel(position);
|
||||
const parts = [
|
||||
duration
|
||||
? this.translate.instant(duration.key, duration.params)
|
||||
: null,
|
||||
this.isEpisodeWatched(episode)
|
||||
? this.translate.instant('PORTALS.DETAIL.WATCHED')
|
||||
: remaining
|
||||
? this.translate.instant(remaining.key, remaining.params)
|
||||
: this.getEpisodePositionText(episode),
|
||||
].filter((part): part is string => !!part);
|
||||
return parts.length ? parts.join(' · ') : null;
|
||||
return buildEpisodeSubline(
|
||||
this.getEpisodeInfo(episode),
|
||||
this.getEpisodePosition(episode),
|
||||
this.translate
|
||||
);
|
||||
}
|
||||
|
||||
getEpisodePositionText(episode: XtreamSerieEpisode): string | null {
|
||||
@@ -464,33 +425,4 @@ export class SeasonContainerComponent implements OnInit {
|
||||
): PlaybackPositionData | undefined {
|
||||
return this.playbackPositions().get(this.getEpisodeContentId(episode));
|
||||
}
|
||||
|
||||
private findSeasonOfEpisode(episodeId: number | null): string | null {
|
||||
if (episodeId === null) {
|
||||
return null;
|
||||
}
|
||||
for (const [key, episodes] of Object.entries(this.seasons())) {
|
||||
if (
|
||||
episodes?.some(
|
||||
(episode) => this.getEpisodeContentId(episode) === episodeId
|
||||
)
|
||||
) {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Auto-select rules live in season-auto-select.util.ts. */
|
||||
private resolveAutoSeason(): string | undefined {
|
||||
return resolveAutoSelectedSeason({
|
||||
keys: this.sortedSeasonKeys(),
|
||||
playingSeasonKey: this.playingSeasonKey(),
|
||||
seasons: this.seasons(),
|
||||
positionOf: (episode) => this.getEpisodePosition(episode),
|
||||
hasUnloadedSeasons: this.hasUnloadedSeasons(),
|
||||
episodeCounts: this.episodeCounts(),
|
||||
watchedCounts: this.watchedCounts(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,7 @@ import { MatIcon } from '@angular/material/icon';
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade.
|
||||
changeDetection: ChangeDetectionStrategy.Eager,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styles: [
|
||||
`
|
||||
.watched-badge {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { ChangeDetectorRef, signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
|
||||
import { By } from '@angular/platform-browser';
|
||||
@@ -72,8 +72,12 @@ describe('EpgItemDescriptionComponent', () => {
|
||||
});
|
||||
|
||||
it('offers archive URL copying only with explicit capability and explains credentials', () => {
|
||||
// The component is OnPush and dialog data is fixed once the dialog
|
||||
// opens, so the test marks the component's own view after mutating it.
|
||||
const componentView =
|
||||
fixture.debugElement.injector.get(ChangeDetectorRef);
|
||||
Object.assign(component.dialogData, { archiveUrlAvailable: true });
|
||||
fixture.changeDetectorRef.markForCheck();
|
||||
componentView.markForCheck();
|
||||
fixture.detectChanges();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector(
|
||||
@@ -84,7 +88,7 @@ describe('EpgItemDescriptionComponent', () => {
|
||||
'EPG.PROGRAM_DIALOG.ARCHIVE_URL_NOTICE'
|
||||
);
|
||||
Object.assign(component.dialogData, { archiveUrlAvailable: false });
|
||||
fixture.changeDetectorRef.markForCheck();
|
||||
componentView.markForCheck();
|
||||
fixture.detectChanges();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector(
|
||||
|
||||
@@ -38,7 +38,7 @@ export type EpgItemDialogData = EpgProgram & {
|
||||
selector: 'app-epg-item-description',
|
||||
templateUrl: './epg-item-description.component.html',
|
||||
styleUrls: ['./epg-item-description.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.Eager,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [DatePipe, MatDialogModule, MatIcon, TranslatePipe],
|
||||
})
|
||||
export class EpgItemDescriptionComponent {
|
||||
|
||||
@@ -32,7 +32,7 @@ interface EpgTrustConfirmDialogData {
|
||||
@Component({
|
||||
selector: 'app-epg-trust-confirm-dialog',
|
||||
imports: [MatButtonModule, MatDialogModule, TranslatePipe],
|
||||
changeDetection: ChangeDetectionStrategy.Eager,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<h2 mat-dialog-title>{{ data.title }}</h2>
|
||||
<mat-dialog-content class="mat-typography">
|
||||
@@ -64,7 +64,7 @@ class EpgTrustConfirmDialogComponent {
|
||||
TranslatePipe,
|
||||
],
|
||||
templateUrl: './epg-progress-panel.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.Eager,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styleUrl: './epg-progress-panel.component.scss',
|
||||
})
|
||||
export class EpgProgressPanelComponent {
|
||||
|
||||
@@ -30,7 +30,7 @@ type BadgeStatus =
|
||||
TranslatePipe,
|
||||
],
|
||||
templateUrl: './epg-source-status.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.Eager,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styleUrl: './epg-source-status.component.scss',
|
||||
})
|
||||
export class EpgSourceStatusComponent implements OnInit {
|
||||
|
||||
+53
@@ -1,5 +1,6 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import {
|
||||
EMBEDDED_MPV_SUPPORT_RECHECK_MS,
|
||||
EmbeddedMpvEngine,
|
||||
EmbeddedMpvSession,
|
||||
ResolvedPortalPlayback,
|
||||
@@ -106,6 +107,58 @@ describe('EmbeddedMpvSessionController (lifecycle & support edges)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('an inconclusive support answer', () => {
|
||||
// A slow login shell: mpv was looked up before its PATH arrived.
|
||||
const inconclusive = {
|
||||
supported: false,
|
||||
platform: 'linux',
|
||||
reason: 'mpv executable missing',
|
||||
inconclusive: true,
|
||||
};
|
||||
|
||||
it('is asked for again, so a mounted player recovers by itself', async () => {
|
||||
const settled = createSupport('native');
|
||||
electron.getEmbeddedMpvSupport
|
||||
.mockResolvedValueOnce(inconclusive)
|
||||
.mockResolvedValue(settled);
|
||||
const controller = TestBed.inject(EmbeddedMpvSessionController);
|
||||
|
||||
await waitFor(
|
||||
() => controller.support() !== null,
|
||||
'the first support answer'
|
||||
);
|
||||
expect(controller.support()).toBe(inconclusive);
|
||||
|
||||
await jest.advanceTimersByTimeAsync(
|
||||
EMBEDDED_MPV_SUPPORT_RECHECK_MS
|
||||
);
|
||||
expect(controller.support()).toBe(settled);
|
||||
expect(electron.getEmbeddedMpvSupport).toHaveBeenCalledTimes(2);
|
||||
|
||||
// A final answer is kept.
|
||||
await jest.advanceTimersByTimeAsync(
|
||||
EMBEDDED_MPV_SUPPORT_RECHECK_MS * 3
|
||||
);
|
||||
expect(electron.getEmbeddedMpvSupport).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('is no longer asked for once the player is gone', async () => {
|
||||
electron.getEmbeddedMpvSupport.mockResolvedValue(inconclusive);
|
||||
const controller = TestBed.inject(EmbeddedMpvSessionController);
|
||||
await waitFor(
|
||||
() => controller.support() !== null,
|
||||
'the first support answer'
|
||||
);
|
||||
|
||||
TestBed.resetTestingModule();
|
||||
await jest.advanceTimersByTimeAsync(
|
||||
EMBEDDED_MPV_SUPPORT_RECHECK_MS * 3
|
||||
);
|
||||
|
||||
expect(electron.getEmbeddedMpvSupport).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves the constructor support probe after preparing a session', async () => {
|
||||
const probedSupport = createSupport('native');
|
||||
const preparedSupport = createSupport('frame-copy');
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
EmbeddedMpvSupport,
|
||||
RecordingStartMetadata,
|
||||
ResolvedPortalPlayback,
|
||||
watchEmbeddedMpvSupport,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { EmbeddedMpvCommandRunner } from './embedded-mpv-command-runner';
|
||||
import { measureBounds } from './embedded-mpv-format.utils';
|
||||
@@ -95,8 +96,9 @@ export class EmbeddedMpvSessionController {
|
||||
this.session.set(session);
|
||||
});
|
||||
|
||||
let stopSupportWatch: (() => void) | undefined;
|
||||
if (typeof window.electron?.getEmbeddedMpvSupport === 'function') {
|
||||
void this.loadSupport();
|
||||
stopSupportWatch = this.watchSupport();
|
||||
} else {
|
||||
this.support.set({
|
||||
supported: false,
|
||||
@@ -114,6 +116,7 @@ export class EmbeddedMpvSessionController {
|
||||
|
||||
this.destroyRef.onDestroy(() => {
|
||||
this.unsubscribeSessionUpdate?.();
|
||||
stopSupportWatch?.();
|
||||
this.stalledTracker.cancel();
|
||||
if (this.boundsAnimationFrame !== null) {
|
||||
cancelAnimationFrame(this.boundsAnimationFrame);
|
||||
@@ -381,22 +384,31 @@ export class EmbeddedMpvSessionController {
|
||||
EmbeddedMpvSession['recording'] | null
|
||||
> => this.commands.stopRecording();
|
||||
|
||||
private async loadSupport(): Promise<void> {
|
||||
try {
|
||||
const electron = this.getElectronBridge();
|
||||
if (!electron?.getEmbeddedMpvSupport) {
|
||||
throw new Error(
|
||||
'Embedded MPV requires the Electron desktop build.'
|
||||
);
|
||||
}
|
||||
this.support.set(await electron.getEmbeddedMpvSupport());
|
||||
} catch (error) {
|
||||
this.support.set({
|
||||
supported: false,
|
||||
platform: window.electron?.platform ?? 'unknown',
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Loads support and keeps asking while the answer is inconclusive: the
|
||||
* session effect starts playback as soon as `support` turns supported,
|
||||
* so a player mounted during that window recovers by itself.
|
||||
*/
|
||||
private watchSupport(): () => void {
|
||||
return watchEmbeddedMpvSupport(
|
||||
() => {
|
||||
const electron = this.getElectronBridge();
|
||||
if (!electron?.getEmbeddedMpvSupport) {
|
||||
throw new Error(
|
||||
'Embedded MPV requires the Electron desktop build.'
|
||||
);
|
||||
}
|
||||
return electron.getEmbeddedMpvSupport();
|
||||
},
|
||||
(support) => this.support.set(support),
|
||||
(error) =>
|
||||
this.support.set({
|
||||
supported: false,
|
||||
platform: window.electron?.platform ?? 'unknown',
|
||||
reason:
|
||||
error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private getElectronBridge(): ElectronBridge | undefined {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { CrossPortalSimilarItem } from '@iptvnator/services';
|
||||
import type {
|
||||
TmdbEnrichedCastMember,
|
||||
VodDetailsItem,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
/**
|
||||
* Pure helpers resolving where the VOD details page's extras rows (cast &
|
||||
* crew, Similar rail) lead. The component owns the actual navigation.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Router commands of the actor page for a cast/crew member, inside the
|
||||
* item's own portal workspace. Null when the member has no TMDB person id
|
||||
* (names parsed from provider text): there is no actor page to open.
|
||||
*/
|
||||
export function buildVodActorRoute(
|
||||
item: VodDetailsItem,
|
||||
member: TmdbEnrichedCastMember
|
||||
): (string | number)[] | null {
|
||||
if (!member.tmdbPersonId) {
|
||||
return null;
|
||||
}
|
||||
const basePath =
|
||||
item.type === 'stalker' ? '/workspace/stalker' : '/workspace/xtreams';
|
||||
return [basePath, item.playlistId, 'actor', member.tmdbPersonId];
|
||||
}
|
||||
|
||||
/**
|
||||
* The cross-portal match behind a Similar-rail card. Rail keys are built by
|
||||
* `buildSimilarRailItems` (vod-details-presentation.ts) as
|
||||
* `x<playlistId>-<xtreamId>`.
|
||||
*/
|
||||
export function findSimilarByRailKey(
|
||||
items: readonly CrossPortalSimilarItem[],
|
||||
railKey: string
|
||||
): CrossPortalSimilarItem | undefined {
|
||||
return items.find(
|
||||
(candidate) =>
|
||||
`x${candidate.match.playlistId}-${candidate.match.xtreamId}` ===
|
||||
railKey
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,9 @@
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
untracked,
|
||||
ChangeDetectionStrategy,
|
||||
} from '@angular/core';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
@@ -53,9 +50,14 @@ import {
|
||||
} from '@iptvnator/services';
|
||||
import { VOD_DETAILS_MENU_ACTION } from './vod-details-presentation';
|
||||
import { createVodDetailsHeroState } from './vod-details-hero.state';
|
||||
import {
|
||||
buildVodActorRoute,
|
||||
findSimilarByRailKey,
|
||||
} from './vod-details-navigation.util';
|
||||
import type { PlaybackFallbackRequest } from '@iptvnator/playback/util';
|
||||
import { PortalInlinePlayerComponent } from '../portal-inline-player/portal-inline-player.component';
|
||||
import { createVodDownloadState } from './vod-download-state.util';
|
||||
import { createVodLocalDownloadState } from './vod-download-state.util';
|
||||
import { createVodSimilarInPortals } from './vod-similar-in-portals.state';
|
||||
|
||||
/**
|
||||
* Unified VOD details component for both Xtream and Stalker portals.
|
||||
@@ -231,38 +233,12 @@ export class VodDetailsComponent {
|
||||
* TMDB recommendations found in the user's OTHER portals (batched DB
|
||||
* match, Electron only). Loaded async — the section appears when
|
||||
* resolved; staleness-guarded against item changes in flight.
|
||||
* Filtered on read: a relock hides matches cached while unlocked.
|
||||
*/
|
||||
private readonly similarInPortalsMatched = signal<CrossPortalSimilarItem[]>(
|
||||
[]
|
||||
readonly similarInPortals = createVodSimilarInPortals(
|
||||
this.normalizedMeta,
|
||||
this.crossPortalSimilar
|
||||
);
|
||||
/** Filtered on read: a relock hides matches cached while unlocked. */
|
||||
readonly similarInPortals = computed(() =>
|
||||
this.crossPortalSimilar.visible(this.similarInPortalsMatched())
|
||||
);
|
||||
|
||||
private readonly loadSimilarInPortals = effect(() => {
|
||||
const meta = this.normalizedMeta();
|
||||
const recommendations = meta.tmdbRecommendations;
|
||||
untracked(() => {
|
||||
this.similarInPortalsMatched.set([]);
|
||||
if (
|
||||
!recommendations?.length ||
|
||||
!this.crossPortalSimilar.isAvailable
|
||||
) {
|
||||
return;
|
||||
}
|
||||
void this.crossPortalSimilar
|
||||
.matchRecommendations(recommendations, 'movie')
|
||||
.then((items) => {
|
||||
if (
|
||||
this.normalizedMeta().tmdbRecommendations ===
|
||||
recommendations
|
||||
) {
|
||||
this.similarInPortalsMatched.set(items);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
openSimilarInPortals(item: CrossPortalSimilarItem): void {
|
||||
void this.router.navigate(this.crossPortalSimilar.buildLink(item));
|
||||
@@ -276,19 +252,14 @@ export class VodDetailsComponent {
|
||||
() => (this.playbackPosition() ?? 0) > 0 && !this.isWatched()
|
||||
);
|
||||
|
||||
private readonly downloadState = createVodDownloadState(
|
||||
this.downloadsService,
|
||||
this.item
|
||||
);
|
||||
readonly isDownloaded = computed(
|
||||
() => !this.providerOnly() && this.downloadState.isDownloaded()
|
||||
);
|
||||
readonly isDownloading = computed(
|
||||
() => !this.providerOnly() && this.downloadState.isDownloading()
|
||||
);
|
||||
readonly isPausedDownload = computed(
|
||||
() => !this.providerOnly() && this.downloadState.isPausedDownload()
|
||||
);
|
||||
private readonly localDownload = createVodLocalDownloadState({
|
||||
downloadsService: this.downloadsService,
|
||||
item: this.item,
|
||||
providerOnly: this.providerOnly,
|
||||
});
|
||||
readonly isDownloaded = this.localDownload.isDownloaded;
|
||||
readonly isDownloading = this.localDownload.isDownloading;
|
||||
readonly isPausedDownload = this.localDownload.isPausedDownload;
|
||||
|
||||
private readonly externalButton = createExternalPlaybackButtonState({
|
||||
session: this.externalPlayback,
|
||||
@@ -360,11 +331,7 @@ export class VodDetailsComponent {
|
||||
readonly scrollToCast = scrollToCastCrewRow;
|
||||
|
||||
openSimilarRailItem(item: SimilarRailItem): void {
|
||||
const match = this.similarInPortals().find(
|
||||
(candidate) =>
|
||||
`x${candidate.match.playlistId}-${candidate.match.xtreamId}` ===
|
||||
item.key
|
||||
);
|
||||
const match = findSimilarByRailKey(this.similarInPortals(), item.key);
|
||||
if (match) {
|
||||
this.openSimilarInPortals(match);
|
||||
}
|
||||
@@ -450,20 +417,10 @@ export class VodDetailsComponent {
|
||||
|
||||
/** Handle back navigation - emit event for parent to handle */
|
||||
openActor(member: TmdbEnrichedCastMember): void {
|
||||
if (!member.tmdbPersonId) {
|
||||
return;
|
||||
const commands = buildVodActorRoute(this.item(), member);
|
||||
if (commands) {
|
||||
void this.router.navigate(commands);
|
||||
}
|
||||
const item = this.item();
|
||||
const basePath =
|
||||
item.type === 'stalker'
|
||||
? '/workspace/stalker'
|
||||
: '/workspace/xtreams';
|
||||
void this.router.navigate([
|
||||
basePath,
|
||||
item.playlistId,
|
||||
'actor',
|
||||
member.tmdbPersonId,
|
||||
]);
|
||||
}
|
||||
|
||||
goBack(): void {
|
||||
@@ -494,13 +451,8 @@ export class VodDetailsComponent {
|
||||
}
|
||||
|
||||
/** Resume the paused download of this VOD */
|
||||
async resumePausedDownload(): Promise<void> {
|
||||
const item = this.item();
|
||||
await this.downloadsService.resumeDownloadByContent(
|
||||
getVodNumericId(item),
|
||||
item.playlistId,
|
||||
'vod'
|
||||
);
|
||||
resumePausedDownload(): Promise<void> {
|
||||
return this.localDownload.resumePausedDownload();
|
||||
}
|
||||
|
||||
onInlineTimeUpdate(event: { currentTime: number; duration: number }): void {
|
||||
@@ -526,18 +478,7 @@ export class VodDetailsComponent {
|
||||
}
|
||||
|
||||
/** Play from local downloaded file */
|
||||
async playFromLocal(): Promise<void> {
|
||||
const item = this.item();
|
||||
const vodId = getVodNumericId(item);
|
||||
|
||||
const filePath = this.downloadsService.getDownloadedFilePath(
|
||||
vodId,
|
||||
item.playlistId,
|
||||
'vod'
|
||||
);
|
||||
|
||||
if (filePath) {
|
||||
await this.downloadsService.playDownload(filePath);
|
||||
}
|
||||
playFromLocal(): Promise<void> {
|
||||
return this.localDownload.playFromLocal();
|
||||
}
|
||||
}
|
||||
@@ -32,3 +32,58 @@ export function createVodDownloadState(
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export interface VodLocalDownloadStateDeps {
|
||||
readonly downloadsService: DownloadsService;
|
||||
readonly item: Signal<VodDetailsItem>;
|
||||
/** Provider detail handoff hides local/download presentation only. */
|
||||
readonly providerOnly: Signal<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The VOD details page's view of its own download: the download-state
|
||||
* signals (always false in provider-only mode) and the two actions that
|
||||
* act on the local copy.
|
||||
*/
|
||||
export function createVodLocalDownloadState(deps: VodLocalDownloadStateDeps) {
|
||||
const { downloadsService, item, providerOnly } = deps;
|
||||
const downloadState = createVodDownloadState(downloadsService, item);
|
||||
|
||||
return {
|
||||
isDownloaded: computed(
|
||||
() => !providerOnly() && downloadState.isDownloaded()
|
||||
),
|
||||
isDownloading: computed(
|
||||
() => !providerOnly() && downloadState.isDownloading()
|
||||
),
|
||||
isPausedDownload: computed(
|
||||
() => !providerOnly() && downloadState.isPausedDownload()
|
||||
),
|
||||
|
||||
/** Resume the paused download of this VOD */
|
||||
async resumePausedDownload(): Promise<void> {
|
||||
const currentItem = item();
|
||||
await downloadsService.resumeDownloadByContent(
|
||||
getVodNumericId(currentItem),
|
||||
currentItem.playlistId,
|
||||
'vod'
|
||||
);
|
||||
},
|
||||
|
||||
/** Play from local downloaded file */
|
||||
async playFromLocal(): Promise<void> {
|
||||
const currentItem = item();
|
||||
const vodId = getVodNumericId(currentItem);
|
||||
|
||||
const filePath = downloadsService.getDownloadedFilePath(
|
||||
vodId,
|
||||
currentItem.playlistId,
|
||||
'vod'
|
||||
);
|
||||
|
||||
if (filePath) {
|
||||
await downloadsService.playDownload(filePath);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
type Signal,
|
||||
computed,
|
||||
effect,
|
||||
signal,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import type {
|
||||
CrossPortalSimilarItem,
|
||||
CrossPortalSimilarService,
|
||||
} from '@iptvnator/services';
|
||||
import type { NormalizedVodMeta } from '@iptvnator/shared/interfaces';
|
||||
|
||||
/**
|
||||
* TMDB recommendations found in the user's OTHER portals (batched DB
|
||||
* match, Electron only). Loaded async — the section appears when
|
||||
* resolved; staleness-guarded against item changes in flight.
|
||||
*
|
||||
* Registers the loading effect, so call it from a field initializer
|
||||
* (injection context).
|
||||
*/
|
||||
export function createVodSimilarInPortals(
|
||||
normalizedMeta: Signal<NormalizedVodMeta>,
|
||||
crossPortalSimilar: CrossPortalSimilarService
|
||||
): Signal<CrossPortalSimilarItem[]> {
|
||||
const matched = signal<CrossPortalSimilarItem[]>([]);
|
||||
/** Filtered on read: a relock hides matches cached while unlocked. */
|
||||
const visible = computed(() => crossPortalSimilar.visible(matched()));
|
||||
|
||||
effect(() => {
|
||||
const meta = normalizedMeta();
|
||||
const recommendations = meta.tmdbRecommendations;
|
||||
untracked(() => {
|
||||
matched.set([]);
|
||||
if (!recommendations?.length || !crossPortalSimilar.isAvailable) {
|
||||
return;
|
||||
}
|
||||
void crossPortalSimilar
|
||||
.matchRecommendations(recommendations, 'movie')
|
||||
.then((items) => {
|
||||
if (
|
||||
normalizedMeta().tmdbRecommendations === recommendations
|
||||
) {
|
||||
matched.set(items);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return visible;
|
||||
}
|
||||
@@ -1,13 +1,4 @@
|
||||
<div class="actor-view">
|
||||
<button
|
||||
class="actor-view__back"
|
||||
type="button"
|
||||
(click)="backClicked.emit()"
|
||||
[attr.aria-label]="'BACK' | translate"
|
||||
>
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
</button>
|
||||
|
||||
@if (isLoading()) {
|
||||
<div class="actor-view__loading">
|
||||
<mat-spinner diameter="48" />
|
||||
|
||||
@@ -32,24 +32,6 @@
|
||||
padding: 24px 32px 48px;
|
||||
position: relative;
|
||||
|
||||
&__back {
|
||||
align-items: center;
|
||||
background: var(--chip-bg);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
height: 40px;
|
||||
justify-content: center;
|
||||
margin-bottom: 16px;
|
||||
width: 40px;
|
||||
|
||||
&:hover {
|
||||
background: var(--chip-border);
|
||||
}
|
||||
}
|
||||
|
||||
&__loading {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { WorkspaceBackNavigationService } from '@iptvnator/portal/shared/data-access';
|
||||
import { ActorViewComponent } from './actor-view.component';
|
||||
|
||||
describe('ActorViewComponent', () => {
|
||||
it('offers its Back in the workspace header instead of its own arrow', () => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [ActorViewComponent, TranslateModule.forRoot()],
|
||||
});
|
||||
const fixture = TestBed.createComponent(ActorViewComponent);
|
||||
const backClicked = jest.fn();
|
||||
fixture.componentInstance.backClicked.subscribe(backClicked);
|
||||
fixture.detectChanges();
|
||||
const backNavigation = TestBed.inject(WorkspaceBackNavigationService);
|
||||
|
||||
// Available while the profile loads, too.
|
||||
expect(
|
||||
(fixture.nativeElement as HTMLElement).querySelector('button')
|
||||
).toBeNull();
|
||||
expect(backNavigation.target()?.escapeShortcut()).toBe(false);
|
||||
backNavigation.goBack();
|
||||
expect(backClicked).toHaveBeenCalledTimes(1);
|
||||
|
||||
fixture.destroy();
|
||||
expect(backNavigation.target()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -4,9 +4,9 @@ import {
|
||||
input,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { registerWorkspaceBack } from '@iptvnator/portal/shared/data-access';
|
||||
import {
|
||||
ActorFilmographyCredit,
|
||||
ActorProfile,
|
||||
@@ -32,7 +32,6 @@ export type ActorViewScope = TitleResultsScope;
|
||||
@Component({
|
||||
selector: 'app-actor-view',
|
||||
imports: [
|
||||
MatIcon,
|
||||
MatProgressSpinnerModule,
|
||||
TranslatePipe,
|
||||
TitleResultsComponent,
|
||||
@@ -54,6 +53,11 @@ export class ActorViewComponent {
|
||||
readonly scope = input<ActorViewScope>('portal');
|
||||
|
||||
readonly itemClicked = output<ActorViewItem>();
|
||||
/** The workspace header's Back on this page. */
|
||||
readonly backClicked = output<void>();
|
||||
readonly scopeChanged = output<ActorViewScope>();
|
||||
|
||||
constructor() {
|
||||
registerWorkspaceBack({ run: () => this.backClicked.emit() });
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,4 @@
|
||||
<div class="discover-view">
|
||||
<button
|
||||
class="discover-view__back"
|
||||
type="button"
|
||||
(click)="backClicked.emit()"
|
||||
[attr.aria-label]="'BACK' | translate"
|
||||
>
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
</button>
|
||||
|
||||
<header class="discover-view__header">
|
||||
<h1>{{ 'XTREAM.DISCOVER_TITLE' | translate }}</h1>
|
||||
<div class="discover-view__facets">
|
||||
|
||||
@@ -30,24 +30,6 @@
|
||||
padding: 24px 32px 48px;
|
||||
position: relative;
|
||||
|
||||
&__back {
|
||||
align-items: center;
|
||||
background: var(--chip-bg);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
height: 40px;
|
||||
justify-content: center;
|
||||
margin-bottom: 16px;
|
||||
width: 40px;
|
||||
|
||||
&:hover {
|
||||
background: var(--chip-border);
|
||||
}
|
||||
}
|
||||
|
||||
&__loading {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { WorkspaceBackNavigationService } from '@iptvnator/portal/shared/data-access';
|
||||
import { DiscoverViewComponent } from './discover-view.component';
|
||||
|
||||
describe('DiscoverViewComponent', () => {
|
||||
it('offers its Back in the workspace header instead of its own arrow', () => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [DiscoverViewComponent, TranslateModule.forRoot()],
|
||||
});
|
||||
const fixture = TestBed.createComponent(DiscoverViewComponent);
|
||||
fixture.componentRef.setInput('facets', {
|
||||
type: 'movie',
|
||||
genreId: 18,
|
||||
genreLabel: 'Drama',
|
||||
year: null,
|
||||
countryCode: null,
|
||||
countryLabel: null,
|
||||
});
|
||||
const backClicked = jest.fn();
|
||||
fixture.componentInstance.backClicked.subscribe(backClicked);
|
||||
fixture.detectChanges();
|
||||
const backNavigation = TestBed.inject(WorkspaceBackNavigationService);
|
||||
|
||||
expect(
|
||||
(fixture.nativeElement as HTMLElement).querySelector(
|
||||
'.discover-view > button'
|
||||
)
|
||||
).toBeNull();
|
||||
expect(backNavigation.target()?.escapeShortcut()).toBe(false);
|
||||
backNavigation.goBack();
|
||||
expect(backClicked).toHaveBeenCalledTimes(1);
|
||||
|
||||
fixture.destroy();
|
||||
expect(backNavigation.target()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -5,9 +5,9 @@ import {
|
||||
input,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { registerWorkspaceBack } from '@iptvnator/portal/shared/data-access';
|
||||
import {
|
||||
TitleResultItem,
|
||||
TitleResultsComponent,
|
||||
@@ -23,7 +23,6 @@ import { DiscoverRouteParams } from './discover-params';
|
||||
@Component({
|
||||
selector: 'app-discover-view',
|
||||
imports: [
|
||||
MatIcon,
|
||||
MatProgressSpinnerModule,
|
||||
TranslatePipe,
|
||||
TitleResultsComponent,
|
||||
@@ -45,6 +44,7 @@ export class DiscoverViewComponent<T extends TitleResultItem> {
|
||||
readonly scope = input<TitleResultsScope>('portal');
|
||||
|
||||
readonly itemClicked = output<T>();
|
||||
/** The workspace header's Back on this page. */
|
||||
readonly backClicked = output<void>();
|
||||
readonly scopeChanged = output<TitleResultsScope>();
|
||||
|
||||
@@ -63,4 +63,8 @@ export class DiscoverViewComponent<T extends TitleResultItem> {
|
||||
facets.countryLabel ?? facets.countryCode,
|
||||
].filter((label): label is string => label !== null);
|
||||
});
|
||||
|
||||
constructor() {
|
||||
registerWorkspaceBack({ run: () => this.backClicked.emit() });
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
"types": ["jest", "node"],
|
||||
"moduleResolution": "bundler"
|
||||
},
|
||||
"files": ["src/test-setup.ts"],
|
||||
"files": ["src/test-setup.ts", "../../../global.d.ts"],
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"src/**/*.test.ts",
|
||||
|
||||
Reference in new issue
Block a user