mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 06:06:18 -08:00
refactor(downloads): extract season download presenter
This commit is contained in:
1 parent
f986c2f0b6
commit
29af7d8674
6 files changed
+510
-385
No files matched your search
@@ -6,23 +6,31 @@
|
||||
</h3>
|
||||
</div>
|
||||
@if (
|
||||
downloadPresentationVisible() ||
|
||||
downloadPresenter.presentationVisible() ||
|
||||
(hasSeasons() && !showSeasonEmptyState())
|
||||
) {
|
||||
<div class="section-header__actions">
|
||||
@if (downloadPresentationVisible()) {
|
||||
@if (downloadPresenter.presentationVisible()) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
class="season-download-button"
|
||||
data-test-id="download-season"
|
||||
[disabled]="seasonDownloadDisabled()"
|
||||
[disabled]="downloadPresenter.seasonDisabled()"
|
||||
[attr.aria-label]="
|
||||
'DOWNLOADS.DOWNLOAD_SEASON_ARIA'
|
||||
| translate: { count: eligibleEpisodeCount() }
|
||||
downloadPresenter.batchRunning()
|
||||
? ('DOWNLOADS.ADDING_TO_QUEUE' | translate)
|
||||
: ('DOWNLOADS.DOWNLOAD_SEASON_ARIA'
|
||||
| translate
|
||||
: {
|
||||
count: downloadPresenter.eligibleEpisodeCount(),
|
||||
})
|
||||
"
|
||||
(click)="downloadSelectedSeason()"
|
||||
[attr.aria-busy]="
|
||||
downloadPresenter.batchRunning() ? 'true' : null
|
||||
"
|
||||
(click)="downloadPresenter.enqueueSeason()"
|
||||
>
|
||||
@if (batchRunning()) {
|
||||
@if (downloadPresenter.batchRunning()) {
|
||||
<mat-spinner
|
||||
diameter="18"
|
||||
aria-hidden="true"
|
||||
@@ -33,7 +41,9 @@
|
||||
{{
|
||||
'DOWNLOADS.DOWNLOAD_SEASON'
|
||||
| translate
|
||||
: { count: eligibleEpisodeCount() }
|
||||
: {
|
||||
count: downloadPresenter.eligibleEpisodeCount(),
|
||||
}
|
||||
}}
|
||||
}
|
||||
</button>
|
||||
@@ -190,9 +200,13 @@
|
||||
}}</mat-icon>
|
||||
</button>
|
||||
<div class="episode-card__actions">
|
||||
@if (downloadPresentationVisible()) {
|
||||
@if (
|
||||
downloadPresenter.presentationVisible()
|
||||
) {
|
||||
@let downloadRow =
|
||||
downloadPresenter.rowFor(episode);
|
||||
@let downloadState =
|
||||
episodeDownloadState(episode);
|
||||
downloadRow.presentation;
|
||||
<button
|
||||
mat-icon-button
|
||||
class="download-btn"
|
||||
@@ -211,24 +225,33 @@
|
||||
'episode-download-' + episode.id
|
||||
"
|
||||
[attr.aria-label]="
|
||||
downloadState.ariaKey
|
||||
| translate
|
||||
: {
|
||||
title: episode.title,
|
||||
}
|
||||
downloadState.action === 'play'
|
||||
? ('DOWNLOADS.PLAY_LOCAL'
|
||||
| translate) +
|
||||
': ' +
|
||||
episode.title
|
||||
: (downloadState.ariaKey
|
||||
| translate
|
||||
: {
|
||||
title: episode.title,
|
||||
})
|
||||
"
|
||||
[matTooltip]="
|
||||
downloadState.ariaKey
|
||||
| translate
|
||||
: {
|
||||
title: episode.title,
|
||||
}
|
||||
downloadState.action === 'play'
|
||||
? ('DOWNLOADS.PLAY_LOCAL'
|
||||
| translate) +
|
||||
': ' +
|
||||
episode.title
|
||||
: (downloadState.ariaKey
|
||||
| translate
|
||||
: {
|
||||
title: episode.title,
|
||||
})
|
||||
"
|
||||
(click)="
|
||||
runEpisodeDownloadAction(
|
||||
downloadPresenter.runEpisodeAction(
|
||||
$event,
|
||||
episode,
|
||||
downloadState
|
||||
episode
|
||||
)
|
||||
"
|
||||
>
|
||||
@@ -426,9 +449,11 @@
|
||||
}}</mat-icon>
|
||||
}
|
||||
</button>
|
||||
@if (downloadPresentationVisible()) {
|
||||
@if (downloadPresenter.presentationVisible()) {
|
||||
@let downloadRow =
|
||||
downloadPresenter.rowFor(episode);
|
||||
@let downloadState =
|
||||
episodeDownloadState(episode);
|
||||
downloadRow.presentation;
|
||||
<button
|
||||
mat-icon-button
|
||||
class="action-btn"
|
||||
@@ -440,24 +465,33 @@
|
||||
'episode-download-' + episode.id
|
||||
"
|
||||
[attr.aria-label]="
|
||||
downloadState.ariaKey
|
||||
| translate
|
||||
: {
|
||||
title: episode.title,
|
||||
}
|
||||
downloadState.action === 'play'
|
||||
? ('DOWNLOADS.PLAY_LOCAL'
|
||||
| translate) +
|
||||
': ' +
|
||||
episode.title
|
||||
: (downloadState.ariaKey
|
||||
| translate
|
||||
: {
|
||||
title: episode.title,
|
||||
})
|
||||
"
|
||||
[matTooltip]="
|
||||
downloadState.ariaKey
|
||||
| translate
|
||||
: {
|
||||
title: episode.title,
|
||||
}
|
||||
downloadState.action === 'play'
|
||||
? ('DOWNLOADS.PLAY_LOCAL'
|
||||
| translate) +
|
||||
': ' +
|
||||
episode.title
|
||||
: (downloadState.ariaKey
|
||||
| translate
|
||||
: {
|
||||
title: episode.title,
|
||||
})
|
||||
"
|
||||
(click)="
|
||||
runEpisodeDownloadAction(
|
||||
downloadPresenter.runEpisodeAction(
|
||||
$event,
|
||||
episode,
|
||||
downloadState
|
||||
episode
|
||||
)
|
||||
"
|
||||
>
|
||||
|
||||
@@ -112,16 +112,8 @@
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 16px;
|
||||
background:
|
||||
linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.06),
|
||||
rgba(255, 255, 255, 0.03)
|
||||
),
|
||||
radial-gradient(
|
||||
circle at top,
|
||||
rgba(255, 255, 255, 0.06),
|
||||
transparent 60%
|
||||
);
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.03)),
|
||||
radial-gradient(circle at top, rgba(255, 255, 255, 0.06), transparent 60%);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.04),
|
||||
0 18px 40px rgba(0, 0, 0, 0.22);
|
||||
@@ -163,16 +155,8 @@
|
||||
&--season {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
background:
|
||||
linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.07),
|
||||
rgba(255, 255, 255, 0.04)
|
||||
),
|
||||
radial-gradient(
|
||||
circle at top,
|
||||
rgba(255, 255, 255, 0.08),
|
||||
transparent 65%
|
||||
);
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.07), rgba(255, 255, 255, 0.04)),
|
||||
radial-gradient(circle at top, rgba(255, 255, 255, 0.08), transparent 65%);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,11 +185,7 @@ app-season-tabs {
|
||||
|
||||
// In-progress state
|
||||
&--in-progress {
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-primary) 60%,
|
||||
transparent
|
||||
);
|
||||
border-color: color-mix(in srgb, var(--mat-sys-primary) 60%, transparent);
|
||||
background: color-mix(in srgb, var(--mat-sys-primary) 5%, transparent);
|
||||
|
||||
&::before {
|
||||
@@ -224,13 +204,8 @@ app-season-tabs {
|
||||
|
||||
&--launching,
|
||||
&--active-external {
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-tertiary) 56%,
|
||||
transparent
|
||||
);
|
||||
box-shadow: 0 0 0 1px
|
||||
color-mix(in srgb, var(--mat-sys-tertiary) 28%, transparent);
|
||||
border-color: color-mix(in srgb, var(--mat-sys-tertiary) 56%, transparent);
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--mat-sys-tertiary) 28%, transparent);
|
||||
}
|
||||
|
||||
&--launching {
|
||||
@@ -241,22 +216,13 @@ app-season-tabs {
|
||||
}
|
||||
|
||||
&--active-external {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-tertiary-container) 18%,
|
||||
transparent
|
||||
);
|
||||
background: color-mix(in srgb, var(--mat-sys-tertiary-container) 18%, transparent);
|
||||
}
|
||||
|
||||
// Playing in the inline player
|
||||
&--playing {
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-primary) 70%,
|
||||
transparent
|
||||
);
|
||||
box-shadow: 0 0 0 2px
|
||||
color-mix(in srgb, var(--mat-sys-primary) 40%, transparent);
|
||||
border-color: color-mix(in srgb, var(--mat-sys-primary) 70%, transparent);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--mat-sys-primary) 40%, transparent);
|
||||
}
|
||||
|
||||
// Watched state — the sole indicator is the watched toggle on the
|
||||
@@ -437,11 +403,7 @@ app-season-tabs {
|
||||
color: var(--mat-sys-tertiary) !important;
|
||||
|
||||
&:hover {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-tertiary) 80%,
|
||||
transparent
|
||||
);
|
||||
background: color-mix(in srgb, var(--mat-sys-tertiary) 80%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -622,24 +584,12 @@ app-season-tabs {
|
||||
}
|
||||
|
||||
&--in-progress {
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-primary) 40%,
|
||||
transparent
|
||||
);
|
||||
border-color: color-mix(in srgb, var(--mat-sys-primary) 40%, transparent);
|
||||
background: color-mix(in srgb, var(--mat-sys-primary) 5%, transparent);
|
||||
|
||||
&:hover {
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-primary) 60%,
|
||||
transparent
|
||||
);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-primary) 8%,
|
||||
transparent
|
||||
);
|
||||
border-color: color-mix(in srgb, var(--mat-sys-primary) 60%, transparent);
|
||||
background: color-mix(in srgb, var(--mat-sys-primary) 8%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -820,11 +770,7 @@ app-season-tabs {
|
||||
color: var(--mat-sys-tertiary);
|
||||
|
||||
&:hover {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-tertiary) 15%,
|
||||
transparent
|
||||
);
|
||||
background: color-mix(in srgb, var(--mat-sys-tertiary) 15%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -838,24 +784,12 @@ app-season-tabs {
|
||||
|
||||
&--launching,
|
||||
&--active-external {
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-tertiary) 46%,
|
||||
transparent
|
||||
);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-tertiary-container) 12%,
|
||||
transparent
|
||||
);
|
||||
border-color: color-mix(in srgb, var(--mat-sys-tertiary) 46%, transparent);
|
||||
background: color-mix(in srgb, var(--mat-sys-tertiary-container) 12%, transparent);
|
||||
}
|
||||
|
||||
&--playing {
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-primary) 60%,
|
||||
transparent
|
||||
);
|
||||
border-color: color-mix(in srgb, var(--mat-sys-primary) 60%, transparent);
|
||||
background: color-mix(in srgb, var(--mat-sys-primary) 8%, transparent);
|
||||
}
|
||||
|
||||
|
||||
@@ -190,6 +190,7 @@ describe('SeasonContainerComponent', () => {
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
localStorage.removeItem('iptvnator_episode_view_mode');
|
||||
downloadsServiceStub = {
|
||||
isAvailable: signal(false),
|
||||
hasLoadedDownloads: signal(false),
|
||||
@@ -239,6 +240,10 @@ describe('SeasonContainerComponent', () => {
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.removeItem('iptvnator_episode_view_mode');
|
||||
});
|
||||
|
||||
it('renders the series-level placeholder when no seasons are available', () => {
|
||||
setRequiredInputs({});
|
||||
fixture.detectChanges();
|
||||
@@ -296,7 +301,9 @@ describe('SeasonContainerComponent', () => {
|
||||
fixture.nativeElement.querySelectorAll('.episode-card').length
|
||||
).toBe(1);
|
||||
expect(
|
||||
fixture.nativeElement.querySelectorAll('.download-btn').length
|
||||
fixture.nativeElement.querySelectorAll(
|
||||
'[data-test-id^="episode-download-"]'
|
||||
).length
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
@@ -648,6 +655,44 @@ describe('SeasonContainerComponent', () => {
|
||||
await fixture.whenStable();
|
||||
});
|
||||
|
||||
it('announces the season queue action as busy until submission refresh settles', async () => {
|
||||
const start = deferred<{ success: boolean }>();
|
||||
const refresh = deferred<void>();
|
||||
downloadsServiceStub.startDownload.mockReturnValue(start.promise);
|
||||
downloadsServiceStub.loadDownloads.mockReturnValue(refresh.promise);
|
||||
enableDownloads();
|
||||
setRequiredInputs({ '1': [createEpisode()] });
|
||||
fixture.detectChanges();
|
||||
|
||||
const seasonButton = () =>
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-test-id="download-season"]'
|
||||
) as HTMLButtonElement;
|
||||
seasonButton().click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(seasonButton().textContent).toContain('Adding to queue');
|
||||
expect(seasonButton().getAttribute('aria-label')).toBe(
|
||||
'Adding to queue'
|
||||
);
|
||||
expect(seasonButton().getAttribute('aria-busy')).toBe('true');
|
||||
|
||||
start.resolve({ success: true });
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
fixture.detectChanges();
|
||||
expect(seasonButton().getAttribute('aria-busy')).toBe('true');
|
||||
|
||||
refresh.resolve(undefined);
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
expect(seasonButton().hasAttribute('aria-busy')).toBe(false);
|
||||
expect(seasonButton().textContent).toContain('Download season (1)');
|
||||
expect(seasonButton().getAttribute('aria-label')).toBe(
|
||||
'Download 1 eligible episodes'
|
||||
);
|
||||
});
|
||||
|
||||
it('hides all download presentation on web, in provider-only mode, or without an adapter', () => {
|
||||
const first = createEpisode();
|
||||
setRequiredInputs({ '1': [first] });
|
||||
@@ -663,7 +708,11 @@ describe('SeasonContainerComponent', () => {
|
||||
downloadsServiceStub.hasLoadedDownloads.set(true);
|
||||
fixture.componentRef.setInput('downloadsEnabled', false);
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('.download-btn')).toBeNull();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-test-id^="episode-download-"]'
|
||||
)
|
||||
).toBeNull();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-test-id="download-season"]'
|
||||
@@ -678,7 +727,11 @@ describe('SeasonContainerComponent', () => {
|
||||
'[data-test-id="download-season"]'
|
||||
)
|
||||
).toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('.download-btn')).toBeNull();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-test-id^="episode-download-"]'
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it.each(['grid', 'list'] as const)(
|
||||
@@ -737,7 +790,7 @@ describe('SeasonContainerComponent', () => {
|
||||
[102, true, 'Adding Queued to queue', 'downloading'],
|
||||
[103, true, 'Adding Active to queue', 'downloading'],
|
||||
[104, false, 'Resume Paused', 'play_arrow'],
|
||||
[105, false, 'Play local', 'folder_open'],
|
||||
[105, false, 'Play local: Local', 'folder_open'],
|
||||
[106, true, 'Download Unknown', 'block'],
|
||||
[107, true, 'Download N/A', 'block'],
|
||||
[108, false, 'Download Failed', 'download'],
|
||||
@@ -760,6 +813,42 @@ describe('SeasonContainerComponent', () => {
|
||||
}
|
||||
);
|
||||
|
||||
it('reuses the selected-season row model across rendering, resume, and local play', async () => {
|
||||
const paused = createEpisode();
|
||||
const local = createEpisode({
|
||||
id: '102',
|
||||
episode_num: 2,
|
||||
title: 'Local',
|
||||
});
|
||||
downloadsServiceStub.downloads.set([
|
||||
createDownload(paused, { id: 71, status: 'paused' }),
|
||||
createDownload(local, {
|
||||
id: 72,
|
||||
status: 'completed',
|
||||
fileAvailability: 'available',
|
||||
filePath: '/authorized/downloads/local.mp4',
|
||||
}),
|
||||
]);
|
||||
const candidateSpy = jest.spyOn(downloadAdapter, 'createCandidate');
|
||||
const coordinator = TestBed.inject(SeasonDownloadCoordinator);
|
||||
const findDownloadSpy = jest.spyOn(coordinator, 'findDownload');
|
||||
enableDownloads();
|
||||
setRequiredInputs({ '1': [paused, local] });
|
||||
|
||||
fixture.detectChanges();
|
||||
fixture.detectChanges();
|
||||
expect(candidateSpy).toHaveBeenCalledTimes(2);
|
||||
expect(findDownloadSpy).toHaveBeenCalledTimes(2);
|
||||
|
||||
episodeAction(101).click();
|
||||
episodeAction(102).click();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(candidateSpy).toHaveBeenCalledTimes(2);
|
||||
expect(findDownloadSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('resumes by managed row id and plays the coordinate-matched local file', async () => {
|
||||
const paused = createEpisode();
|
||||
const local = createEpisode({
|
||||
|
||||
@@ -16,15 +16,9 @@ import { MatButtonToggleModule } from '@angular/material/button-toggle';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import {
|
||||
EPISODE_DOWNLOAD_SUBMISSIONS,
|
||||
SeasonDownloadCoordinator,
|
||||
type EpisodeDownloadCandidate,
|
||||
type SeasonEpisodeDownloadAdapter,
|
||||
} from '@iptvnator/portal/shared/data-access';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { type SeasonEpisodeDownloadAdapter } from '@iptvnator/portal/shared/data-access';
|
||||
import {
|
||||
createLogger,
|
||||
getPortalPlaybackProgressPercent,
|
||||
@@ -36,7 +30,6 @@ import {
|
||||
XtreamSerieEpisode,
|
||||
XtreamSerieEpisodeInfo,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { DownloadsService, type DownloadItem } from '@iptvnator/services';
|
||||
import { ProgressCapsuleComponent } from '../progress-capsule/progress-capsule.component';
|
||||
import {
|
||||
EPISODE_INFO_PLAY,
|
||||
@@ -47,65 +40,12 @@ import {
|
||||
formatEpisodePositionText,
|
||||
parseDuration,
|
||||
} from './episode-progress.util';
|
||||
import { SeasonDownloadPresenter } from './season-download-presenter';
|
||||
import { SeasonTabsComponent } from './season-tabs.component';
|
||||
|
||||
type EpisodeViewMode = 'grid' | 'list';
|
||||
const EPISODE_VIEW_MODE_KEY = 'iptvnator_episode_view_mode';
|
||||
|
||||
const EPISODE_DOWNLOAD_ACTIONS = {
|
||||
Blocked: 'blocked',
|
||||
Download: 'download',
|
||||
Pending: 'pending',
|
||||
Play: 'play',
|
||||
Resume: 'resume',
|
||||
} as const;
|
||||
|
||||
type EpisodeDownloadAction =
|
||||
(typeof EPISODE_DOWNLOAD_ACTIONS)[keyof typeof EPISODE_DOWNLOAD_ACTIONS];
|
||||
|
||||
interface EpisodeDownloadState {
|
||||
readonly action: EpisodeDownloadAction;
|
||||
readonly ariaKey: string;
|
||||
readonly disabled: boolean;
|
||||
readonly icon: string;
|
||||
}
|
||||
|
||||
const EPISODE_DOWNLOAD_STATES: Record<
|
||||
EpisodeDownloadAction,
|
||||
EpisodeDownloadState
|
||||
> = {
|
||||
blocked: {
|
||||
action: EPISODE_DOWNLOAD_ACTIONS.Blocked,
|
||||
ariaKey: 'DOWNLOADS.EPISODE_DOWNLOAD_ARIA',
|
||||
disabled: true,
|
||||
icon: 'block',
|
||||
},
|
||||
download: {
|
||||
action: EPISODE_DOWNLOAD_ACTIONS.Download,
|
||||
ariaKey: 'DOWNLOADS.EPISODE_DOWNLOAD_ARIA',
|
||||
disabled: false,
|
||||
icon: 'download',
|
||||
},
|
||||
pending: {
|
||||
action: EPISODE_DOWNLOAD_ACTIONS.Pending,
|
||||
ariaKey: 'DOWNLOADS.EPISODE_DOWNLOAD_PENDING_ARIA',
|
||||
disabled: true,
|
||||
icon: 'downloading',
|
||||
},
|
||||
play: {
|
||||
action: EPISODE_DOWNLOAD_ACTIONS.Play,
|
||||
ariaKey: 'DOWNLOADS.PLAY_LOCAL',
|
||||
disabled: false,
|
||||
icon: 'folder_open',
|
||||
},
|
||||
resume: {
|
||||
action: EPISODE_DOWNLOAD_ACTIONS.Resume,
|
||||
ariaKey: 'DOWNLOADS.ARIA.RESUME',
|
||||
disabled: false,
|
||||
icon: 'play_arrow',
|
||||
},
|
||||
};
|
||||
|
||||
export interface SeasonContainerPlaybackToggleRequest {
|
||||
contentXtreamId: number;
|
||||
nextPosition: PlaybackPositionData | null;
|
||||
@@ -116,6 +56,7 @@ export interface SeasonContainerPlaybackToggleRequest {
|
||||
templateUrl: './season-container.component.html',
|
||||
styleUrls: ['./season-container.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
providers: [SeasonDownloadPresenter],
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
MatButtonToggleModule,
|
||||
@@ -128,14 +69,11 @@ export interface SeasonContainerPlaybackToggleRequest {
|
||||
],
|
||||
})
|
||||
export class SeasonContainerComponent implements OnInit {
|
||||
private readonly downloadsService = inject(DownloadsService);
|
||||
private readonly downloadCoordinator = inject(SeasonDownloadCoordinator);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly logger = createLogger('SeasonContainer');
|
||||
private lastEmittedSeason: string | undefined;
|
||||
readonly downloadPresenter = inject(SeasonDownloadPresenter);
|
||||
|
||||
readonly seasons = input.required<Record<string, XtreamSerieEpisode[]>>();
|
||||
readonly seriesId = input.required<number>();
|
||||
@@ -160,46 +98,6 @@ export class SeasonContainerComponent implements OnInit {
|
||||
output<SeasonContainerPlaybackToggleRequest>();
|
||||
readonly seasonSelected = output<string>();
|
||||
readonly viewMode = signal<EpisodeViewMode>('grid');
|
||||
readonly batchRunning = signal(false);
|
||||
|
||||
readonly downloadPresentationVisible = computed(
|
||||
() =>
|
||||
this.downloadsService.isAvailable() &&
|
||||
this.downloadsEnabled() &&
|
||||
this.downloadAdapter() !== null
|
||||
);
|
||||
|
||||
readonly eligibleEpisodeCount = computed(() => {
|
||||
const adapter = this.downloadAdapter();
|
||||
const seasonKey = this.selectedSeason();
|
||||
if (
|
||||
!adapter ||
|
||||
!seasonKey ||
|
||||
!this.downloadsService.hasLoadedDownloads()
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return this.selectedSeasonEpisodes().reduce((count, episode) => {
|
||||
const candidate = adapter.createCandidate(episode, seasonKey);
|
||||
return (
|
||||
count +
|
||||
(candidate && this.downloadCoordinator.isEligible(candidate)
|
||||
? 1
|
||||
: 0)
|
||||
);
|
||||
}, 0);
|
||||
});
|
||||
|
||||
readonly seasonDownloadDisabled = computed(
|
||||
() =>
|
||||
this.isLoading() ||
|
||||
this.batchRunning() ||
|
||||
!this.downloadsService.hasLoadedDownloads() ||
|
||||
!this.selectedSeason() ||
|
||||
this.selectedSeasonEpisodes().length === 0 ||
|
||||
this.eligibleEpisodeCount() === 0
|
||||
);
|
||||
|
||||
readonly sortedSeasonKeys = computed(() =>
|
||||
Object.keys(this.seasons()).sort((a, b) => Number(a) - Number(b))
|
||||
@@ -238,6 +136,11 @@ export class SeasonContainerComponent implements OnInit {
|
||||
*/
|
||||
readonly selectedSeason = signal<string | undefined>(undefined);
|
||||
|
||||
readonly selectedSeasonEpisodes = computed(() => {
|
||||
const selected = this.selectedSeason();
|
||||
return selected ? (this.seasons()[selected] ?? []) : [];
|
||||
});
|
||||
|
||||
private readonly autoSelectKey = computed(
|
||||
() =>
|
||||
`${this.sortedSeasonKeys().join('|')}::${
|
||||
@@ -272,6 +175,14 @@ export class SeasonContainerComponent implements OnInit {
|
||||
});
|
||||
|
||||
constructor() {
|
||||
this.downloadPresenter.connect({
|
||||
adapter: this.downloadAdapter,
|
||||
downloadsEnabled: this.downloadsEnabled,
|
||||
isLoading: this.isLoading,
|
||||
selectedEpisodes: this.selectedSeasonEpisodes,
|
||||
selectedSeason: this.selectedSeason,
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const key = this.autoSelectKey();
|
||||
if (key === this.lastAutoSelectKey) {
|
||||
@@ -322,11 +233,6 @@ export class SeasonContainerComponent implements OnInit {
|
||||
);
|
||||
}
|
||||
|
||||
selectedSeasonEpisodes(): XtreamSerieEpisode[] {
|
||||
const selected = this.selectedSeason();
|
||||
return selected ? (this.seasons()[selected] ?? []) : [];
|
||||
}
|
||||
|
||||
selectSeason(seasonKey: string) {
|
||||
this.selectedSeason.set(seasonKey);
|
||||
}
|
||||
@@ -451,152 +357,6 @@ export class SeasonContainerComponent implements OnInit {
|
||||
return Number(episode.id);
|
||||
}
|
||||
|
||||
candidateFor(episode: XtreamSerieEpisode): EpisodeDownloadCandidate | null {
|
||||
return (
|
||||
this.downloadAdapter()?.createCandidate(
|
||||
episode,
|
||||
this.selectedSeason()
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
downloadFor(episode: XtreamSerieEpisode): DownloadItem | undefined {
|
||||
const candidate = this.candidateFor(episode);
|
||||
return candidate
|
||||
? this.downloadCoordinator.findDownload(candidate.identity)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
episodeDownloadState(episode: XtreamSerieEpisode): EpisodeDownloadState {
|
||||
const candidate = this.candidateFor(episode);
|
||||
if (!candidate || !this.downloadsService.hasLoadedDownloads()) {
|
||||
return this.downloadState(EPISODE_DOWNLOAD_ACTIONS.Blocked);
|
||||
}
|
||||
if (this.downloadCoordinator.isPending(candidate.identity)) {
|
||||
return this.downloadState(EPISODE_DOWNLOAD_ACTIONS.Pending);
|
||||
}
|
||||
|
||||
const download = this.downloadCoordinator.findDownload(
|
||||
candidate.identity
|
||||
);
|
||||
if (
|
||||
download?.status === 'queued' ||
|
||||
download?.status === 'downloading'
|
||||
) {
|
||||
return this.downloadState(EPISODE_DOWNLOAD_ACTIONS.Pending);
|
||||
}
|
||||
if (download?.status === 'paused') {
|
||||
return this.downloadState(EPISODE_DOWNLOAD_ACTIONS.Resume);
|
||||
}
|
||||
if (download?.status === 'completed') {
|
||||
if (
|
||||
download.fileAvailability === 'available' &&
|
||||
download.filePath
|
||||
) {
|
||||
return this.downloadState(EPISODE_DOWNLOAD_ACTIONS.Play);
|
||||
}
|
||||
return this.downloadState(
|
||||
download.fileAvailability === 'missing'
|
||||
? EPISODE_DOWNLOAD_ACTIONS.Download
|
||||
: EPISODE_DOWNLOAD_ACTIONS.Blocked
|
||||
);
|
||||
}
|
||||
|
||||
return this.downloadState(EPISODE_DOWNLOAD_ACTIONS.Download);
|
||||
}
|
||||
|
||||
runEpisodeDownloadAction(
|
||||
event: Event,
|
||||
episode: XtreamSerieEpisode,
|
||||
state: EpisodeDownloadState
|
||||
): void {
|
||||
event.stopPropagation();
|
||||
if (state.disabled) {
|
||||
return;
|
||||
}
|
||||
if (state.action === EPISODE_DOWNLOAD_ACTIONS.Resume) {
|
||||
void this.resumeEpisodeDownload(episode);
|
||||
} else if (state.action === EPISODE_DOWNLOAD_ACTIONS.Play) {
|
||||
void this.playFromLocal(episode);
|
||||
} else if (state.action === EPISODE_DOWNLOAD_ACTIONS.Download) {
|
||||
void this.downloadEpisode(episode);
|
||||
}
|
||||
}
|
||||
|
||||
async downloadEpisode(episode: XtreamSerieEpisode): Promise<void> {
|
||||
if (!this.downloadPresentationVisible()) {
|
||||
return;
|
||||
}
|
||||
const candidate = this.candidateFor(episode);
|
||||
if (!candidate) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.downloadCoordinator.enqueueOne(candidate);
|
||||
if (result === EPISODE_DOWNLOAD_SUBMISSIONS.Failed) {
|
||||
this.snackBar.open(
|
||||
this.translate.instant('DOWNLOADS.EPISODE_DOWNLOAD_FAILED'),
|
||||
undefined,
|
||||
{ duration: 5000 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async downloadSelectedSeason(): Promise<void> {
|
||||
const adapter = this.downloadAdapter();
|
||||
const seasonKey = this.selectedSeason();
|
||||
if (
|
||||
this.batchRunning() ||
|
||||
this.seasonDownloadDisabled() ||
|
||||
!this.downloadPresentationVisible() ||
|
||||
!adapter ||
|
||||
!seasonKey
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const episodes = [...this.selectedSeasonEpisodes()];
|
||||
const candidates = episodes.map((episode) =>
|
||||
adapter.createCandidate(episode, seasonKey)
|
||||
);
|
||||
this.batchRunning.set(true);
|
||||
try {
|
||||
const result =
|
||||
await this.downloadCoordinator.enqueueSeason(candidates);
|
||||
const key =
|
||||
result.failed > 0
|
||||
? 'DOWNLOADS.SEASON_QUEUE_RESULT_WITH_FAILURES'
|
||||
: 'DOWNLOADS.SEASON_QUEUE_RESULT';
|
||||
this.snackBar.open(this.translate.instant(key, result), undefined, {
|
||||
duration: 5000,
|
||||
});
|
||||
} finally {
|
||||
this.batchRunning.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async resumeEpisodeDownload(episode: XtreamSerieEpisode): Promise<void> {
|
||||
const download = this.downloadFor(episode);
|
||||
if (download?.status === 'paused') {
|
||||
await this.downloadsService.resumeDownload(download.id);
|
||||
}
|
||||
}
|
||||
|
||||
async playFromLocal(episode: XtreamSerieEpisode): Promise<void> {
|
||||
const download = this.downloadFor(episode);
|
||||
if (
|
||||
download?.status === 'completed' &&
|
||||
download.fileAvailability === 'available' &&
|
||||
download.filePath
|
||||
) {
|
||||
await this.downloadsService.playDownload(download.filePath);
|
||||
}
|
||||
}
|
||||
|
||||
private downloadState(action: EpisodeDownloadAction): EpisodeDownloadState {
|
||||
return EPISODE_DOWNLOAD_STATES[action];
|
||||
}
|
||||
|
||||
private getEpisodePosition(
|
||||
episode: XtreamSerieEpisode
|
||||
): PlaybackPositionData | undefined {
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
import {
|
||||
Injectable,
|
||||
type Signal,
|
||||
computed,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import {
|
||||
EPISODE_DOWNLOAD_SUBMISSIONS,
|
||||
SeasonDownloadCoordinator,
|
||||
type EpisodeDownloadCandidate,
|
||||
type SeasonEpisodeDownloadAdapter,
|
||||
} from '@iptvnator/portal/shared/data-access';
|
||||
import { isEpisodeDownloadEligible } from '@iptvnator/portal/shared/util';
|
||||
import { XtreamSerieEpisode } from '@iptvnator/shared/interfaces';
|
||||
import { DownloadsService, type DownloadItem } from '@iptvnator/services';
|
||||
|
||||
const EPISODE_DOWNLOAD_ACTIONS = {
|
||||
Blocked: 'blocked',
|
||||
Download: 'download',
|
||||
Pending: 'pending',
|
||||
Play: 'play',
|
||||
Resume: 'resume',
|
||||
} as const;
|
||||
|
||||
type EpisodeDownloadAction =
|
||||
(typeof EPISODE_DOWNLOAD_ACTIONS)[keyof typeof EPISODE_DOWNLOAD_ACTIONS];
|
||||
|
||||
export interface EpisodeDownloadPresentation {
|
||||
readonly action: EpisodeDownloadAction;
|
||||
readonly ariaKey: string;
|
||||
readonly disabled: boolean;
|
||||
readonly icon: string;
|
||||
}
|
||||
|
||||
export interface SeasonEpisodeDownloadRow {
|
||||
readonly candidate: EpisodeDownloadCandidate | null;
|
||||
readonly download: DownloadItem | undefined;
|
||||
readonly eligible: boolean;
|
||||
readonly episode: XtreamSerieEpisode;
|
||||
readonly presentation: EpisodeDownloadPresentation;
|
||||
}
|
||||
|
||||
export interface SeasonDownloadPresenterSources {
|
||||
readonly adapter: Signal<SeasonEpisodeDownloadAdapter | null>;
|
||||
readonly downloadsEnabled: Signal<boolean>;
|
||||
readonly isLoading: Signal<boolean>;
|
||||
readonly selectedEpisodes: Signal<readonly XtreamSerieEpisode[]>;
|
||||
readonly selectedSeason: Signal<string | undefined>;
|
||||
}
|
||||
|
||||
const EPISODE_DOWNLOAD_STATES: Record<
|
||||
EpisodeDownloadAction,
|
||||
EpisodeDownloadPresentation
|
||||
> = {
|
||||
blocked: {
|
||||
action: EPISODE_DOWNLOAD_ACTIONS.Blocked,
|
||||
ariaKey: 'DOWNLOADS.EPISODE_DOWNLOAD_ARIA',
|
||||
disabled: true,
|
||||
icon: 'block',
|
||||
},
|
||||
download: {
|
||||
action: EPISODE_DOWNLOAD_ACTIONS.Download,
|
||||
ariaKey: 'DOWNLOADS.EPISODE_DOWNLOAD_ARIA',
|
||||
disabled: false,
|
||||
icon: 'download',
|
||||
},
|
||||
pending: {
|
||||
action: EPISODE_DOWNLOAD_ACTIONS.Pending,
|
||||
ariaKey: 'DOWNLOADS.EPISODE_DOWNLOAD_PENDING_ARIA',
|
||||
disabled: true,
|
||||
icon: 'downloading',
|
||||
},
|
||||
play: {
|
||||
action: EPISODE_DOWNLOAD_ACTIONS.Play,
|
||||
ariaKey: 'DOWNLOADS.PLAY_LOCAL',
|
||||
disabled: false,
|
||||
icon: 'folder_open',
|
||||
},
|
||||
resume: {
|
||||
action: EPISODE_DOWNLOAD_ACTIONS.Resume,
|
||||
ariaKey: 'DOWNLOADS.ARIA.RESUME',
|
||||
disabled: false,
|
||||
icon: 'play_arrow',
|
||||
},
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class SeasonDownloadPresenter {
|
||||
private readonly coordinator = inject(SeasonDownloadCoordinator);
|
||||
private readonly downloadsService = inject(DownloadsService);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly sources = signal<SeasonDownloadPresenterSources | null>(
|
||||
null
|
||||
);
|
||||
private readonly batchInFlight = signal(false);
|
||||
|
||||
readonly batchRunning = this.batchInFlight.asReadonly();
|
||||
readonly presentationVisible = computed(() => {
|
||||
const sources = this.sources();
|
||||
return Boolean(
|
||||
sources &&
|
||||
this.downloadsService.isAvailable() &&
|
||||
sources.downloadsEnabled() &&
|
||||
sources.adapter()
|
||||
);
|
||||
});
|
||||
|
||||
readonly rows = computed<readonly SeasonEpisodeDownloadRow[]>(() => {
|
||||
const sources = this.sources();
|
||||
if (!sources) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const adapter = sources.adapter();
|
||||
const seasonKey = sources.selectedSeason();
|
||||
const downloadsAvailable = this.downloadsService.isAvailable();
|
||||
const downloadsLoaded = this.downloadsService.hasLoadedDownloads();
|
||||
this.downloadsService.downloads();
|
||||
|
||||
return sources.selectedEpisodes().map((episode) => {
|
||||
const candidate =
|
||||
adapter && seasonKey
|
||||
? adapter.createCandidate(episode, seasonKey)
|
||||
: null;
|
||||
const pending = candidate
|
||||
? this.coordinator.isPending(candidate.identity)
|
||||
: false;
|
||||
const download = candidate
|
||||
? this.coordinator.findDownload(candidate.identity)
|
||||
: undefined;
|
||||
return {
|
||||
candidate,
|
||||
download,
|
||||
eligible: Boolean(
|
||||
candidate &&
|
||||
downloadsAvailable &&
|
||||
downloadsLoaded &&
|
||||
!pending &&
|
||||
isEpisodeDownloadEligible(download)
|
||||
),
|
||||
episode,
|
||||
presentation: this.createPresentation(
|
||||
candidate,
|
||||
download,
|
||||
pending,
|
||||
downloadsLoaded
|
||||
),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
private readonly rowByEpisode = computed(
|
||||
() => new Map(this.rows().map((row) => [row.episode, row] as const))
|
||||
);
|
||||
|
||||
readonly eligibleEpisodeCount = computed(
|
||||
() => this.rows().filter((row) => row.eligible).length
|
||||
);
|
||||
|
||||
readonly seasonDisabled = computed(() => {
|
||||
const sources = this.sources();
|
||||
return (
|
||||
!sources ||
|
||||
sources.isLoading() ||
|
||||
this.batchRunning() ||
|
||||
!this.downloadsService.hasLoadedDownloads() ||
|
||||
!sources.selectedSeason() ||
|
||||
this.rows().length === 0 ||
|
||||
this.eligibleEpisodeCount() === 0
|
||||
);
|
||||
});
|
||||
|
||||
connect(sources: SeasonDownloadPresenterSources): void {
|
||||
if (this.sources()) {
|
||||
throw new Error('SeasonDownloadPresenter is already connected');
|
||||
}
|
||||
this.sources.set(sources);
|
||||
}
|
||||
|
||||
rowFor(episode: XtreamSerieEpisode): SeasonEpisodeDownloadRow {
|
||||
const row = this.rowByEpisode().get(episode);
|
||||
if (!row) {
|
||||
throw new Error('Episode is outside the selected-season row model');
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
runEpisodeAction(event: Event, episode: XtreamSerieEpisode): void {
|
||||
event.stopPropagation();
|
||||
const row = this.rowByEpisode().get(episode);
|
||||
if (!row || row.presentation.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (row.presentation.action === EPISODE_DOWNLOAD_ACTIONS.Resume) {
|
||||
void this.resume(row);
|
||||
} else if (row.presentation.action === EPISODE_DOWNLOAD_ACTIONS.Play) {
|
||||
void this.play(row);
|
||||
} else if (
|
||||
row.presentation.action === EPISODE_DOWNLOAD_ACTIONS.Download
|
||||
) {
|
||||
void this.enqueueOne(episode);
|
||||
}
|
||||
}
|
||||
|
||||
async enqueueOne(episode: XtreamSerieEpisode): Promise<void> {
|
||||
if (!this.presentationVisible()) {
|
||||
return;
|
||||
}
|
||||
const row = this.rowByEpisode().get(episode);
|
||||
if (!row?.candidate || !row.eligible) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.coordinator.enqueueOne(row.candidate);
|
||||
if (result === EPISODE_DOWNLOAD_SUBMISSIONS.Failed) {
|
||||
this.notify('DOWNLOADS.EPISODE_DOWNLOAD_FAILED');
|
||||
}
|
||||
}
|
||||
|
||||
async enqueueSeason(): Promise<void> {
|
||||
if (
|
||||
this.batchRunning() ||
|
||||
this.seasonDisabled() ||
|
||||
!this.presentationVisible()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const candidates = this.rows().map((row) => row.candidate);
|
||||
this.batchInFlight.set(true);
|
||||
try {
|
||||
const result = await this.coordinator.enqueueSeason(candidates);
|
||||
this.notify(
|
||||
result.failed > 0
|
||||
? 'DOWNLOADS.SEASON_QUEUE_RESULT_WITH_FAILURES'
|
||||
: 'DOWNLOADS.SEASON_QUEUE_RESULT',
|
||||
result
|
||||
);
|
||||
} finally {
|
||||
this.batchInFlight.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async resume(row: SeasonEpisodeDownloadRow): Promise<void> {
|
||||
const download = row.download;
|
||||
if (
|
||||
download?.status === 'paused' &&
|
||||
Number.isSafeInteger(download.id) &&
|
||||
download.id > 0
|
||||
) {
|
||||
await this.downloadsService.resumeDownload(download.id);
|
||||
}
|
||||
}
|
||||
|
||||
private async play(row: SeasonEpisodeDownloadRow): Promise<void> {
|
||||
const download = row.download;
|
||||
if (
|
||||
download?.status === 'completed' &&
|
||||
download.fileAvailability === 'available' &&
|
||||
download.filePath
|
||||
) {
|
||||
await this.downloadsService.playDownload(download.filePath);
|
||||
}
|
||||
}
|
||||
|
||||
private notify(key: string, params?: object): void {
|
||||
this.snackBar.open(this.translate.instant(key, params), undefined, {
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
private createPresentation(
|
||||
candidate: EpisodeDownloadCandidate | null,
|
||||
download: DownloadItem | undefined,
|
||||
pending: boolean,
|
||||
downloadsLoaded: boolean
|
||||
): EpisodeDownloadPresentation {
|
||||
if (!candidate || !downloadsLoaded) {
|
||||
return EPISODE_DOWNLOAD_STATES.blocked;
|
||||
}
|
||||
if (
|
||||
pending ||
|
||||
download?.status === 'queued' ||
|
||||
download?.status === 'downloading'
|
||||
) {
|
||||
return EPISODE_DOWNLOAD_STATES.pending;
|
||||
}
|
||||
if (download?.status === 'paused') {
|
||||
return EPISODE_DOWNLOAD_STATES.resume;
|
||||
}
|
||||
if (download?.status === 'completed') {
|
||||
if (
|
||||
download.fileAvailability === 'available' &&
|
||||
download.filePath
|
||||
) {
|
||||
return EPISODE_DOWNLOAD_STATES.play;
|
||||
}
|
||||
return download.fileAvailability === 'missing'
|
||||
? EPISODE_DOWNLOAD_STATES.download
|
||||
: EPISODE_DOWNLOAD_STATES.blocked;
|
||||
}
|
||||
return EPISODE_DOWNLOAD_STATES.download;
|
||||
}
|
||||
}
|
||||
@@ -64,7 +64,6 @@ export const maxLinesBaseline = [
|
||||
'libs/shared/interfaces/src/lib/electron-api.interface.ts',
|
||||
'libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts',
|
||||
'libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts',
|
||||
'libs/ui/components/src/lib/season-container/season-container.component.ts',
|
||||
'libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts',
|
||||
'libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts',
|
||||
'libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts',
|
||||
|
||||
Reference in new issue
Block a user