mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 05:36:17 -08:00
Merge origin/master into claude/parental-control-feature-31dde2
Resolves the settings-store defaults split, the electron-conf key list and the guidance reorganization (CLAUDE.md now imports AGENTS.md; the parental lock contract is linked from the agent context map instead). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
commit
ae5d3f49de
732 files changed
+42572
-15380
No files matched your search
@@ -19,6 +19,7 @@ export * from './lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component
|
||||
export * from './lib/progress-capsule/progress-capsule.component';
|
||||
export * from './lib/resizable/resizable.directive';
|
||||
export * from './lib/season-container/season-container.component';
|
||||
export * from './lib/season-container/season-tabs.component';
|
||||
export * from './lib/season-container/season-watch-presenter';
|
||||
export * from './lib/season-container/season-watch-toggle.util';
|
||||
export * from './lib/vod-sources/vod-source-copy-row.component';
|
||||
|
||||
+4
-1
@@ -86,7 +86,10 @@
|
||||
[showDetailsContextMenu]="true"
|
||||
[isRadio]="channel?.radio === 'true'"
|
||||
[epgProgram]="
|
||||
epgMetadataMap().get(getChannelEpgKey(channel))?.epgProgram
|
||||
$safeNavigationMigration(
|
||||
epgMetadataMap().get(getChannelEpgKey(channel))
|
||||
?.epgProgram
|
||||
)
|
||||
"
|
||||
[progressPercentage]="
|
||||
epgMetadataMap().get(getChannelEpgKey(channel))
|
||||
|
||||
+24
@@ -91,6 +91,30 @@ describe('AllChannelsViewComponent', () => {
|
||||
localStorage.removeItem(ALL_CHANNELS_SORT_STORAGE_KEY);
|
||||
});
|
||||
|
||||
it('filters the Turkish dotted capital İ case-insensitively (issue #609)', () => {
|
||||
const turkishChannel = createChannel(
|
||||
'channel-2',
|
||||
'İnşaat Kanalı',
|
||||
'https://example.com/insaat.m3u8'
|
||||
);
|
||||
fixture.componentRef.setInput('channels', [
|
||||
primaryChannel,
|
||||
turkishChannel,
|
||||
]);
|
||||
|
||||
fixture.componentRef.setInput('searchTerm', 'inş');
|
||||
fixture.detectChanges();
|
||||
expect(component.filteredChannels()).toEqual([turkishChannel]);
|
||||
|
||||
fixture.componentRef.setInput('searchTerm', 'İNŞ');
|
||||
fixture.detectChanges();
|
||||
expect(component.filteredChannels()).toEqual([turkishChannel]);
|
||||
|
||||
fixture.componentRef.setInput('searchTerm', 'news');
|
||||
fixture.detectChanges();
|
||||
expect(component.filteredChannels()).toEqual([primaryChannel]);
|
||||
});
|
||||
|
||||
it('defaults to playlist order when no saved sort mode exists', () => {
|
||||
expect(component.allChannelsSortMode()).toBe('server');
|
||||
expect(component.allChannelsSortLabel()).toBe('Playlist Order');
|
||||
|
||||
+5
-2
@@ -21,6 +21,7 @@ import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
|
||||
import { SettingsStore } from '@iptvnator/services';
|
||||
import { resolveChannelEpgLookupKey } from '@iptvnator/m3u-state';
|
||||
import {
|
||||
foldSearchText,
|
||||
Channel,
|
||||
EpgProgram,
|
||||
epgProviderClockMs,
|
||||
@@ -127,10 +128,12 @@ export class AllChannelsViewComponent {
|
||||
* reference when there is no search term, so large lists avoid cloning.
|
||||
*/
|
||||
readonly filteredChannels = computed(() => {
|
||||
const term = this.searchTerm().trim().toLowerCase();
|
||||
const term = foldSearchText(this.searchTerm().trim());
|
||||
const channels = this.channels();
|
||||
const filteredChannels = term
|
||||
? channels.filter((ch) => ch.name?.toLowerCase().includes(term))
|
||||
? channels.filter((ch) =>
|
||||
foldSearchText(ch.name ?? '').includes(term)
|
||||
)
|
||||
: channels;
|
||||
|
||||
return sortPlaylistChannelItems(
|
||||
|
||||
+3
-2
@@ -51,6 +51,7 @@ import {
|
||||
SettingsStore,
|
||||
} from '@iptvnator/services';
|
||||
import {
|
||||
foldSearchText,
|
||||
Channel,
|
||||
EpgProgram,
|
||||
isM3uRecentlyViewedItem,
|
||||
@@ -220,12 +221,12 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
|
||||
private readonly routeSearchTerm = queryParamSignal(
|
||||
this.route,
|
||||
'q',
|
||||
(value) => (value ?? '').trim().toLowerCase()
|
||||
(value) => foldSearchText((value ?? '').trim())
|
||||
);
|
||||
readonly workspaceSearchTerm = computed(() => {
|
||||
const hostTerm = this.searchTerm();
|
||||
if (hostTerm !== null) {
|
||||
return hostTerm.trim().toLowerCase();
|
||||
return foldSearchText(hostTerm.trim());
|
||||
}
|
||||
return this.isWorkspaceLayout ? this.routeSearchTerm() : '';
|
||||
});
|
||||
|
||||
+4
-1
@@ -25,7 +25,10 @@
|
||||
[showEpg]="shouldShowEpg()"
|
||||
[isRadio]="channel?.radio === 'true'"
|
||||
[epgProgram]="
|
||||
epgMetadataMap().get(getChannelEpgKey(channel))?.epgProgram
|
||||
$safeNavigationMigration(
|
||||
epgMetadataMap().get(getChannelEpgKey(channel))
|
||||
?.epgProgram
|
||||
)
|
||||
"
|
||||
[progressPercentage]="
|
||||
epgMetadataMap().get(getChannelEpgKey(channel))
|
||||
|
||||
+5
-4
@@ -22,6 +22,7 @@ import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
|
||||
import { SettingsStore } from '@iptvnator/services';
|
||||
import { resolveChannelEpgLookupKey } from '@iptvnator/m3u-state';
|
||||
import {
|
||||
foldSearchText,
|
||||
Channel,
|
||||
EpgProgram,
|
||||
epgProviderClockMs,
|
||||
@@ -97,16 +98,16 @@ export class FavoritesViewComponent {
|
||||
);
|
||||
readonly filteredFavorites = computed(() => {
|
||||
const favorites = this.favorites();
|
||||
const term = this.searchTerm().trim().toLowerCase();
|
||||
const term = foldSearchText(this.searchTerm().trim());
|
||||
|
||||
if (!term) {
|
||||
return favorites;
|
||||
}
|
||||
|
||||
return favorites.filter((channel) =>
|
||||
`${channel.name ?? ''} ${channel.group?.title ?? ''}`
|
||||
.toLowerCase()
|
||||
.includes(term)
|
||||
foldSearchText(
|
||||
`${channel.name ?? ''} ${channel.group?.title ?? ''}`
|
||||
).includes(term)
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
+3
-2
@@ -16,6 +16,7 @@ import {
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { foldSearchText } from '@iptvnator/shared/interfaces';
|
||||
|
||||
export interface GroupManagementDialogGroup {
|
||||
readonly count: number;
|
||||
@@ -78,14 +79,14 @@ export class GroupManagementDialogComponent {
|
||||
);
|
||||
|
||||
readonly filteredGroups = computed(() => {
|
||||
const term = this.searchTerm().trim().toLowerCase();
|
||||
const term = foldSearchText(this.searchTerm().trim());
|
||||
|
||||
if (!term) {
|
||||
return this.groups();
|
||||
}
|
||||
|
||||
return this.groups().filter((group) =>
|
||||
group.key.toLowerCase().includes(term)
|
||||
foldSearchText(group.key).includes(term)
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
+4
-2
@@ -269,8 +269,10 @@
|
||||
[showDetailsContextMenu]="true"
|
||||
[isRadio]="channel?.radio === 'true'"
|
||||
[epgProgram]="
|
||||
epgMetadataMap().get(getChannelEpgKey(channel))
|
||||
?.epgProgram
|
||||
$safeNavigationMigration(
|
||||
epgMetadataMap().get(getChannelEpgKey(channel))
|
||||
?.epgProgram
|
||||
)
|
||||
"
|
||||
[progressPercentage]="
|
||||
epgMetadataMap().get(getChannelEpgKey(channel))
|
||||
|
||||
+8
-5
@@ -24,6 +24,7 @@ import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
|
||||
import { resolveChannelEpgLookupKey } from '@iptvnator/m3u-state';
|
||||
import { SettingsStore } from '@iptvnator/services';
|
||||
import {
|
||||
foldSearchText,
|
||||
Channel,
|
||||
EpgProgram,
|
||||
epgProviderClockMs,
|
||||
@@ -331,7 +332,7 @@ export class GroupsViewComponent {
|
||||
});
|
||||
|
||||
readonly workspaceFilteredGroups = computed<FilteredGroupView[]>(() => {
|
||||
const term = this.searchTerm().trim().toLowerCase();
|
||||
const term = foldSearchText(this.searchTerm().trim());
|
||||
const groups = this.visibleGroups();
|
||||
|
||||
if (!term) {
|
||||
@@ -344,11 +345,11 @@ export class GroupsViewComponent {
|
||||
}
|
||||
|
||||
return groups.reduce<FilteredGroupView[]>((acc, group) => {
|
||||
const titleMatches = group.key.toLowerCase().includes(term);
|
||||
const titleMatches = foldSearchText(group.key).includes(term);
|
||||
const channels = titleMatches
|
||||
? group.channels
|
||||
: group.channels.filter((channel) =>
|
||||
`${channel.name ?? ''}`.toLowerCase().includes(term)
|
||||
foldSearchText(`${channel.name ?? ''}`).includes(term)
|
||||
);
|
||||
|
||||
if (channels.length === 0) {
|
||||
@@ -366,14 +367,16 @@ export class GroupsViewComponent {
|
||||
});
|
||||
|
||||
readonly filteredGroups = computed<FilteredGroupView[]>(() => {
|
||||
const term = this.localGroupSearchTerm().trim().toLowerCase();
|
||||
const term = foldSearchText(this.localGroupSearchTerm().trim());
|
||||
const groups = this.workspaceFilteredGroups();
|
||||
|
||||
if (!term) {
|
||||
return groups;
|
||||
}
|
||||
|
||||
return groups.filter((group) => group.key.toLowerCase().includes(term));
|
||||
return groups.filter((group) =>
|
||||
foldSearchText(group.key).includes(term)
|
||||
);
|
||||
});
|
||||
|
||||
readonly hasAnyGroups = computed(() => this.allGroups().length > 0);
|
||||
|
||||
+5
-4
@@ -17,6 +17,7 @@ import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
|
||||
import { resolveChannelEpgLookupKey } from '@iptvnator/m3u-state';
|
||||
import { SettingsStore } from '@iptvnator/services';
|
||||
import {
|
||||
foldSearchText,
|
||||
Channel,
|
||||
EpgProgram,
|
||||
epgProviderClockMs,
|
||||
@@ -78,16 +79,16 @@ export class RecentViewComponent {
|
||||
|
||||
readonly filteredRecentItems = computed(() => {
|
||||
const recentItems = this.recentItems();
|
||||
const term = this.searchTerm().trim().toLowerCase();
|
||||
const term = foldSearchText(this.searchTerm().trim());
|
||||
|
||||
if (!term) {
|
||||
return recentItems;
|
||||
}
|
||||
|
||||
return recentItems.filter(({ channel }) =>
|
||||
`${channel.name ?? ''} ${channel.group?.title ?? ''}`
|
||||
.toLowerCase()
|
||||
.includes(term)
|
||||
foldSearchText(
|
||||
`${channel.name ?? ''} ${channel.group?.title ?? ''}`
|
||||
).includes(term)
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { Component, inject, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
@@ -16,6 +16,8 @@ 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,
|
||||
template: `
|
||||
<h2 mat-dialog-title>
|
||||
{{ dialogData.title }}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
untracked,
|
||||
viewChild,
|
||||
ElementRef,
|
||||
ChangeDetectionStrategy,
|
||||
} from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
@@ -25,6 +26,8 @@ import { NgxSkeletonLoaderComponent } from 'ngx-skeleton-loader';
|
||||
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,
|
||||
styleUrls: ['./content-hero.component.scss'],
|
||||
})
|
||||
export class ContentHeroComponent {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
signal,
|
||||
untracked,
|
||||
viewChild,
|
||||
ChangeDetectionStrategy,
|
||||
} from '@angular/core';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
@@ -46,6 +47,8 @@ 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,
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { NgTemplateOutlet } from '@angular/common';
|
||||
import { Component, TemplateRef, input } from '@angular/core';
|
||||
import {
|
||||
Component,
|
||||
TemplateRef,
|
||||
input,
|
||||
ChangeDetectionStrategy,
|
||||
} from '@angular/core';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
|
||||
/**
|
||||
@@ -48,6 +53,8 @@ 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,
|
||||
styleUrls: ['./content-about.component.scss'],
|
||||
})
|
||||
export class ContentAboutComponent {
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
@if (backAvailable() || isWatch()) {
|
||||
@let label =
|
||||
isWatch()
|
||||
? ('PORTALS.CLOSE_PLAYER' | translate)
|
||||
: backLabel() || ('BACK' | translate);
|
||||
@if (backAvailable()) {
|
||||
@let label = backLabel() || ('BACK' | translate);
|
||||
<!-- Route-level Back in both states. Watch keeps the arrow's meaning;
|
||||
closing the player belongs to the player's own Close button and Escape. -->
|
||||
<div class="shell__navigation">
|
||||
<button
|
||||
#backButton
|
||||
class="shell__back-button"
|
||||
type="button"
|
||||
[attr.aria-label]="label"
|
||||
aria-keyshortcuts="Escape"
|
||||
[title]="label + ' (Esc)'"
|
||||
(click)="onBack()"
|
||||
[attr.aria-keyshortcuts]="isWatch() ? null : 'Escape'"
|
||||
[title]="isWatch() ? label : label + ' (Esc)'"
|
||||
(click)="backClicked.emit()"
|
||||
>
|
||||
<mat-icon aria-hidden="true">arrow_back</mat-icon>
|
||||
</button>
|
||||
|
||||
+38
-7
@@ -277,23 +277,44 @@ describe('PortalDetailShellComponent', () => {
|
||||
expect(host.closeRequests).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps one labelled back control outside the collapsing hero', async () => {
|
||||
it('keeps one route-back control outside the collapsing hero in both states', () => {
|
||||
const back = requiredQuery('.shell__back-button');
|
||||
expect(back.closest('app-content-hero')).toBeNull();
|
||||
expect(back.getAttribute('aria-label')).toBe('Return to downloads');
|
||||
expect(back.getAttribute('aria-keyshortcuts')).toBe('Escape');
|
||||
back.click();
|
||||
expect(host.backRequests).toBe(1);
|
||||
host.playbackActive.set(true);
|
||||
fixture.detectChanges();
|
||||
expect(query('.shell__back-button')).toBe(back);
|
||||
expect(back.getAttribute('aria-label')).toBe('Close player');
|
||||
expect(back.getAttribute('aria-keyshortcuts')).toBe('Escape');
|
||||
requiredQuery('.fake-player').tabIndex = 0;
|
||||
requiredQuery('.fake-player').focus();
|
||||
// Watch keeps the arrow's meaning: it leaves the page, it does not
|
||||
// close the player. Escape is the close shortcut, so the hint goes.
|
||||
expect(back.getAttribute('aria-label')).toBe('Return to downloads');
|
||||
expect(back.getAttribute('aria-keyshortcuts')).toBeNull();
|
||||
expect(back.getAttribute('title')).toBe('Return to downloads');
|
||||
back.click();
|
||||
expect(host.backRequests).toBe(2);
|
||||
expect(host.closeRequests).toBe(0);
|
||||
});
|
||||
|
||||
it('moves lost focus to the back control after Escape closes the player', async () => {
|
||||
host.playbackActive.set(true);
|
||||
fixture.detectChanges();
|
||||
const back = requiredQuery('.shell__back-button');
|
||||
const player = requiredQuery('.fake-player');
|
||||
player.tabIndex = 0;
|
||||
player.focus();
|
||||
player.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
expect(host.closeRequests).toBe(1);
|
||||
expect(host.backRequests).toBe(1);
|
||||
expect(host.backRequests).toBe(0);
|
||||
expect(document.activeElement).toBe(back);
|
||||
});
|
||||
|
||||
@@ -311,8 +332,18 @@ describe('PortalDetailShellComponent', () => {
|
||||
expect(host.backRequests).toBe(0);
|
||||
host.playbackActive.set(true);
|
||||
fixture.detectChanges();
|
||||
requiredQuery('.shell__back-button').click();
|
||||
// No route to go back to, so no arrow in watch either; the player's
|
||||
// own Close button and Escape remain the exits.
|
||||
expect(query('.shell__back-button')).toBeNull();
|
||||
requiredQuery('app-portal-detail-shell').dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
);
|
||||
expect(host.closeRequests).toBe(1);
|
||||
expect(host.backRequests).toBe(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
input,
|
||||
output,
|
||||
viewChild,
|
||||
ChangeDetectionStrategy,
|
||||
} from '@angular/core';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
@@ -31,7 +32,9 @@ import {
|
||||
* About block below the episodes slot.
|
||||
*
|
||||
* The shell owns the page scroll, the browse↔watch animation, Escape
|
||||
* handling, and never conditionally wraps the `[detail-player]` slot — the
|
||||
* handling, the one sticky Back control (route-level in both states; closing
|
||||
* the player is the player's own Close button and Escape), and never
|
||||
* conditionally wraps the `[detail-player]` slot — the
|
||||
* host's own `@if (inlinePlayback())` is the only thing that creates or
|
||||
* destroys the player, so shell state changes cannot recreate it.
|
||||
*/
|
||||
@@ -47,6 +50,8 @@ import {
|
||||
],
|
||||
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,
|
||||
host: {
|
||||
tabindex: '0',
|
||||
role: 'region',
|
||||
@@ -77,8 +82,9 @@ export class PortalDetailShellComponent {
|
||||
/** True while inline playback is active — flips the layout to watch state. */
|
||||
readonly playbackActive = input(false);
|
||||
|
||||
/** The sticky control in either state, or Escape in browse. */
|
||||
readonly backClicked = output<void>();
|
||||
/** Emitted by the sticky control or Escape during inline playback. */
|
||||
/** Emitted by Escape during inline playback. */
|
||||
readonly closePlayerRequested = output<void>();
|
||||
|
||||
protected readonly tagsTemplate = contentChild(DetailTagsTemplateDirective);
|
||||
@@ -195,10 +201,11 @@ export class PortalDetailShellComponent {
|
||||
)
|
||||
return;
|
||||
event.preventDefault();
|
||||
this.onBack();
|
||||
this.unwind();
|
||||
}
|
||||
|
||||
onBack(): void {
|
||||
/** One level per Escape: watch closes the player, browse leaves the page. */
|
||||
private unwind(): void {
|
||||
if (!this.playbackActive()) {
|
||||
if (this.backAvailable()) this.backClicked.emit();
|
||||
return;
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { Component, computed, input } from '@angular/core';
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
ChangeDetectionStrategy,
|
||||
} from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-progress-capsule',
|
||||
@@ -14,6 +19,8 @@ import { Component, computed, input } from '@angular/core';
|
||||
></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,
|
||||
styles: [
|
||||
`
|
||||
.progress-capsule {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { Component, inject, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import {
|
||||
MAT_DIALOG_DATA,
|
||||
@@ -25,9 +25,7 @@ export function buildEpisodeInfoDialogData(
|
||||
fallbackSeasonKey: string | undefined
|
||||
): EpisodeInfoDialogData {
|
||||
const info =
|
||||
!episode.info || Array.isArray(episode.info)
|
||||
? undefined
|
||||
: episode.info;
|
||||
!episode.info || Array.isArray(episode.info) ? undefined : episode.info;
|
||||
const seasonNumber = Number(episode.season || fallbackSeasonKey || 1);
|
||||
const episodeNumber = Number(episode.episode_num || 1);
|
||||
return {
|
||||
@@ -107,6 +105,8 @@ 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,
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import {
|
||||
PlaybackPositionData,
|
||||
XtreamSerieEpisode,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
type AutoSeasonContext,
|
||||
resolveAutoSelectedSeason,
|
||||
} from './season-auto-select.util';
|
||||
|
||||
// Direct coverage for the pure season auto-select rule. The container specs
|
||||
// exercise it through the component; this file pins each branch on its own.
|
||||
|
||||
function episode(id: string, season: number): XtreamSerieEpisode {
|
||||
return {
|
||||
id,
|
||||
episode_num: 1,
|
||||
title: `Episode ${id}`,
|
||||
container_extension: 'mp4',
|
||||
info: {},
|
||||
custom_sid: '',
|
||||
added: '',
|
||||
season,
|
||||
direct_source: '',
|
||||
} as XtreamSerieEpisode;
|
||||
}
|
||||
|
||||
function position(
|
||||
contentXtreamId: number,
|
||||
overrides: Partial<PlaybackPositionData> = {}
|
||||
): PlaybackPositionData {
|
||||
return {
|
||||
contentXtreamId,
|
||||
contentType: 'episode',
|
||||
positionSeconds: 600,
|
||||
durationSeconds: 2700,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const inProgress = (contentXtreamId: number, updatedAt?: string) =>
|
||||
position(contentXtreamId, { positionSeconds: 600, updatedAt });
|
||||
const watched = (contentXtreamId: number) =>
|
||||
position(contentXtreamId, { positionSeconds: 2700 });
|
||||
|
||||
/**
|
||||
* Builds the context the container would hand over: one episode per listed
|
||||
* id, season keys in display order, counts derived from the same data.
|
||||
*/
|
||||
function context(
|
||||
seasons: Record<string, string[]>,
|
||||
positions: Record<string, PlaybackPositionData> = {},
|
||||
overrides: Partial<AutoSeasonContext> = {}
|
||||
): AutoSeasonContext {
|
||||
const keys = Object.keys(seasons).sort((a, b) => Number(a) - Number(b));
|
||||
const episodes: Record<string, XtreamSerieEpisode[]> = {};
|
||||
const episodeCounts: Record<string, number> = {};
|
||||
const watchedCounts: Record<string, number> = {};
|
||||
for (const key of keys) {
|
||||
episodes[key] = seasons[key].map((id) => episode(id, Number(key)));
|
||||
episodeCounts[key] = seasons[key].length;
|
||||
watchedCounts[key] = seasons[key].filter((id) => {
|
||||
const stored = positions[id];
|
||||
return (
|
||||
stored !== undefined &&
|
||||
stored.positionSeconds / (stored.durationSeconds ?? 1) >= 0.9
|
||||
);
|
||||
}).length;
|
||||
}
|
||||
return {
|
||||
keys,
|
||||
playingSeasonKey: null,
|
||||
seasons: episodes,
|
||||
positionOf: (item) => positions[item.id],
|
||||
hasUnloadedSeasons: false,
|
||||
episodeCounts,
|
||||
watchedCounts,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('resolveAutoSelectedSeason', () => {
|
||||
it('returns undefined without season keys', () => {
|
||||
expect(resolveAutoSelectedSeason(context({}))).toBeUndefined();
|
||||
expect(
|
||||
resolveAutoSelectedSeason(
|
||||
context({}, {}, { playingSeasonKey: '3' })
|
||||
)
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('prefers the playing season over every other rule', () => {
|
||||
const ctx = context(
|
||||
{ '1': ['101'], '2': ['201'], '3': ['301'] },
|
||||
{ '201': inProgress(201, '2026-07-02T00:00:00.000Z') },
|
||||
{ playingSeasonKey: '3' }
|
||||
);
|
||||
expect(resolveAutoSelectedSeason(ctx)).toBe('3');
|
||||
});
|
||||
|
||||
it('picks the most recently updated in-progress season', () => {
|
||||
const ctx = context(
|
||||
{ '1': ['101', '102'], '2': ['201'], '3': ['301'] },
|
||||
{
|
||||
'101': inProgress(101, '2026-07-03T00:00:00.000Z'),
|
||||
'301': inProgress(301, '2026-07-01T00:00:00.000Z'),
|
||||
}
|
||||
);
|
||||
// Season three is later in display order but its resume point is
|
||||
// older; recency, not position, decides.
|
||||
expect(resolveAutoSelectedSeason(ctx)).toBe('1');
|
||||
});
|
||||
|
||||
it('treats a missing updatedAt as the oldest resume point', () => {
|
||||
const ctx = context(
|
||||
{ '1': ['101'], '2': ['201'] },
|
||||
{
|
||||
'101': inProgress(101, '2026-07-01T00:00:00.000Z'),
|
||||
'201': inProgress(201),
|
||||
}
|
||||
);
|
||||
expect(resolveAutoSelectedSeason(ctx)).toBe('1');
|
||||
});
|
||||
|
||||
it('resolves an updatedAt tie to the season scanned last', () => {
|
||||
const ctx = context(
|
||||
{ '1': ['101'], '2': ['201'] },
|
||||
{
|
||||
'101': inProgress(101, '2026-07-01T00:00:00.000Z'),
|
||||
'201': inProgress(201, '2026-07-01T00:00:00.000Z'),
|
||||
}
|
||||
);
|
||||
// `>=` keeps the later entry on a tie: the scan order of the seasons
|
||||
// map, which follows the container's sorted keys.
|
||||
expect(resolveAutoSelectedSeason(ctx)).toBe('2');
|
||||
});
|
||||
|
||||
it('ignores watched and barely-started positions when looking for progress', () => {
|
||||
const ctx = context(
|
||||
{ '1': ['101'], '2': ['201'], '3': ['301'] },
|
||||
{
|
||||
'101': watched(101),
|
||||
'301': position(301, {
|
||||
positionSeconds: 5,
|
||||
updatedAt: '2026-07-09T00:00:00.000Z',
|
||||
}),
|
||||
}
|
||||
);
|
||||
// Season one is watched, season three's 5 s is below the in-progress
|
||||
// threshold, so the default rule runs: earliest unwatched season.
|
||||
expect(resolveAutoSelectedSeason(ctx)).toBe('2');
|
||||
});
|
||||
|
||||
it('falls back to the earliest season with unwatched episodes', () => {
|
||||
const ctx = context(
|
||||
{ '1': ['101', '102'], '2': ['201'], '3': ['301'] },
|
||||
{ '101': watched(101), '102': watched(102) }
|
||||
);
|
||||
expect(resolveAutoSelectedSeason(ctx)).toBe('2');
|
||||
});
|
||||
|
||||
it('opens the latest non-empty season once everything is watched', () => {
|
||||
const ctx = context(
|
||||
{ '1': ['101'], '2': ['201'], '3': ['301'] },
|
||||
{ '101': watched(101), '201': watched(201), '301': watched(301) }
|
||||
);
|
||||
expect(resolveAutoSelectedSeason(ctx)).toBe('3');
|
||||
});
|
||||
|
||||
it('skips loaded-but-empty seasons in both fallback branches', () => {
|
||||
// An empty season is never "unwatched": the first season with
|
||||
// episodes wins.
|
||||
expect(
|
||||
resolveAutoSelectedSeason(
|
||||
context({ '1': [], '2': ['201'], '3': ['301'] })
|
||||
)
|
||||
).toBe('2');
|
||||
// Nor is it the "latest" season once everything is watched.
|
||||
expect(
|
||||
resolveAutoSelectedSeason(
|
||||
context(
|
||||
{ '1': ['101'], '2': ['201'], '3': [] },
|
||||
{ '101': watched(101), '201': watched(201) }
|
||||
)
|
||||
)
|
||||
).toBe('2');
|
||||
});
|
||||
|
||||
it('returns the first key when every season is empty', () => {
|
||||
expect(resolveAutoSelectedSeason(context({ '1': [], '2': [] }))).toBe(
|
||||
'1'
|
||||
);
|
||||
});
|
||||
|
||||
it('pins the first key while seasons are still unloaded', () => {
|
||||
const ctx = context(
|
||||
{ '1': ['101'], '2': ['201'], '3': [] },
|
||||
{ '101': watched(101) },
|
||||
{ hasUnloadedSeasons: true }
|
||||
);
|
||||
// Watched state of the unloaded seasons is unknown, so the fallback
|
||||
// does not skip ahead — but a resume point still outranks the pin.
|
||||
expect(resolveAutoSelectedSeason(ctx)).toBe('1');
|
||||
expect(
|
||||
resolveAutoSelectedSeason(
|
||||
context(
|
||||
{ '1': ['101'], '2': ['201'], '3': [] },
|
||||
{ '201': inProgress(201, '2026-07-01T00:00:00.000Z') },
|
||||
{ hasUnloadedSeasons: true }
|
||||
)
|
||||
)
|
||||
).toBe('2');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { isPortalPlaybackInProgress } from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
PlaybackPositionData,
|
||||
XtreamSerieEpisode,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
export interface AutoSeasonContext {
|
||||
/** Season keys in display order. */
|
||||
keys: readonly string[];
|
||||
/** Season of the inline-playing episode, if it is in the loaded set. */
|
||||
playingSeasonKey: string | null;
|
||||
seasons: Record<string, XtreamSerieEpisode[]>;
|
||||
positionOf: (
|
||||
episode: XtreamSerieEpisode
|
||||
) => PlaybackPositionData | undefined;
|
||||
/** Stalker lazy-VOD: some seasons' episode lists are not loaded yet. */
|
||||
hasUnloadedSeasons: boolean;
|
||||
episodeCounts: Record<string, number>;
|
||||
watchedCounts: Record<string, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The season the container opens on when the season set or the initial
|
||||
* playback positions change: the inline-playing episode's season, else the
|
||||
* most recently updated in-progress episode's season, else the default
|
||||
* fallback below. Pure, so the container's auto-select effect stays a thin
|
||||
* wrapper (see `SeasonContainerComponent.selectedSeason`).
|
||||
*/
|
||||
export function resolveAutoSelectedSeason(
|
||||
context: AutoSeasonContext
|
||||
): string | undefined {
|
||||
const { keys } = context;
|
||||
if (keys.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (context.playingSeasonKey) {
|
||||
return context.playingSeasonKey;
|
||||
}
|
||||
return (
|
||||
findMostRecentInProgressSeason(context) ?? resolveDefaultSeason(context)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback when nothing is playing or in progress: the earliest season with
|
||||
* unwatched episodes, or — once everything loaded is watched — the latest
|
||||
* non-empty season, where new episodes land (issue #1441). Loaded-but-empty
|
||||
* seasons (a valid Stalker answer) are never picked over one that has
|
||||
* episodes. Stalker lazy-VOD series with unhydrated seasons keep the first
|
||||
* season: their watched state is unknown, so skipping past them would be a
|
||||
* guess.
|
||||
*/
|
||||
function resolveDefaultSeason({
|
||||
keys,
|
||||
hasUnloadedSeasons,
|
||||
episodeCounts,
|
||||
watchedCounts,
|
||||
}: AutoSeasonContext): string {
|
||||
if (hasUnloadedSeasons) {
|
||||
return keys[0];
|
||||
}
|
||||
const firstUnwatched = keys.find((key) => {
|
||||
const total = episodeCounts[key] ?? 0;
|
||||
return total > 0 && (watchedCounts[key] ?? 0) < total;
|
||||
});
|
||||
if (firstUnwatched) {
|
||||
return firstUnwatched;
|
||||
}
|
||||
const latestWithEpisodes = [...keys]
|
||||
.reverse()
|
||||
.find((key) => (episodeCounts[key] ?? 0) > 0);
|
||||
return latestWithEpisodes ?? keys[0];
|
||||
}
|
||||
|
||||
function findMostRecentInProgressSeason({
|
||||
seasons,
|
||||
positionOf,
|
||||
}: AutoSeasonContext): string | null {
|
||||
let bestSeason: string | null = null;
|
||||
let bestUpdatedAt = '';
|
||||
for (const [key, episodes] of Object.entries(seasons)) {
|
||||
for (const episode of episodes ?? []) {
|
||||
const position = positionOf(episode);
|
||||
if (!isPortalPlaybackInProgress(position)) {
|
||||
continue;
|
||||
}
|
||||
const updatedAt = position?.updatedAt ?? '';
|
||||
if (updatedAt >= bestUpdatedAt) {
|
||||
bestUpdatedAt = updatedAt;
|
||||
bestSeason = key;
|
||||
}
|
||||
}
|
||||
}
|
||||
return bestSeason;
|
||||
}
|
||||
@@ -22,16 +22,38 @@
|
||||
/>
|
||||
|
||||
@if (hasSeasons()) {
|
||||
<app-season-tabs
|
||||
[seasonKeys]="sortedSeasonKeys()"
|
||||
[selectedSeason]="selectedSeason()"
|
||||
[episodeCounts]="episodeCounts()"
|
||||
[watchedCounts]="watchedCounts()"
|
||||
[seasonDescription]="selectedSeasonDescription()"
|
||||
[playingSeasonKey]="playingSeasonKey()"
|
||||
(seasonSelected)="selectSeason($event)"
|
||||
(backToPlayingRequested)="scrollToPlayingEpisode()"
|
||||
/>
|
||||
<!-- Season card: the selected season's own cover beside the tabs and
|
||||
description. Without a cover the column is not rendered at all,
|
||||
so the tabs sit exactly where they did before season posters. -->
|
||||
<div
|
||||
class="season-card"
|
||||
[class.season-card--with-cover]="selectedSeasonPosterUrl()"
|
||||
>
|
||||
@if (selectedSeasonPosterUrl(); as posterUrl) {
|
||||
<img
|
||||
class="season-card__cover"
|
||||
data-testid="season-cover"
|
||||
[src]="posterUrl"
|
||||
[alt]="
|
||||
'PORTALS.SEASON_TAB'
|
||||
| translate: { number: selectedSeason() }
|
||||
"
|
||||
loading="lazy"
|
||||
(error)="onSeasonPosterError(posterUrl)"
|
||||
/>
|
||||
}
|
||||
<app-season-tabs
|
||||
[seasonKeys]="sortedSeasonKeys()"
|
||||
[selectedSeason]="selectedSeason()"
|
||||
[episodeCounts]="episodeCounts()"
|
||||
[watchedCounts]="watchedCounts()"
|
||||
[seasonDescription]="selectedSeasonDescription()"
|
||||
[seasonPosters]="seasonPosters()"
|
||||
[playingSeasonKey]="playingSeasonKey()"
|
||||
(seasonSelected)="selectSeason($event)"
|
||||
(backToPlayingRequested)="scrollToPlayingEpisode()"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="content-container">
|
||||
|
||||
@@ -77,9 +77,42 @@
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
}
|
||||
|
||||
// Season card: an optional cover column (--season-cover-width follows
|
||||
// Settings.coverSize: 96 / 120 / 144px, tokens in apps/web/src/_cover-size.scss)
|
||||
// next to the tabs + description. Without a cover the grid is one column and
|
||||
// the tabs render exactly as they did before season posters existed.
|
||||
.season-card {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
margin-bottom: 16px;
|
||||
|
||||
&--with-cover {
|
||||
grid-template-columns: var(--season-cover-width, 120px) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
&__cover {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 2 / 3;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
background: var(--app-widget-bg);
|
||||
box-shadow: var(--poster-shadow, 0 8px 40px rgba(0, 0, 0, 0.5));
|
||||
}
|
||||
}
|
||||
|
||||
app-season-tabs {
|
||||
display: block;
|
||||
margin-bottom: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.season-card--with-cover {
|
||||
grid-template-columns: 88px minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
// Episode Card
|
||||
|
||||
@@ -1185,9 +1185,7 @@ describe('SeasonContainerComponent', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
const button = toggleButton();
|
||||
expect(button?.textContent).toContain(
|
||||
'Mark season as watched (2)'
|
||||
);
|
||||
expect(button?.textContent).toContain('Mark season as watched (2)');
|
||||
button?.click();
|
||||
|
||||
expect(emitted).toEqual([
|
||||
@@ -1277,9 +1275,7 @@ describe('SeasonContainerComponent', () => {
|
||||
|
||||
const button = toggleButton();
|
||||
// 103 is the only markable episode: 101 watched, 102 playing.
|
||||
expect(button?.textContent).toContain(
|
||||
'Mark season as watched (1)'
|
||||
);
|
||||
expect(button?.textContent).toContain('Mark season as watched (1)');
|
||||
button?.click();
|
||||
expect(
|
||||
emitted[0].requests.map((item) => item.contentXtreamId)
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
buildEpisodeInfoDialogData,
|
||||
} from './episode-info-dialog.component';
|
||||
import { formatEpisodePositionText } from './episode-progress.util';
|
||||
import { resolveAutoSelectedSeason } from './season-auto-select.util';
|
||||
import { SeasonDownloadPresenter } from './season-download-presenter';
|
||||
import {
|
||||
type EpisodeViewMode,
|
||||
@@ -95,6 +96,11 @@ export class SeasonContainerComponent implements OnInit {
|
||||
readonly playingEpisodeId = input<number | null>(null);
|
||||
/** Per-season descriptions (TMDB/provider), keyed by season key. */
|
||||
readonly seasonDescriptions = input<Record<string, string> | null>(null);
|
||||
/**
|
||||
* Per-season poster URLs (TMDB season poster, provider season cover),
|
||||
* keyed by season key. Rendered as the season cover beside the tabs.
|
||||
*/
|
||||
readonly seasonPosters = input<Record<string, string> | null>(null);
|
||||
/** True while a host is persisting a season-level watched toggle. */
|
||||
readonly seasonWatchBatchRunning = input(false);
|
||||
/**
|
||||
@@ -201,6 +207,26 @@ export class SeasonContainerComponent implements OnInit {
|
||||
return this.seasonDescriptions()?.[selected] ?? null;
|
||||
});
|
||||
|
||||
/** Poster URLs whose image request failed; the cover column then folds. */
|
||||
private readonly failedSeasonPosters = signal<ReadonlySet<string>>(
|
||||
new Set()
|
||||
);
|
||||
|
||||
/**
|
||||
* The selected season's cover. Withheld for one-season items — that
|
||||
* poster is the show poster again, a few hundred pixels below the hero —
|
||||
* and for a URL whose image failed, so a dead provider link never leaves
|
||||
* a broken-image frame beside the tabs.
|
||||
*/
|
||||
readonly selectedSeasonPosterUrl = computed(() => {
|
||||
const selected = this.selectedSeason();
|
||||
if (!selected || this.sortedSeasonKeys().length < 2) {
|
||||
return null;
|
||||
}
|
||||
const url = this.seasonPosters()?.[selected] ?? null;
|
||||
return url && !this.failedSeasonPosters().has(url) ? url : null;
|
||||
});
|
||||
|
||||
constructor() {
|
||||
this.downloadPresenter.connect({
|
||||
adapter: this.downloadAdapter,
|
||||
@@ -298,6 +324,10 @@ export class SeasonContainerComponent implements OnInit {
|
||||
this.selectedSeason.set(seasonKey);
|
||||
}
|
||||
|
||||
onSeasonPosterError(url: string): void {
|
||||
this.failedSeasonPosters.update((failed) => new Set(failed).add(url));
|
||||
}
|
||||
|
||||
scrollToPlayingEpisode(): void {
|
||||
const playingSeason = this.playingSeasonKey();
|
||||
if (!playingSeason) {
|
||||
@@ -427,66 +457,16 @@ export class SeasonContainerComponent implements OnInit {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Auto-select rules live in season-auto-select.util.ts. */
|
||||
private resolveAutoSeason(): string | undefined {
|
||||
const keys = this.sortedSeasonKeys();
|
||||
if (keys.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const playingSeason = this.playingSeasonKey();
|
||||
if (playingSeason) {
|
||||
return playingSeason;
|
||||
}
|
||||
|
||||
const resumeSeason = this.findMostRecentInProgressSeason();
|
||||
return resumeSeason ?? this.resolveDefaultSeason(keys);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback when nothing is playing or in progress: the earliest season
|
||||
* with unwatched episodes, or — once everything loaded is watched — the
|
||||
* latest non-empty season, where new episodes land (issue #1441).
|
||||
* Loaded-but-empty seasons (a valid Stalker answer) are never picked over
|
||||
* one that has episodes. Stalker lazy-VOD series with unhydrated seasons
|
||||
* keep the first season: their watched state is unknown, so skipping
|
||||
* past them would be a guess.
|
||||
*/
|
||||
private resolveDefaultSeason(keys: readonly string[]): string {
|
||||
if (this.hasUnloadedSeasons()) {
|
||||
return keys[0];
|
||||
}
|
||||
|
||||
const episodeCounts = this.episodeCounts();
|
||||
const watchedCounts = this.watchedCounts();
|
||||
const firstUnwatched = keys.find((key) => {
|
||||
const total = episodeCounts[key] ?? 0;
|
||||
return total > 0 && (watchedCounts[key] ?? 0) < total;
|
||||
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(),
|
||||
});
|
||||
if (firstUnwatched) {
|
||||
return firstUnwatched;
|
||||
}
|
||||
const latestWithEpisodes = [...keys]
|
||||
.reverse()
|
||||
.find((key) => (episodeCounts[key] ?? 0) > 0);
|
||||
return latestWithEpisodes ?? keys[0];
|
||||
}
|
||||
|
||||
private findMostRecentInProgressSeason(): string | null {
|
||||
let bestSeason: string | null = null;
|
||||
let bestUpdatedAt = '';
|
||||
for (const [key, episodes] of Object.entries(this.seasons())) {
|
||||
for (const episode of episodes ?? []) {
|
||||
const position = this.getEpisodePosition(episode);
|
||||
if (!isPortalPlaybackInProgress(position)) {
|
||||
continue;
|
||||
}
|
||||
const updatedAt = position?.updatedAt ?? '';
|
||||
if (updatedAt >= bestUpdatedAt) {
|
||||
bestUpdatedAt = updatedAt;
|
||||
bestSeason = key;
|
||||
}
|
||||
}
|
||||
}
|
||||
return bestSeason;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { SeasonDownloadCoordinator } from '@iptvnator/portal/shared/data-access';
|
||||
import { XtreamSerieEpisode } from '@iptvnator/shared/interfaces';
|
||||
import { DownloadsService } from '@iptvnator/services';
|
||||
import { SeasonContainerComponent } from './season-container.component';
|
||||
|
||||
// Coverage for the season cover beside the season tabs and the season
|
||||
// thumbnails in the >6-seasons dropdown (both fed by the `seasonPosters`
|
||||
// input). Lives beside season-container.component.spec.ts, which sits at the
|
||||
// max-lines cap.
|
||||
|
||||
function createEpisode(
|
||||
overrides: Partial<XtreamSerieEpisode> = {}
|
||||
): XtreamSerieEpisode {
|
||||
return {
|
||||
id: '101',
|
||||
episode_num: 1,
|
||||
title: 'Pilot',
|
||||
container_extension: 'mp4',
|
||||
info: { duration: '45 min' },
|
||||
custom_sid: '',
|
||||
added: '',
|
||||
season: 1,
|
||||
direct_source: '',
|
||||
...overrides,
|
||||
} as XtreamSerieEpisode;
|
||||
}
|
||||
|
||||
describe('SeasonContainerComponent season cover', () => {
|
||||
let fixture: ComponentFixture<SeasonContainerComponent>;
|
||||
let component: SeasonContainerComponent;
|
||||
|
||||
const setRequiredInputs = (
|
||||
seasons: Record<string, XtreamSerieEpisode[]>
|
||||
) => {
|
||||
fixture.componentRef.setInput('seasons', seasons);
|
||||
fixture.componentRef.setInput('seriesId', 20);
|
||||
fixture.componentRef.setInput('playlistId', 'playlist-1');
|
||||
};
|
||||
|
||||
const cover = () =>
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-testid="season-cover"]'
|
||||
) as HTMLImageElement | null;
|
||||
|
||||
const coverColumnRendered = () =>
|
||||
fixture.nativeElement.querySelector('.season-card--with-cover') !==
|
||||
null;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
NoopAnimationsModule,
|
||||
SeasonContainerComponent,
|
||||
TranslateModule.forRoot(),
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: DownloadsService,
|
||||
useValue: {
|
||||
isAvailable: signal(false),
|
||||
hasAuthoritativeDownloadList: signal(false),
|
||||
hasLoadedDownloads: signal(false),
|
||||
downloads: signal([]),
|
||||
},
|
||||
},
|
||||
{ provide: MatDialog, useValue: { open: jest.fn() } },
|
||||
SeasonDownloadCoordinator,
|
||||
{ provide: MatSnackBar, useValue: { open: jest.fn() } },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(SeasonContainerComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it('renders the selected season cover and swaps it with the tab', () => {
|
||||
fixture.componentRef.setInput('seasonPosters', {
|
||||
'1': 'https://img.test/season-1.jpg',
|
||||
'2': 'https://img.test/season-2.jpg',
|
||||
});
|
||||
setRequiredInputs({
|
||||
'1': [createEpisode()],
|
||||
'2': [createEpisode({ id: '201', season: 2 })],
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(cover()?.src).toBe('https://img.test/season-1.jpg');
|
||||
expect(coverColumnRendered()).toBe(true);
|
||||
|
||||
const tabs = fixture.nativeElement.querySelectorAll(
|
||||
'.season-tabs__pill'
|
||||
) as NodeListOf<HTMLButtonElement>;
|
||||
tabs[1].click();
|
||||
fixture.detectChanges();
|
||||
expect(cover()?.src).toBe('https://img.test/season-2.jpg');
|
||||
});
|
||||
|
||||
it('folds the cover column when the image request fails', () => {
|
||||
fixture.componentRef.setInput('seasonPosters', {
|
||||
'1': 'https://img.test/season-1.jpg',
|
||||
'2': 'https://img.test/season-2.jpg',
|
||||
});
|
||||
setRequiredInputs({
|
||||
'1': [createEpisode()],
|
||||
'2': [createEpisode({ id: '201', season: 2 })],
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
// A dead image link folds the cover column instead of leaving a
|
||||
// broken-image frame beside the tabs; the tabs stay in place.
|
||||
cover()?.dispatchEvent(new Event('error'));
|
||||
fixture.detectChanges();
|
||||
expect(cover()).toBeNull();
|
||||
expect(coverColumnRendered()).toBe(false);
|
||||
expect(
|
||||
fixture.nativeElement.querySelectorAll('.season-tabs__pill')
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('withholds the season cover for a one-season item', () => {
|
||||
fixture.componentRef.setInput('seasonPosters', {
|
||||
'1': 'https://img.test/season-1.jpg',
|
||||
});
|
||||
setRequiredInputs({ '1': [createEpisode()] });
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(cover()).toBeNull();
|
||||
expect(coverColumnRendered()).toBe(false);
|
||||
});
|
||||
|
||||
describe('dropdown thumbnails (more than six seasons)', () => {
|
||||
const sevenSeasons = (): Record<string, XtreamSerieEpisode[]> => {
|
||||
const seasons: Record<string, XtreamSerieEpisode[]> = {};
|
||||
for (let index = 1; index <= 7; index++) {
|
||||
seasons[String(index)] = [
|
||||
createEpisode({
|
||||
id: String(100 + index),
|
||||
season: index,
|
||||
}),
|
||||
];
|
||||
}
|
||||
return seasons;
|
||||
};
|
||||
const trigger = () =>
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-testid="season-dropdown"]'
|
||||
) as HTMLButtonElement | null;
|
||||
const triggerThumb = () =>
|
||||
trigger()?.querySelector<HTMLImageElement>(
|
||||
'[data-testid="season-dropdown-thumb"]'
|
||||
) ?? null;
|
||||
const openMenu = (): HTMLButtonElement[] => {
|
||||
trigger()?.click();
|
||||
fixture.detectChanges();
|
||||
return Array.from(
|
||||
document.querySelectorAll<HTMLButtonElement>(
|
||||
'.mat-mdc-menu-panel .mat-mdc-menu-item'
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
afterEach(() => fixture.destroy());
|
||||
|
||||
it('renders a thumbnail per menu row that has a poster and none where a season has no poster', () => {
|
||||
fixture.componentRef.setInput('seasonPosters', {
|
||||
'1': 'https://img.test/season-1.jpg',
|
||||
'3': 'https://img.test/season-3.jpg',
|
||||
});
|
||||
setRequiredInputs(sevenSeasons());
|
||||
fixture.detectChanges();
|
||||
|
||||
// The pill row is untouched: the dropdown replaces it.
|
||||
expect(
|
||||
fixture.nativeElement.querySelectorAll('.season-tabs__pill')
|
||||
).toHaveLength(0);
|
||||
const items = openMenu();
|
||||
expect(items).toHaveLength(7);
|
||||
const thumbOf = (item: HTMLButtonElement) =>
|
||||
item.querySelector<HTMLImageElement>(
|
||||
'[data-testid="season-menu-thumb"]'
|
||||
);
|
||||
expect(thumbOf(items[0])?.src).toBe(
|
||||
'https://img.test/season-1.jpg'
|
||||
);
|
||||
// Projected into the leading slot, ahead of Material's text span.
|
||||
expect(thumbOf(items[0])?.parentElement).toBe(items[0]);
|
||||
expect(thumbOf(items[1])).toBeNull();
|
||||
expect(thumbOf(items[2])?.src).toBe(
|
||||
'https://img.test/season-3.jpg'
|
||||
);
|
||||
// Rows without a poster carry no placeholder either.
|
||||
expect(items[1].querySelector('.season-tabs__thumb')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the selected season poster in the closed trigger and swaps it with the selection', () => {
|
||||
fixture.componentRef.setInput('seasonPosters', {
|
||||
'1': 'https://img.test/season-1.jpg',
|
||||
'2': 'https://img.test/season-2.jpg',
|
||||
});
|
||||
setRequiredInputs(sevenSeasons());
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.selectedSeason()).toBe('1');
|
||||
expect(triggerThumb()?.src).toBe('https://img.test/season-1.jpg');
|
||||
expect(
|
||||
trigger()?.classList.contains(
|
||||
'season-tabs__dropdown--with-thumb'
|
||||
)
|
||||
).toBe(true);
|
||||
|
||||
openMenu()[1].click();
|
||||
fixture.detectChanges();
|
||||
expect(component.selectedSeason()).toBe('2');
|
||||
expect(triggerThumb()?.src).toBe('https://img.test/season-2.jpg');
|
||||
|
||||
// A season without a poster leaves the trigger text-only.
|
||||
openMenu()[2].click();
|
||||
fixture.detectChanges();
|
||||
expect(component.selectedSeason()).toBe('3');
|
||||
expect(triggerThumb()).toBeNull();
|
||||
expect(
|
||||
trigger()?.classList.contains(
|
||||
'season-tabs__dropdown--with-thumb'
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('drops a thumbnail whose image request fails instead of leaving a broken frame', () => {
|
||||
fixture.componentRef.setInput('seasonPosters', {
|
||||
'1': 'https://img.test/season-1.jpg',
|
||||
});
|
||||
setRequiredInputs(sevenSeasons());
|
||||
fixture.detectChanges();
|
||||
|
||||
triggerThumb()?.dispatchEvent(new Event('error'));
|
||||
fixture.detectChanges();
|
||||
expect(triggerThumb()).toBeNull();
|
||||
// The same URL is dropped from the menu row too.
|
||||
expect(
|
||||
openMenu()[0].querySelector('[data-testid="season-menu-thumb"]')
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('withholds the cover when the selected season has no poster', () => {
|
||||
fixture.componentRef.setInput('seasonPosters', {
|
||||
'2': 'https://img.test/season-2.jpg',
|
||||
});
|
||||
setRequiredInputs({
|
||||
'1': [createEpisode()],
|
||||
'2': [createEpisode({ id: '201', season: 2 })],
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.selectedSeason()).toBe('1');
|
||||
expect(cover()).toBeNull();
|
||||
expect(coverColumnRendered()).toBe(false);
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('app-season-tabs')
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -3,9 +3,21 @@
|
||||
<button
|
||||
class="season-tabs__dropdown"
|
||||
data-testid="season-dropdown"
|
||||
[class.season-tabs__dropdown--with-thumb]="selectedPosterUrl()"
|
||||
[matMenuTriggerFor]="seasonMenu"
|
||||
type="button"
|
||||
>
|
||||
@if (selectedPosterUrl(); as posterUrl) {
|
||||
<img
|
||||
class="season-tabs__thumb"
|
||||
data-testid="season-dropdown-thumb"
|
||||
[src]="posterUrl"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
loading="lazy"
|
||||
(error)="onPosterError(posterUrl)"
|
||||
/>
|
||||
}
|
||||
<span>
|
||||
{{
|
||||
'PORTALS.SEASON_TAB'
|
||||
@@ -23,6 +35,20 @@
|
||||
"
|
||||
(click)="selectSeason(seasonKey)"
|
||||
>
|
||||
<!-- Projected into the item's leading slot so the row
|
||||
stays a flex line: thumbnail, name, count, check. -->
|
||||
@if (posterUrlOf(seasonKey); as posterUrl) {
|
||||
<img
|
||||
matMenuItemIcon
|
||||
class="season-tabs__thumb season-tabs__thumb--menu"
|
||||
data-testid="season-menu-thumb"
|
||||
[src]="posterUrl"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
loading="lazy"
|
||||
(error)="onPosterError(posterUrl)"
|
||||
/>
|
||||
}
|
||||
<span>
|
||||
{{
|
||||
'PORTALS.SEASON_TAB'
|
||||
|
||||
@@ -50,6 +50,28 @@
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
|
||||
// The trigger hugs its thumbnail: less inset on the picture side and
|
||||
// vertically, so the pill grows only as much as the 42px poster needs.
|
||||
&--with-thumb {
|
||||
gap: 8px;
|
||||
padding: 4px 10px 4px 4px;
|
||||
}
|
||||
}
|
||||
|
||||
// Season thumbnail in the dropdown (trigger + menu rows). Poster ratio at
|
||||
// 28×42 — small enough to keep the 48px menu row height Material sets.
|
||||
&__thumb {
|
||||
flex: none;
|
||||
width: 28px;
|
||||
height: 42px;
|
||||
object-fit: cover;
|
||||
border-radius: 3px;
|
||||
background: var(--tag-bg, rgba(255, 255, 255, 0.08));
|
||||
|
||||
&--menu {
|
||||
margin-right: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
&__menu-count {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
computed,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
@@ -18,6 +19,15 @@ const MAX_SEASON_PILLS = 6;
|
||||
* for up to 6 seasons, a dropdown beyond that. Shows an optional season
|
||||
* description under the tabs and a "back to playing episode" chip when the
|
||||
* currently playing episode belongs to a different season.
|
||||
*
|
||||
* The dropdown carries season thumbnails (`seasonPosters`): a small poster at
|
||||
* the start of each menu row and, for the selected season, in the closed
|
||||
* trigger — a long menu of "Season 7 … Season 14" rows reads far faster with
|
||||
* a picture per row. The pill row deliberately stays text-only: a thumbnail
|
||||
* per pill turns a compact tab strip into a second poster rail. A season
|
||||
* without a poster simply has no thumbnail (no placeholder tile), and a
|
||||
* poster whose image request fails is dropped rather than left as a
|
||||
* broken-image frame.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-season-tabs',
|
||||
@@ -36,6 +46,13 @@ export class SeasonTabsComponent {
|
||||
readonly seasonDescription = input<string | null>(null);
|
||||
/** Season key of the episode currently playing inline, if any. */
|
||||
readonly playingSeasonKey = input<string | null>(null);
|
||||
/**
|
||||
* Per-season poster URLs keyed by season key, rendered as thumbnails in
|
||||
* the dropdown only (menu rows + the selected season's trigger).
|
||||
*/
|
||||
readonly seasonPosters = input<Readonly<Record<string, string>> | null>(
|
||||
null
|
||||
);
|
||||
|
||||
readonly seasonSelected = output<string>();
|
||||
readonly backToPlayingRequested = output<void>();
|
||||
@@ -49,6 +66,24 @@ export class SeasonTabsComponent {
|
||||
return playing !== null && playing !== this.selectedSeason();
|
||||
});
|
||||
|
||||
/** Poster URLs whose image request failed; their thumbnail is dropped. */
|
||||
private readonly failedPosters = signal<ReadonlySet<string>>(new Set());
|
||||
|
||||
/** Thumbnail shown in the closed dropdown trigger. */
|
||||
readonly selectedPosterUrl = computed(() => {
|
||||
const selected = this.selectedSeason();
|
||||
return selected === undefined ? null : this.posterUrlOf(selected);
|
||||
});
|
||||
|
||||
posterUrlOf(seasonKey: string): string | null {
|
||||
const url = this.seasonPosters()?.[seasonKey];
|
||||
return url && !this.failedPosters().has(url) ? url : null;
|
||||
}
|
||||
|
||||
onPosterError(url: string): void {
|
||||
this.failedPosters.update((failed) => new Set(failed).add(url));
|
||||
}
|
||||
|
||||
isSeasonCompleted(seasonKey: string): boolean {
|
||||
const total = this.episodeCounts()[seasonKey] ?? 0;
|
||||
return total > 0 && (this.watchedCounts()[seasonKey] ?? 0) >= total;
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '@angular/material/slide-toggle';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import {
|
||||
foldSearchText,
|
||||
playlistDisplayLabel,
|
||||
VodSourceDescriptor,
|
||||
VodSourceMatchKind,
|
||||
@@ -111,16 +112,16 @@ export class VodSourcesMenuComponent {
|
||||
|
||||
/** Sources surviving the search box; what "check all" acts on. */
|
||||
private readonly searchedSources = computed(() => {
|
||||
const query = this.filter().trim().toLowerCase();
|
||||
const query = foldSearchText(this.filter().trim());
|
||||
const sources = this.sources();
|
||||
if (!query || !this.showFilter()) {
|
||||
return sources;
|
||||
}
|
||||
|
||||
return sources.filter((source) =>
|
||||
playlistDisplayLabel(source.playlistName, source.playlistId)
|
||||
.toLowerCase()
|
||||
.includes(query)
|
||||
foldSearchText(
|
||||
playlistDisplayLabel(source.playlistName, source.playlistId)
|
||||
).includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
import { Component, input, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
|
||||
@Component({
|
||||
@@ -12,6 +12,8 @@ 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,
|
||||
styles: [
|
||||
`
|
||||
.watched-badge {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
|
||||
// Keep Zone's fakeAsync helpers while preserving the Angular 21 test scheduler.
|
||||
import 'zone.js';
|
||||
import 'zone.js/testing';
|
||||
import { setupZonelessTestEnv } from 'jest-preset-angular/setup-env/zoneless';
|
||||
|
||||
setupZoneTestEnv({
|
||||
setupZonelessTestEnv({
|
||||
errorOnUnknownElements: true,
|
||||
errorOnUnknownProperties: true,
|
||||
});
|
||||
Reference in new issue
Block a user