chore(web): enable strict TypeScript mode

This commit is contained in:
4gray committed 2026-05-22 02:58:12 +03:00
1 parent ff9c13058c
commit 4ab8915483
55 files changed
+675 -369

No files matched your search

+9 -7
View File
@@ -49,14 +49,16 @@ export class AppComponent implements OnInit {
private readonly DEFAULT_LANG = Language.ENGLISH;
constructor() {
const electronProcess = this.dataService.remote?.process;
if (
((this.dataService.isElectron &&
this.dataService?.remote?.process.platform === 'linux') ||
this.dataService?.remote?.process.platform === 'win32') &&
this.dataService.remote.process.argv.length > 2
this.dataService.isElectron &&
electronProcess &&
(electronProcess.platform === 'linux' ||
electronProcess.platform === 'win32') &&
electronProcess.argv.length > 2
) {
const filePath = this.dataService.remote.process.argv.find(
(filepath) =>
const filePath = electronProcess.argv.find(
(filepath: string) =>
filepath.endsWith('.m3u') || filepath.endsWith('.m3u8')
);
if (filePath) {
@@ -102,7 +104,7 @@ export class AppComponent implements OnInit {
*/
initSettings(): void {
this.settingsService
.getValueFromLocalStorage(STORE_KEY.Settings)
.getValueFromLocalStorage<Settings>(STORE_KEY.Settings)
.subscribe((settings: Settings) => {
if (settings && Object.keys(settings).length > 0) {
// No need to send settings to Electron on init
@@ -2,9 +2,11 @@ import { Component, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces';
import { EmbeddedMpvOverlayVisibilityService } from '../../../../libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-overlay-visibility.service';
import { EmbeddedMpvPlayerComponent } from '../../../../libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component';
import { EmbeddedMpvSessionController } from '../../../../libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller';
import {
EmbeddedMpvOverlayVisibilityService,
EmbeddedMpvPlayerComponent,
EmbeddedMpvSessionController,
} from '@iptvnator/ui/playback/embedded-mpv-player';
@Component({
imports: [EmbeddedMpvPlayerComponent],
@@ -117,7 +117,7 @@ export class ElectronService extends DataService {
payload?: unknown
): Promise<T> {
if (type === PLAYLIST_PARSE_BY_URL) {
this.fetchM3uPlaylistFromUrl(payload);
this.fetchM3uPlaylistFromUrl(payload as Partial<Playlist>);
return undefined as T;
}
@@ -156,7 +156,7 @@ export class ElectronService extends DataService {
data.url,
data.title ?? '',
data.thumbnail ?? '',
data['user-agent'] ?? undefined,
data['user-agent'] ?? '',
data.referer ?? undefined,
data.origin ?? undefined,
data.contentInfo,
@@ -185,7 +185,7 @@ export class ElectronService extends DataService {
data.url,
data.title ?? '',
data.thumbnail ?? '',
data['user-agent'] ?? undefined,
data['user-agent'] ?? '',
data.referer ?? undefined,
data.origin ?? undefined,
data.contentInfo,
@@ -219,7 +219,7 @@ export class ElectronService extends DataService {
this.translateService.instant(
'HOME.PLAYLISTS.AUTO_REFRESH_UPDATE_SUCCESS'
),
null,
undefined,
{ duration: 2000 }
);
return playlists as T;
@@ -266,6 +266,10 @@ export class ElectronService extends DataService {
}
private async fetchM3uPlaylistFromUrl(payload: Partial<Playlist>) {
if (!payload.url) {
return;
}
const title = payload.title?.trim() || undefined;
window.electron
@@ -353,7 +357,7 @@ export class ElectronService extends DataService {
this.translateService.instant(
'HOME.PLAYLISTS.PLAYLIST_UPDATE_SUCCESS'
),
null,
undefined,
{ duration: 2000 }
);
} catch (error: unknown) {
+28 -9
View File
@@ -5,7 +5,14 @@ import { SwUpdate } from '@angular/service-worker';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { PlaylistActions } from '@iptvnator/m3u-state';
import { catchError, firstValueFrom, from, switchMap, throwError } from 'rxjs';
import {
catchError,
firstValueFrom,
from,
Observable,
switchMap,
throwError,
} from 'rxjs';
import { DataService } from '@iptvnator/services';
import {
ERROR,
@@ -108,12 +115,12 @@ export class PwaService extends DataService {
*/
sendIpcEvent<T = unknown>(type: string, payload?: unknown): T {
if (type === PLAYLIST_PARSE_BY_URL) {
this.fetchFromUrl(payload);
this.fetchFromUrl(payload as Partial<Playlist>);
return undefined as T;
}
if (type === PLAYLIST_UPDATE) {
this.refreshPlaylist(payload);
this.refreshPlaylist(payload as Partial<Playlist & { id: string }>);
return undefined as T;
}
@@ -138,6 +145,12 @@ export class PwaService extends DataService {
}
refreshPlaylist(payload: Partial<Playlist & { id: string }>) {
if (!payload.url || !payload.id) {
return;
}
const playlistId = payload.id;
this.getPlaylistFromUrl(payload.url)
.pipe(
catchError((error) => {
@@ -155,7 +168,7 @@ export class PwaService extends DataService {
this.store.dispatch(
PlaylistActions.updatePlaylist({
playlist,
playlistId: payload.id,
playlistId,
})
);
@@ -163,7 +176,7 @@ export class PwaService extends DataService {
this.translateService.instant(
'HOME.PLAYLISTS.PLAYLIST_UPDATE_SUCCESS'
),
null,
undefined,
{ duration: 2000 }
);
});
@@ -193,13 +206,19 @@ export class PwaService extends DataService {
* @param payload playlist payload
*/
fetchFromUrl(payload: Partial<Playlist>): void {
if (!payload.url) {
return;
}
const title = payload.title?.trim() || undefined;
this.getPlaylistFromUrl(payload.url)
.pipe(
catchError((error) => {
this.snackBar.open(
this.getErrorMessageByStatusCode(error.status),
this.getErrorMessageByStatusCode(
this.extractHttpStatusCode(error)
),
'Close',
{
duration: 5000,
@@ -226,7 +245,7 @@ export class PwaService extends DataService {
});
}
getErrorMessageByStatusCode(status: number) {
getErrorMessageByStatusCode(status: number | null | undefined) {
let messageKey = 'HOME.URL_UPLOAD.ERROR_FETCH_FAILED';
switch (status) {
case 413:
@@ -524,10 +543,10 @@ export class PwaService extends DataService {
}
}
getPlaylistFromUrl(url: string) {
getPlaylistFromUrl(url: string): Observable<Playlist> {
return from(this.getProviderTargetId(url)).pipe(
switchMap((targetId) =>
this.http.get(`${this.corsProxyUrl}/parse`, {
this.http.get<Playlist>(`${this.corsProxyUrl}/parse`, {
params: { targetId },
})
)
+10 -10
View File
@@ -115,7 +115,10 @@ export class SettingsService {
const mediaQuery = this.systemThemeMediaQuery as LegacyMediaQueryList;
if (mediaQuery.addEventListener) {
mediaQuery.addEventListener('change', this.systemThemeChangeHandler);
mediaQuery.addEventListener(
'change',
this.systemThemeChangeHandler
);
} else {
mediaQuery.addListener?.(this.systemThemeChangeHandler);
}
@@ -147,8 +150,8 @@ export class SettingsService {
* @param key key to get
* @returns returns the value of the given key
*/
getValueFromLocalStorage(key: STORE_KEY) {
return this.storage.get(key);
getValueFromLocalStorage<T = unknown>(key: STORE_KEY): Observable<T> {
return this.storage.get(key) as Observable<T>;
}
/**
@@ -175,9 +178,9 @@ export class SettingsService {
*/
getAppVersion() {
return this.http
.get<{ created_at: string; name: string }[]>(
'https://api.github.com/repos/4gray/iptvnator/releases'
)
.get<
{ created_at: string; name: string }[]
>('https://api.github.com/repos/4gray/iptvnator/releases')
.pipe(
map((response) => {
// Filter out pre-release versions (beta, alpha, rc, etc.)
@@ -215,10 +218,7 @@ export class SettingsService {
* @param latestVersion latest stable version from GitHub
* @returns true if current version is outdated
*/
isVersionOutdated(
currentVersion: string,
latestVersion: string
): boolean {
isVersionOutdated(currentVersion: string, latestVersion: string): boolean {
const cleanCurrent = parseVersion(currentVersion);
const cleanLatest = parseVersion(latestVersion);
@@ -494,7 +494,8 @@ describe('SettingsComponent', () => {
.some((player) => player.id === VideoPlayer.EmbeddedMpv)
).toBe(false);
resolveSupport!({
expect(resolveSupport).toBeDefined();
resolveSupport({
supported: true,
platform: 'darwin',
});
+59 -22
View File
@@ -12,6 +12,7 @@ import {
import {
FormArray,
FormBuilder,
FormControl,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
@@ -48,6 +49,7 @@ import {
CoverSize,
Language,
normalizeExternalPlayerArguments,
Settings,
StartupBehavior,
StreamFormat,
Theme,
@@ -162,10 +164,10 @@ export class SettingsComponent implements OnInit, OnDestroy {
]);
/** Current version of the app */
version: string;
version = '';
/** Update message to show */
updateMessage: string;
updateMessage = '';
/** EPG availability flag */
epgAvailable$ = this.store.select(selectIsEpgAvailable);
@@ -178,7 +180,9 @@ export class SettingsComponent implements OnInit, OnDestroy {
/** Settings form object */
settingsForm = this.formBuilder.group({
player: [VideoPlayer.VideoJs],
...(this.isDesktop ? { epgUrl: new FormArray([]) } : {}),
...(this.isDesktop
? { epgUrl: new FormArray<FormControl<string | null>>([]) }
: {}),
streamFormat: StreamFormat.M3u8StreamFormat,
openStreamOnDoubleClick: false,
language: Language.ENGLISH,
@@ -468,21 +472,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
* the indexed db store
*/
onSubmit(): void {
const settings = {
...this.settingsForm.value,
mpvPlayerPath: this.normalizeExternalPlayerPath(
this.settingsForm.value.mpvPlayerPath
),
vlcPlayerPath: this.normalizeExternalPlayerPath(
this.settingsForm.value.vlcPlayerPath
),
mpvPlayerArguments: normalizeExternalPlayerArguments(
this.settingsForm.value.mpvPlayerArguments
),
vlcPlayerArguments: normalizeExternalPlayerArguments(
this.settingsForm.value.vlcPlayerArguments
),
};
const settings = this.createSettingsFromFormValue();
this.settingsStore.updateSettings(settings).then(() => {
this.applyChangedSettings();
@@ -505,6 +495,48 @@ export class SettingsComponent implements OnInit, OnDestroy {
return playerPath?.trim() ?? '';
}
private createSettingsFromFormValue(): Settings {
const value = this.settingsForm.value;
const epgUrl = Array.isArray(value.epgUrl)
? value.epgUrl.filter(
(url): url is string => typeof url === 'string'
)
: [];
return {
player: value.player ?? VideoPlayer.VideoJs,
streamFormat: value.streamFormat ?? StreamFormat.M3u8StreamFormat,
openStreamOnDoubleClick: value.openStreamOnDoubleClick ?? false,
language: value.language ?? Language.ENGLISH,
showCaptions: value.showCaptions ?? false,
showDashboard: value.showDashboard ?? true,
startupBehavior: value.startupBehavior ?? StartupBehavior.FirstView,
showExternalPlaybackBar: value.showExternalPlaybackBar ?? true,
theme: value.theme ?? Theme.SystemTheme,
mpvPlayerPath: this.normalizeExternalPlayerPath(
value.mpvPlayerPath
),
mpvPlayerArguments: normalizeExternalPlayerArguments(
value.mpvPlayerArguments
),
mpvReuseInstance: value.mpvReuseInstance ?? false,
vlcPlayerPath: this.normalizeExternalPlayerPath(
value.vlcPlayerPath
),
vlcPlayerArguments: normalizeExternalPlayerArguments(
value.vlcPlayerArguments
),
vlcReuseInstance: value.vlcReuseInstance ?? false,
remoteControl: value.remoteControl ?? false,
remoteControlPort: Number(value.remoteControlPort ?? 8765),
recordingFolder: value.recordingFolder ?? '',
coverSize: value.coverSize ?? 'medium',
epgUrl,
preferUploadedEpgOverXtream:
value.preferUploadedEpgOverXtream ?? false,
};
}
/**
* Applies the changed settings to the app
*/
@@ -516,14 +548,19 @@ export class SettingsComponent implements OnInit, OnDestroy {
if (!Array.isArray(epgUrls)) {
epgUrls = [epgUrls];
}
epgUrls = epgUrls.filter((url) => url !== '');
if (epgUrls.length > 0) {
const validEpgUrls = epgUrls.filter(
(url): url is string =>
typeof url === 'string' && url !== ''
);
if (validEpgUrls.length > 0) {
// Fetch all EPG URLs at once
this.epgService.fetchEpg(epgUrls);
this.epgService.fetchEpg(validEpgUrls);
}
}
}
this.translate.use(this.settingsForm.value.language);
this.translate.use(
this.settingsForm.value.language ?? Language.ENGLISH
);
this.settingsService.changeTheme(
this.settingsForm.value.theme ?? Theme.SystemTheme
);
+2 -1
View File
@@ -19,6 +19,7 @@ import {
PlaylistRefreshPayload,
PortalDebugEvent,
ResolvedPortalPlayback,
Settings,
XtreamBackupFavoriteItem,
XtreamBackupHiddenCategory,
XtreamBackupRecentlyViewedItem,
@@ -119,7 +120,7 @@ declare global {
searchTerm: string,
limit?: number
) => Promise<EpgProgram[]>;
updateSettings: (settings: JsonObject) => Promise<void>;
updateSettings: (settings: Partial<Settings>) => Promise<void>;
getAiSettings: () => Promise<{
aiProvider: string;
aiModelName: string;
+1 -1
View File
@@ -13,7 +13,7 @@
"typeRoots": ["../../node_modules/@types"],
"lib": ["es2018", "es2017", "es2016", "es2015", "dom"],
"resolveJsonModule": true,
"strict": false,
"strict": true,
"skipLibCheck": true,
"useDefineForClassFields": false
},
@@ -109,7 +109,7 @@
[disabled]="!textImport()?.textForm?.valid"
(click)="
textImport()?.textAdded?.emit(
textImport()?.textForm?.value?.text
textImport()?.textForm?.value?.text ?? undefined
)
"
>
@@ -112,11 +112,11 @@ export class StalkerPortalImportComponent {
const isFullStalkerPortal =
this.isFullStalkerPortalUrl(originalUrl);
const stalkerIdentity = normalizeStalkerPortalIdentity({
serialNumber: formValue.serialNumber,
deviceId1: formValue.deviceId1,
deviceId2: formValue.deviceId2,
signature1: formValue.signature1,
signature2: formValue.signature2,
serialNumber: formValue.serialNumber ?? undefined,
deviceId1: formValue.deviceId1 ?? undefined,
deviceId2: formValue.deviceId2 ?? undefined,
signature1: formValue.signature1 ?? undefined,
signature2: formValue.signature2 ?? undefined,
});
let stalkerToken: string | undefined;
@@ -151,7 +151,7 @@ export class StalkerPortalImportComponent {
);
this.snackBar.open(
`Portal validated. Expires: ${expireDate.toLocaleDateString()}`,
null,
undefined,
{ duration: 3000 }
);
}
@@ -162,7 +162,7 @@ export class StalkerPortalImportComponent {
);
this.snackBar.open(
'Failed to authenticate with portal. Please check URL and MAC address.',
null,
undefined,
{ duration: 5000 }
);
this.isLoading.set(false);
@@ -14,7 +14,7 @@
type="text"
id="serverUrl"
formControlName="serverUrl"
(input)="extractParams(form.value.serverUrl)"
(input)="extractParams(form.value.serverUrl ?? '')"
/>
<mat-hint>{{
'HOME.XTREAM_PLAYLIST.URL_VALIDATION_ERROR' | translate
@@ -155,8 +155,8 @@ export class XtreamCodeImportComponent {
extractParams(urlAsString: string): void {
if (
this.form.get('username').value !== '' ||
this.form.get('password').value !== ''
this.form.get('username')?.value !== '' ||
this.form.get('password')?.value !== ''
)
return;
try {
@@ -411,8 +411,16 @@ export class PlaylistSwitcherComponent {
this.portalStatusAbortController = controller;
const xtreamPlaylists = playlists.filter(
(playlist) =>
playlist.serverUrl && playlist.username && playlist.password
(
playlist
): playlist is PlaylistMeta & {
serverUrl: string;
username: string;
password: string;
} =>
Boolean(
playlist.serverUrl && playlist.username && playlist.password
)
);
if (xtreamPlaylists.length === 0) {
return;
@@ -422,7 +430,11 @@ export class PlaylistSwitcherComponent {
// 'checking' in a single signal write so the UI flips from blank →
// cached/checking dots in one render, not one per playlist.
const next = new Map(this.portalStatuses());
const toFetch: PlaylistMeta[] = [];
const toFetch: (PlaylistMeta & {
serverUrl: string;
username: string;
password: string;
})[] = [];
for (const playlist of xtreamPlaylists) {
const cached = this.portalStatusService.getCachedStatus(
playlist.serverUrl,
@@ -460,7 +460,7 @@ export class RecentPlaylistsComponent {
this.translate.instant(
'HOME.PLAYLISTS.PLAYLIST_UPDATE_SUCCESS'
),
null,
undefined,
{ duration: 2000 }
);
} catch (error) {
@@ -15,7 +15,7 @@ import { MatTooltip } from '@angular/material/tooltip';
import { ActivatedRoute, Router } from '@angular/router';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { DialogService } from '@iptvnator/ui/components';
import { firstValueFrom, map } from 'rxjs';
import { firstValueFrom, map, startWith } from 'rxjs';
import {
DatabaseService,
type DownloadItem,
@@ -83,16 +83,22 @@ export class DownloadsComponent {
readonly searchTerm = queryParamSignal(this.route, 'q', (value) =>
(value ?? '').trim().toLowerCase()
);
readonly playlists = toSignal(this.playlistsService.getAllPlaylists(), {
initialValue: null as Playlist[] | null,
});
readonly playlists = toSignal(
this.playlistsService.getAllPlaylists().pipe(startWith(null)),
{
initialValue: null as Playlist[] | null,
}
);
readonly playlistsLoaded = computed(() => this.playlists() !== null);
readonly playlistItems = computed(() => this.playlists() ?? []);
readonly hasNoPlaylists = computed(
() => this.playlistsLoaded() && this.playlistItems().length === 0
);
readonly skeletonItems = Array.from({ length: 6 }, (_, index) => index);
readonly skeletonActionSlots = Array.from({ length: 4 }, (_, index) => index);
readonly skeletonActionSlots = Array.from(
{ length: 4 },
(_, index) => index
);
addPlaylist(): void {
this.shellActions.openAddPlaylistDialog();
@@ -275,7 +281,9 @@ export class DownloadsComponent {
async reveal(item: DownloadItem) {
if (item.filePath) {
const result = await this.downloadsService.revealFile(item.filePath);
const result = await this.downloadsService.revealFile(
item.filePath
);
if (!result.success) {
this.handleFileActionError(result.error);
}
@@ -309,7 +317,10 @@ export class DownloadsComponent {
}
hasPoster(item: DownloadItem): boolean {
return !!item.posterUrl && !this.failedPosterKeys()[this.getPosterKey(item)];
return (
!!item.posterUrl &&
!this.failedPosterKeys()[this.getPosterKey(item)]
);
}
markPosterFailed(item: DownloadItem): void {
@@ -32,7 +32,7 @@ import {
const GLOBAL_FAVORITES_ORDER_KEY = 'global-favorites-channel-order-v1';
type PlaylistWithChannels = Playlist & {
type PlaylistWithChannels = Omit<Playlist, 'playlist'> & {
readonly playlist?: { readonly items?: Channel[] };
};
type StalkerPortalFavoriteItem = StalkerPortalItem & {
@@ -31,7 +31,7 @@ import {
XtreamContentItem,
} from '@iptvnator/portal/xtream/data-access';
type PlaylistWithChannels = Playlist & {
type PlaylistWithChannels = Omit<Playlist, 'playlist'> & {
readonly playlist?: { readonly items?: Channel[] };
};
@@ -1,4 +1,4 @@
@if (items()?.length > 0) {
@if (items().length > 0) {
<div class="nav-list">
@for (item of items(); track $index) {
<a
@@ -7,7 +7,10 @@ import { PlaylistSwitcherComponent } from '@iptvnator/playlist/shared/ui';
import { isWorkspaceLayoutRoute } from '@iptvnator/portal/shared/util';
import { TranslatePipe } from '@ngx-translate/core';
import { ResizableDirective } from '@iptvnator/ui/components';
import { StalkerPortalItem, XtreamCategory } from '@iptvnator/shared/interfaces';
import {
StalkerPortalItem,
XtreamCategory,
} from '@iptvnator/shared/interfaces';
import { CategoryViewComponent } from '../category-view/category-view.component';
import { ContentCardComponent } from '../content-card/content-card.component';
@@ -78,7 +81,11 @@ export class FavoritesLayoutComponent {
readonly openItem = output<FavoriteLayoutItem>();
readonly headerActionClicked = output<void>();
setCategoryId(categoryId: string | number) {
setCategoryId(categoryId: string | number | undefined) {
if (categoryId === undefined) {
return;
}
this.categoryClicked.emit({ category_id: categoryId });
}
@@ -162,7 +162,7 @@ export function resolveGridRating(
}
}
</div>
@if (showPaginator() && items()?.length > 0) {
@if (showPaginator() && items().length > 0) {
<mat-paginator
[pageIndex]="pageIndex()"
[length]="totalPages() * limit()"
@@ -186,17 +186,17 @@ export function resolveGridRating(
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class GridListComponent {
readonly items = input<GridListItem[]>();
readonly isLoading = input<boolean>();
readonly items = input<GridListItem[]>([]);
readonly isLoading = input<boolean>(false);
readonly showPaginator = input(true);
readonly searchTerm = input<string>('');
readonly itemClicked = output<GridListItem>();
readonly pageChange = output<PageEvent>();
readonly pageIndex = input<number>();
readonly totalPages = input<number>();
readonly limit = input<number>();
readonly pageSizeOptions = input<number[]>();
readonly pageIndex = input<number>(0);
readonly totalPages = input<number>(0);
readonly limit = input<number>(25);
readonly pageSizeOptions = input<number[]>([]);
protected readonly resolveRating = resolveGridRating;
protected readonly hasActiveSearch = computed(
() => (this.searchTerm() ?? '').trim().length > 0
@@ -26,10 +26,10 @@ export class PlaylistErrorViewComponent {
private readonly currentPlaylist = this.playlistContext.activePlaylist;
readonly description = input<string>(undefined);
readonly description = input<string | undefined>(undefined);
readonly showIllustration = input(true);
readonly showActionButtons = input(true);
readonly title = input<string>(undefined);
readonly title = input<string | undefined>(undefined);
readonly viewType = input<'ERROR' | 'EMPTY_CATEGORY' | 'NO_SEARCH_RESULTS'>(
'ERROR'
);
@@ -49,7 +49,7 @@ export function getRecentItemNavigation(
if (item.type === 'live') {
return buildLiveCollectionNavigationTarget({
mode: 'recent',
sourceType: item.source,
sourceType: normalizeCollectionSourceType(item.source) ?? 'm3u',
playlistId: item.playlist_id,
itemId: item.xtream_id ?? item.id,
title: item.title,
@@ -91,7 +91,7 @@ export function getGlobalFavoriteNavigation(
if (item.type === 'live') {
return buildLiveCollectionNavigationTarget({
mode: 'favorites',
sourceType: item.source,
sourceType: normalizeCollectionSourceType(item.source) ?? 'm3u',
playlistId: item.playlist_id,
itemId: item.xtream_id ?? item.id,
title: item.title,
@@ -231,7 +231,7 @@ export function buildOpenCollectionDetailItemState(
item: UnifiedCollectionItem
): OpenCollectionDetailItemState {
return {
item: normalizeCollectionDetailItem(item),
item: normalizeCollectionDetailItem(item) ?? item,
};
}
@@ -539,6 +539,14 @@ function normalizeCollectionScope(
return value === 'playlist' || value === 'all' ? value : null;
}
function normalizeCollectionSourceType(
value: unknown
): CollectionSourceType | null {
return value === 'm3u' || value === 'xtream' || value === 'stalker'
? value
: null;
}
function buildDashboardCollectionDetailItem(
item: PortalFavoriteItem | PortalRecentItem
): UnifiedCollectionItem | null {
@@ -669,9 +677,7 @@ function normalizeCollectionDetailItem(
}
const contentType = toPathSegment(record['contentType']);
const sourceType = toPathSegment(
record['sourceType']
) as CollectionSourceType;
const sourceType = normalizeCollectionSourceType(record['sourceType']);
const uid = toPathSegment(record['uid']);
const name = toPathSegment(record['name']);
const playlistId = toPathSegment(record['playlistId']);
@@ -680,6 +686,7 @@ function normalizeCollectionDetailItem(
!uid ||
!name ||
!playlistId ||
!sourceType ||
(contentType !== 'movie' && contentType !== 'series') ||
(sourceType !== 'xtream' && sourceType !== 'stalker')
) {
@@ -8,10 +8,7 @@ import {
type StalkerPortalIdentity,
} from './stalker-identity.utils';
export {
getStalkerPortalIdentityFromPlaylist,
normalizeStalkerPortalIdentity,
};
export { getStalkerPortalIdentityFromPlaylist, normalizeStalkerPortalIdentity };
export type { StalkerPortalIdentity };
/**
@@ -100,7 +97,10 @@ export class StalkerSessionService {
string,
Promise<{ token: string; serialNumber?: string }>
>();
private watchdogIntervals = new Map<string, ReturnType<typeof setInterval>>();
private watchdogIntervals = new Map<
string,
ReturnType<typeof setInterval>
>();
private watchdogPlaylists = new Map<string, Playlist>();
private watchdogInFlight = new Set<string>();
private activeWatchdogPlaylistId: string | null = null;
@@ -494,14 +494,16 @@ export class StalkerSessionService {
console.error('[StalkerSession] Missing portal URL or MAC address');
throw new Error('Portal URL and MAC address are required');
}
const portalUrl = playlist.portalUrl;
const macAddress = playlist.macAddress;
// Create the authentication promise and store it to prevent concurrent auth attempts
// Use async/await wrapper to properly clean up on both success and failure
const authPromise = (async () => {
try {
const { token } = await this.authenticate(
playlist.portalUrl,
playlist.macAddress,
portalUrl,
macAddress,
identity
);
this.setCachedToken(playlist._id, token);
@@ -94,9 +94,9 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
const item = this.selectedItem();
return Boolean(
item &&
(this.contentType() === 'series' ||
item.is_series === true ||
String(item.is_series) === '1')
(this.contentType() === 'series' ||
item.is_series === true ||
String(item.is_series) === '1')
);
});
@@ -257,7 +257,7 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
showCopyNotification(): void {
this.snackBar.open(
this.translateService.instant('PORTALS.STREAM_URL_COPIED'),
null,
undefined,
{
duration: 2000,
}
@@ -381,7 +381,7 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
'PORTALS.CONTENT_NOT_AVAILABLE'
)
: this.translateService.instant('PORTALS.PLAYBACK_ERROR');
this.snackBar.open(errorMessage, null, {
this.snackBar.open(errorMessage, undefined, {
duration: 3000,
});
}
@@ -27,8 +27,7 @@ function calculateProgress(position: PlaybackPositionData | undefined): number {
return 0;
}
const percent =
(position.positionSeconds / position.durationSeconds) * 100;
const percent = (position.positionSeconds / position.durationSeconds) * 100;
if (position.positionSeconds > 10 && percent < 1) {
return 1;
@@ -38,20 +37,17 @@ function calculateProgress(position: PlaybackPositionData | undefined): number {
}
@Injectable()
export class StalkerCatalogFacadeService
implements
StalkerPortalCatalogFacade<
Record<string, unknown>,
StalkerVodSource,
StalkerVodSource
>
{
export class StalkerCatalogFacadeService implements StalkerPortalCatalogFacade<
Record<string, unknown>,
StalkerVodSource,
StalkerVodSource
> {
private readonly stalkerStore = inject(StalkerStore);
private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS);
private readonly destroyRef = inject(DestroyRef);
private readonly stalkerPositions = signal<Map<string, PlaybackPositionData>>(
new Map()
);
private readonly stalkerPositions = signal<
Map<string, PlaybackPositionData>
>(new Map());
private readonly stalkerSeriesPositions = signal<
Map<number, PlaybackPositionData[]>
>(new Map());
@@ -72,9 +68,9 @@ export class StalkerCatalogFacadeService
this.stalkerStore.isPaginatedContentLoading;
readonly selectedCategoryTitle = computed(() => {
const category = this.selectedCategory();
const fromCategory = String(
category?.['category_name'] ?? category?.['name'] ?? ''
);
const fromCategory = category
? String(category.category_name ?? '')
: '';
if (fromCategory) {
return fromCategory;
@@ -125,7 +121,10 @@ export class StalkerCatalogFacadeService
if (window.electron?.onPlaybackPositionUpdate) {
const unsubscribe = window.electron.onPlaybackPositionUpdate(
(data: PlaybackPositionData) => {
if (data.playlistId !== this.playlist()?.id) {
if (
!data.playlistId ||
data.playlistId !== this.playlist()?.id
) {
return;
}
@@ -138,10 +137,7 @@ export class StalkerCatalogFacadeService
this.updateVodPlaybackPosition(data);
}
if (
data.contentType === 'episode' &&
data.seriesXtreamId
) {
if (data.contentType === 'episode' && data.seriesXtreamId) {
this.updateSeriesPlaybackPosition(data);
}
}
@@ -181,7 +181,7 @@ export class StalkerCollectionDetailComponent {
this.originalState.selectedContentType
);
this.stalkerStore.setSelectedCategory(
this.originalState.selectedCategoryId
this.originalState.selectedCategoryId ?? null
);
this.stalkerStore.setSelectedItem(
this.originalState.selectedItem as never
@@ -2,7 +2,7 @@ import { AsyncPipe } from '@angular/common';
import { Component, inject, input } from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { TranslateModule } from '@ngx-translate/core';
import { BehaviorSubject, map, switchMap } from 'rxjs';
import { BehaviorSubject, map, of, switchMap } from 'rxjs';
import { PlaylistsService } from '@iptvnator/services';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import {
@@ -33,11 +33,12 @@ export class FavoritesButtonComponent {
private readonly favoritesChanged$ = new BehaviorSubject<void>(undefined);
readonly isFavorite$ = this.favoritesChanged$.pipe(
switchMap(() =>
this.playlistService.getPortalFavorites(
this.stalkerStore.currentPlaylist()?._id
)
),
switchMap(() => {
const playlistId = this.stalkerStore.currentPlaylist()?._id;
return playlistId
? this.playlistService.getPortalFavorites(playlistId)
: of([]);
}),
map((favorites) =>
favorites.some((favorite) =>
matchesFavoriteById(favorite, this.itemId())
@@ -8,10 +8,10 @@
} @else if (isSeries()) {
<!-- VOD item that is actually a series (Ministra plugin is_series=1) -->
<app-stalker-series-view (backClicked)="onBackClicked()" />
} @else if (vodDetailsItem()) {
} @else if (vodDetailsItem(); as item) {
<!-- Regular VOD item -->
<app-vod-details
[item]="vodDetailsItem()"
[item]="item"
[isFavorite]="isFavorite()"
[playbackPosition]="playbackPosition()"
[inlinePlayback]="inlinePlayback()"
@@ -31,6 +31,7 @@ import {
EpgItem,
EpgProgram,
ResolvedPortalPlayback,
StalkerPortalItem,
} from '@iptvnator/shared/interfaces';
import {
EpgDateNavigationDirection,
@@ -66,6 +67,11 @@ import {
normalizeStalkerEntityId,
} from '@iptvnator/portal/stalker/data-access';
type StalkerPlayableChannel = StalkerPortalItem & {
cmd?: string;
has_files?: unknown;
};
@Component({
selector: 'app-stalker-live-stream-layout',
templateUrl: './stalker-live-stream-layout.component.html',
@@ -246,19 +252,22 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
constructor() {
// Load favorites for current playlist
this.playlistService
.getPortalFavorites(this.stalkerStore.currentPlaylist()?._id)
.pipe(takeUntilDestroyed())
.subscribe((favs) => {
favs.forEach((fav: StalkerFavoriteItem) => {
if (fav.id !== undefined) {
this.favorites.set(
normalizeStalkerEntityId(fav.id),
true
);
}
const playlistId = this.stalkerStore.currentPlaylist()?._id;
if (playlistId) {
this.playlistService
.getPortalFavorites(playlistId)
.pipe(takeUntilDestroyed())
.subscribe((favs) => {
favs.forEach((fav: StalkerFavoriteItem) => {
if (fav.id !== undefined) {
this.favorites.set(
normalizeStalkerEntityId(fav.id),
true
);
}
});
});
});
}
// Reset channels/page on category change
effect(() => {
@@ -427,10 +436,10 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
this.logger.error('Playback failed', error);
const errorMessage =
error?.message === 'nothing_to_play'
error instanceof Error && error.message === 'nothing_to_play'
? this.translate.instant('PORTALS.CONTENT_NOT_AVAILABLE')
: this.translate.instant('PORTALS.PLAYBACK_ERROR');
this.snackBar.open(errorMessage, null, { duration: 3000 });
this.snackBar.open(errorMessage, undefined, { duration: 3000 });
}
}
@@ -446,9 +455,10 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
return this.playbackResolution.promise;
}
const playableItem = this.toPlayableChannel(item);
const promise = this.isRadioMode()
? this.stalkerStore.resolveRadioPlayback(item)
: this.stalkerStore.resolveItvPlayback(item);
? this.stalkerStore.resolveRadioPlayback(playableItem)
: this.stalkerStore.resolveItvPlayback(playableItem);
this.playbackResolution = { channelId: playbackChannelId, promise };
const cleanup = () => {
@@ -468,8 +478,9 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
this.stalkerStore.removeFromFavorites(itemId);
this.favorites.delete(itemId);
} else {
const playableItem = this.toPlayableChannel(item);
this.stalkerStore.addToFavorites({
...item,
...playableItem,
category_id: this.isRadioMode() ? 'radio' : 'itv',
title: item.o_name || item.name,
cover: item.logo,
@@ -620,6 +631,8 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
const nowMs = Date.now();
if (
startMs !== null &&
stopMs !== null &&
Number.isFinite(startMs) &&
Number.isFinite(stopMs) &&
nowMs >= startMs &&
@@ -636,6 +649,11 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
this.currentProgramsProgress.delete(channelId);
}
private toPlayableChannel(item: StalkerItvChannel): StalkerPlayableChannel {
const { is_series, ...rest } = item;
return is_series == null ? rest : { ...rest, is_series };
}
private setupScrollListener() {
this.removeScrollListener();
@@ -23,7 +23,9 @@
(inlineTimeUpdated)="handleInlineTimeUpdate($event)"
(inlinePlaybackClosed)="closeInlinePlayer()"
(streamUrlCopied)="showCopyNotification()"
(inlineExternalFallbackRequested)="handleExternalFallbackRequest($event)"
(inlineExternalFallbackRequested)="
handleExternalFallbackRequest($event)
"
(favoriteToggled)="onVodFavoriteToggled($event)"
(backClicked)="onVodBack()"
/>
@@ -46,7 +48,7 @@
<div class="results-grid">
@for (item of searchResultsResource.value(); track item.id) {
<app-content-card
[title]="item.o_name || item.name"
[title]="item.o_name || item.name || ''"
[posterUrl]="item.screenshot_uri"
[type]="selectedFilterType() === 'vod' ? 'movie' : 'series'"
[showPlaceholder]="true"
@@ -55,11 +55,13 @@ import {
import { StalkerVodPlaybackController } from '../stalker-vod-playback-controller';
interface StalkerFilter {
key: string;
key: StalkerSearchContentType;
label: string;
translationKey: string;
}
type StalkerSearchContentType = 'vod' | 'series';
interface StalkerSearchResponse {
js?: {
data?: StalkerVodSource[];
@@ -96,7 +98,7 @@ export class StalkerSearchComponent {
private readonly translateService = inject(TranslateService);
private readonly logger = createLogger('StalkerSearch');
readonly filters = signal({
readonly filters = signal<Record<StalkerSearchContentType, boolean>>({
series: false,
vod: true,
});
@@ -127,7 +129,7 @@ export class StalkerSearchComponent {
return playlist?.macAddress ? playlist : null;
});
readonly selectedFilterType = signal('vod');
readonly selectedFilterType = signal<StalkerSearchContentType>('vod');
private readonly favoritesRefresh = createRefreshTrigger();
itemDetails: StalkerSelectedVodItem | null = null;
@@ -167,6 +169,10 @@ export class StalkerSearchComponent {
const playlist = this.currentPlaylist();
if (!playlist) return [];
const { portalUrl, macAddress } = playlist;
if (!portalUrl || !macAddress) {
return [];
}
const contentType = params.contentType;
let token: string | undefined;
let serialNumber: string | undefined;
@@ -189,9 +195,9 @@ export class StalkerSearchComponent {
url: portalUrl,
macAddress,
params: {
action: StalkerContentTypes[params.contentType]
action: StalkerContentTypes[contentType]
.getContentAction,
type: params.contentType,
type: contentType,
search: params.search,
max_page_items: 100,
},
@@ -199,16 +205,10 @@ export class StalkerSearchComponent {
serialNumber,
}
);
if (response) {
const items = response.js?.data || [];
return items.map((item: StalkerVodSource) =>
this.processItemUrls(item, portalUrl)
);
} else {
throw new Error(
`Error: ${response.message} (Status: ${response.status})`
);
}
const items = response.js?.data || [];
return items.map((item: StalkerVodSource) =>
this.processItemUrls(item, portalUrl)
);
},
});
@@ -243,23 +243,26 @@ export class StalkerSearchComponent {
this.searchTerm.set(term);
}
updateFilter(key: string, value: boolean) {
updateFilter(key: StalkerSearchContentType, value: boolean) {
if (value) {
// Single selection mode - set clicked filter, disable others
this.selectedFilterType.set(key);
this.filters.update((f) => {
const newFilters: Record<string, boolean> = {};
Object.keys(f).forEach((k) => {
newFilters[k] = k === key;
this.filters.update(() => {
const newFilters: Record<StalkerSearchContentType, boolean> = {
series: false,
vod: false,
};
this.filterConfig.forEach((filter) => {
newFilters[filter.key] = filter.key === key;
});
return newFilters as typeof f;
return newFilters;
});
}
}
selectItem(item: StalkerVodSource) {
this.closeInlinePlayer();
const hasEmbeddedSeries = item.series?.length > 0;
const hasEmbeddedSeries = (item.series?.length ?? 0) > 0;
const needsSeriesFetch =
this.selectedFilterType() === 'vod' &&
!hasEmbeddedSeries &&
@@ -8,14 +8,10 @@
<!-- Tags: Year, Genre, Rating -->
<ng-container hero-tags>
@if (serial.info.releasedate) {
<span class="details__tag">{{
serial.info.releasedate
}}</span>
<span class="details__tag">{{ serial.info.releasedate }}</span>
}
@if (serial.info.genre) {
<span class="details__tag">{{
serial.info.genre
}}</span>
<span class="details__tag">{{ serial.info.genre }}</span>
}
@if (serial.info.rating_imdb) {
<span class="details__tag details__tag--rating">
@@ -33,9 +29,7 @@
<ng-container hero-meta>
@if (serial.info.actors) {
<div class="details__meta-item">
<span class="label">{{
'XTREAM.ACTORS' | translate
}}</span>
<span class="label">{{ 'XTREAM.ACTORS' | translate }}</span>
<span class="value">{{ serial.info.actors }}</span>
</div>
}
@@ -75,10 +69,7 @@
</span>
</button>
}
<app-favorites-button
[itemId]="serial.id"
[item]="serial"
/>
<app-favorites-button [itemId]="serial.id" [item]="serial" />
</ng-container>
@if (inlinePlayback(); as playback) {
@@ -99,19 +90,20 @@
[seasons]="mappedSeasons()"
[seriesId]="toSeriesId(serial.id)"
[playlistId]="stalkerStore.currentPlaylist()?._id ?? ''"
[seriesTitle]="serial.info?.name"
[seriesTitle]="serial.info.name"
[playbackPositions]="episodePlaybackPositions()"
[openingEpisodeId]="openingEpisodeId()"
[activeEpisodeId]="activeEpisodeId()"
[isLoading]="
isVodSeriesSeasonsLoading() ||
(seasonContainer?.selectedSeason &&
isCurrentSeasonLoading(seasonContainer.selectedSeason))
isCurrentSeasonLoading(seasonContainer?.selectedSeason)
"
(seasonSelected)="onSeasonSelected($event)"
(episodeClicked)="onEpisodeClicked($event)"
(episodeDownloadRequested)="downloadEpisode($event)"
(playbackToggleRequested)="handlePlaybackToggleRequested($event)"
(playbackToggleRequested)="
handlePlaybackToggleRequested($event)
"
#seasonContainer
/>
}
@@ -334,7 +334,8 @@ export class StalkerSeriesViewComponent implements OnDestroy {
/**
* Determines if the current selected season is loading
*/
isCurrentSeasonLoading(seasonKey: string): boolean {
isCurrentSeasonLoading(seasonKey?: string): boolean {
if (!seasonKey) return false;
if (!this.isVodSeries()) return false;
const season = this.vodSeriesSeasons().find(
(s) => getVodSeriesSeasonKey(s) === seasonKey
@@ -498,7 +499,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
showCopyNotification(): void {
this.snackBar.open(
this.translateService.instant('PORTALS.STREAM_URL_COPIED'),
null,
undefined,
{
duration: 2000,
}
@@ -546,7 +547,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
'PORTALS.CONTENT_NOT_AVAILABLE'
)
: this.translateService.instant('PORTALS.PLAYBACK_ERROR');
this.snackBar.open(errorMessage, null, {
this.snackBar.open(errorMessage, undefined, {
duration: 3000,
});
}
@@ -56,7 +56,7 @@ export class StalkerVodPlaybackController {
: this.config.translateService.instant(
'PORTALS.PLAYBACK_ERROR'
);
this.config.snackBar.open(errorMessage, null, {
this.config.snackBar.open(errorMessage, undefined, {
duration: 3000,
});
}
@@ -122,7 +122,7 @@ export class StalkerVodPlaybackController {
showCopyNotification(): void {
this.config.snackBar.open(
this.config.translateService.instant('PORTALS.STREAM_URL_COPIED'),
null,
undefined,
{
duration: 2000,
}
@@ -360,7 +360,7 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
): Promise<number> {
return this.dbService.saveXtreamContent(
playlistId,
streams,
streams as Parameters<DatabaseService['saveXtreamContent']>[1],
type,
onProgress,
options
@@ -616,8 +616,8 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
// Sort by updatedAt descending
playlistPositions.sort(
(a, b) =>
new Date(b.updatedAt).getTime() -
new Date(a.updatedAt).getTime()
new Date(b.updatedAt ?? '').getTime() -
new Date(a.updatedAt ?? '').getTime()
);
return limit ? playlistPositions.slice(0, limit) : playlistPositions;
@@ -6,7 +6,10 @@ import {
withState,
} from '@ngrx/signals';
import { PORTAL_PLAYER } from '@iptvnator/portal/shared/util';
import { XtreamSerieEpisode, XtreamVodDetails } from '@iptvnator/shared/interfaces';
import {
XtreamSerieEpisode,
XtreamVodDetails,
} from '@iptvnator/shared/interfaces';
import { XtreamCredentials } from '../../services/xtream-api.service';
import { XtreamUrlService } from '../../services/xtream-url.service';
@@ -80,10 +83,11 @@ export function withPlayer() {
/**
* Helper to get playlist with headers from parent store
*/
const getPlaylistFromStore = (): XtreamPlaylistHeadersLike | null => {
const storeAny = store as ParentPlayerStoreLike;
return storeAny.currentPlaylist?.();
};
const getPlaylistFromStore =
(): XtreamPlaylistHeadersLike | null => {
const storeAny = store as ParentPlayerStoreLike;
return storeAny.currentPlaylist?.() ?? null;
};
return {
/**
@@ -51,7 +51,7 @@ function mapDbRecentItem(
export const withRecentItems = function () {
const logger = createLogger('withRecentItems');
return signalStoreFeature(
withState({
withState<{ recentItems: RecentlyViewedItem[] }>({
recentItems: [],
}),
withMethods((store, dataSource = inject(XTREAM_DATA_SOURCE)) => ({
@@ -81,7 +81,7 @@ export const withRecentItems = function () {
addRecentItem: rxMethod<{
xtreamId: number | string;
contentType: 'live' | 'movie' | 'series';
playlist: Signal<{ id: string }>;
playlist: Signal<{ id: string } | null | undefined>;
backdropUrl?: string;
}>(
pipe(
@@ -92,7 +92,7 @@ export const withRecentItems = function () {
playlist,
backdropUrl,
}) => {
const playlistId = playlist().id;
const playlistId = playlist()?.id;
const normalizedXtreamId = Number(xtreamId);
if (
!playlistId ||
@@ -145,10 +145,10 @@ export const withRecentItems = function () {
}: {
xtreamId: number | string;
contentType: 'live' | 'movie' | 'series';
playlist: Signal<{ id: string }>;
playlist: Signal<{ id: string } | null | undefined>;
backdropUrl?: string;
}): Promise<void> {
const playlistId = playlist().id;
const playlistId = playlist()?.id;
const normalizedXtreamId = Number(xtreamId);
const normalizedBackdropUrl = backdropUrl?.trim();
if (
@@ -26,7 +26,7 @@
trackBy: trackBy;
templateCacheSize: 0
"
[name]="item.title || item.name"
[name]="item.title || item.name || ''"
[logo]="item.stream_icon ?? item.poster_url"
[selected]="
xtreamStore.selectedItem()?.xtream_id === item.xtream_id
@@ -32,7 +32,7 @@
>
@for (item of recentlyAddedVod(); track item.xtream_id) {
<app-content-card
[title]="item.title || item.name"
[title]="item.title || item.name || ''"
[posterUrl]="item.poster_url || item.stream_icon"
[type]="'movie'"
[date]="getDate(item)"
@@ -52,7 +52,7 @@
>
@for (item of recentlyAddedSeries(); track item.xtream_id) {
<app-content-card
[title]="item.title || item.name"
[title]="item.title || item.name || ''"
[posterUrl]="item.poster_url || item.cover"
[type]="'series'"
[date]="getDate(item, true)"
@@ -72,7 +72,7 @@
>
@for (item of recentlyAddedLive(); track item.xtream_id) {
<app-content-card
[title]="item.title || item.name"
[title]="item.title || item.name || ''"
[posterUrl]="item.poster_url || item.stream_icon"
[type]="'live'"
[date]="getDate(item)"
@@ -44,17 +44,14 @@ interface SearchResultsData {
function groupResultsByPlaylistName(
items: XtreamContentItem[]
): Record<string, XtreamContentItem[]> {
return items.reduce<Record<string, XtreamContentItem[]>>(
(groups, item) => {
const key = String(item.playlist_name);
if (!groups[key]) {
groups[key] = [];
}
groups[key].push(item);
return groups;
},
{}
);
return items.reduce<Record<string, XtreamContentItem[]>>((groups, item) => {
const key = String(item.playlist_name);
if (!groups[key]) {
groups[key] = [];
}
groups[key].push(item);
return groups;
}, {});
}
@Component({
@@ -199,7 +196,7 @@ export class SearchResultsComponent implements AfterViewInit {
}
ngAfterViewInit() {
this.xtreamStore.setSelectedContentType(undefined);
this.xtreamStore.setSelectedContentType('vod');
setTimeout(() => {
this.searchLayoutComponent()?.focusSearchInput();
});
@@ -1,46 +1,47 @@
@let item = selectedItem();
@if (item?.info) {
@if (item && item.info) {
@let info = item.info;
<app-content-hero
[title]="item.info?.name"
[description]="item.info?.plot"
[posterUrl]="item.info?.cover"
[backdropUrl]="item.info?.backdrop_path?.[0]"
[title]="info.name"
[description]="info.plot"
[posterUrl]="info.cover"
[backdropUrl]="info.backdrop_path?.[0]"
(backClicked)="goBack()"
>
<ng-container hero-tags>
@if (item.info?.releaseDate) {
@if (info.releaseDate) {
<span class="details__tag">{{
item.info.releaseDate | slice: 0 : 4
info.releaseDate | slice: 0 : 4
}}</span>
}
@if (item.info?.genre) {
<span class="details__tag">{{ item.info.genre }}</span>
@if (info.genre) {
<span class="details__tag">{{ info.genre }}</span>
}
@if (item.info?.episode_run_time) {
@if (info.episode_run_time) {
<span class="details__tag"
>{{ item.info.episode_run_time }} min/ep</span
>{{ info.episode_run_time }} min/ep</span
>
}
@if (item.info?.rating) {
@if (info.rating) {
<span class="details__tag details__tag--rating">
⭐ {{ item.info.rating }}
⭐ {{ info.rating }}
</span>
}
</ng-container>
<ng-container hero-meta>
@if (item.info?.cast) {
@if (info.cast) {
<div class="details__meta-item">
<span class="label">{{ 'XTREAM.CAST' | translate }}</span>
<span class="value">{{ item.info.cast }}</span>
<span class="value">{{ info.cast }}</span>
</div>
}
@if (item.info?.director) {
@if (info.director) {
<div class="details__meta-item">
<span class="label">{{
'XTREAM.DIRECTOR' | translate
}}</span>
<span class="value">{{ item.info.director }}</span>
<span class="value">{{ info.director }}</span>
</div>
}
</ng-container>
@@ -100,7 +101,7 @@
[seasons]="item.episodes"
[seriesId]="item.series_id"
[playlistId]="currentPlaylistId()"
[seriesTitle]="item.info?.name"
[seriesTitle]="info.name ?? ''"
[playbackPositions]="episodePlaybackPositions()"
[xtreamDownloadContext]="xtreamDownloadContext()"
[openingEpisodeId]="openingEpisodeId()"
@@ -111,7 +112,7 @@
</app-content-hero>
} @else if (detailsError()) {
<app-content-hero
[errorMessage]="detailsError()"
[errorMessage]="detailsError() ?? undefined"
(backClicked)="goBack()"
/>
} @else {
@@ -154,7 +154,12 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
const xtreamId = Number(selectedItem?.series_id ?? 0);
const backdropUrl = selectedItem?.info?.backdrop_path?.[0]?.trim();
if (!playlistId || !Number.isFinite(xtreamId) || xtreamId <= 0 || !backdropUrl) {
if (
!playlistId ||
!Number.isFinite(xtreamId) ||
xtreamId <= 0 ||
!backdropUrl
) {
return;
}
@@ -246,14 +251,20 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
}
playEpisode(episode: XtreamSerieEpisode): void {
const playlist = this.xtreamStore.currentPlaylist();
const selectedItem = this.selectedItem();
if (!playlist || !selectedItem) {
return;
}
this.addToRecentlyViewed(this.route.snapshot.params.serialId);
const streamUrl = this.xtreamStore.constructEpisodeStreamUrl(episode);
const contentInfo: PlayerContentInfo = {
playlistId: this.xtreamStore.currentPlaylist().id,
playlistId: playlist.id,
contentXtreamId: Number(episode.id),
contentType: 'episode',
seriesXtreamId: Number(this.selectedItem().series_id),
seriesXtreamId: Number(selectedItem.series_id),
seasonNumber: Number(episode.season),
episodeNumber: Number(episode.episode_num),
};
@@ -265,7 +276,7 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
const playback: ResolvedPortalPlayback = {
streamUrl,
title: episode.title,
thumbnail: this.selectedItem().info.cover,
thumbnail: selectedItem.info.cover,
startTime: position?.positionSeconds,
contentInfo,
};
@@ -283,9 +294,14 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
}
toggleFavorite(): void {
const playlist = this.xtreamStore.currentPlaylist();
if (!playlist) {
return;
}
this.xtreamStore.toggleFavorite(
this.route.snapshot.params.serialId,
this.xtreamStore.currentPlaylist().id,
playlist.id,
'series',
this.selectedItem()?.info?.backdrop_path?.[0]
);
@@ -327,7 +343,7 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
showCopyNotification(): void {
this.snackBar.open(
this.translateService.instant('PORTALS.STREAM_URL_COPIED'),
null,
undefined,
{
duration: 2000,
}
@@ -60,8 +60,12 @@
class="play-btn play-btn--resume"
(click)="onPrimaryAction(item)"
[disabled]="isExternalLaunchPending()"
[class.play-btn--state-launching]="externalPrimaryButtonState() === 'launching'"
[class.play-btn--state-stop]="externalPrimaryButtonState() === 'stop'"
[class.play-btn--state-launching]="
externalPrimaryButtonState() === 'launching'
"
[class.play-btn--state-stop]="
externalPrimaryButtonState() === 'stop'
"
>
<span class="play-btn__icon-shell" aria-hidden="true">
<mat-icon class="play-btn__icon">{{
@@ -91,8 +95,12 @@
class="play-btn"
(click)="onPrimaryAction(item)"
[disabled]="isExternalLaunchPending()"
[class.play-btn--state-launching]="externalPrimaryButtonState() === 'launching'"
[class.play-btn--state-stop]="externalPrimaryButtonState() === 'stop'"
[class.play-btn--state-launching]="
externalPrimaryButtonState() === 'launching'
"
[class.play-btn--state-stop]="
externalPrimaryButtonState() === 'stop'
"
>
<span class="play-btn__icon-shell" aria-hidden="true">
<mat-icon class="play-btn__icon">{{
@@ -170,7 +178,15 @@
info.youtube_trailer | safe
"
title="YouTube video player"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allow="
accelerometer;
autoplay;
clipboard-write;
encrypted-media;
gyroscope;
picture-in-picture;
web-share;
"
referrerpolicy="strict-origin-when-cross-origin"
allowfullscreen
></iframe>
@@ -218,10 +234,7 @@
@for (fact of fallback.facts; track fact.labelKey) {
<div class="details__meta-item details__meta-item--fallback">
<span class="label">{{ fact.labelKey | translate }}</span>
<span
class="value"
[class.value--mono]="fact.monospace"
>
<span class="value" [class.value--mono]="fact.monospace">
{{ fact.value }}
</span>
</div>
@@ -230,7 +243,7 @@
</app-content-hero>
} @else if (detailsError()) {
<app-content-hero
[errorMessage]="detailsError()"
[errorMessage]="detailsError() ?? undefined"
(backClicked)="goBack()"
/>
} @else {
@@ -98,21 +98,28 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
.find(
(category) =>
String(
(category as XtreamCategory & { id?: string | number })
.category_id ??
(category as XtreamCategory & { id?: string | number })
.id
(
category as XtreamCategory & {
id?: string | number;
}
).category_id ??
(
category as XtreamCategory & {
id?: string | number;
}
).id
) === String(categoryId)
) ?? null
);
});
readonly selectedCatalogItem = computed<
(Partial<XtreamVodStream> & {
id?: string | number;
poster_url?: string;
title?: string;
xtream_id?: string | number;
}) | null
| (Partial<XtreamVodStream> & {
id?: string | number;
poster_url?: string;
title?: string;
xtream_id?: string | number;
})
| null
>(() => {
const vodId = this.selectedVodId();
if (!Number.isFinite(vodId) || vodId <= 0) {
@@ -120,29 +127,27 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
}
return (
this.xtreamStore
.vodStreams()
.find(
(item) =>
Number(
this.xtreamStore.vodStreams().find(
(item) =>
Number(
(
item as XtreamVodStream & {
id?: string | number;
xtream_id?: string | number;
}
).xtream_id ??
(
item as XtreamVodStream & {
id?: string | number;
xtream_id?: string | number;
}
).xtream_id ??
(
item as XtreamVodStream & {
id?: string | number;
}
).stream_id ??
(
item as XtreamVodStream & {
id?: string | number;
}
).id
) === vodId
) ?? null
).stream_id ??
(
item as XtreamVodStream & {
id?: string | number;
}
).id
) === vodId
) ?? null
);
});
readonly selectedVodInfo = computed(() => {
@@ -278,9 +283,15 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
effect(() => {
const playlistId = this.xtreamStore.currentPlaylist()?.id;
const vodId = Number(this.route.snapshot.params.vodId);
const backdropUrl = this.selectedVodInfo()?.backdrop_path?.[0]?.trim();
const backdropUrl =
this.selectedVodInfo()?.backdrop_path?.[0]?.trim();
if (!playlistId || !Number.isFinite(vodId) || vodId <= 0 || !backdropUrl) {
if (
!playlistId ||
!Number.isFinite(vodId) ||
vodId <= 0 ||
!backdropUrl
) {
return;
}
@@ -302,7 +313,8 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
this.unsubscribePositionUpdates =
window.electron.onPlaybackPositionUpdate(
(data: PlaybackPositionData) => {
const playlistId = this.xtreamStore.currentPlaylist()?.id;
const playlistId =
this.xtreamStore.currentPlaylist()?.id;
const vodId = Number(this.route.snapshot.params.vodId);
if (
@@ -325,7 +337,10 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
this.logger.warn('Deferring VOD details init: playlist not ready');
return;
}
this.initializeVodDetails(currentPlaylist.id, Number(this.route.snapshot.params.vodId));
this.initializeVodDetails(
currentPlaylist.id,
Number(this.route.snapshot.params.vodId)
);
this.detailsInitDone.set(true);
}
@@ -335,7 +350,16 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
this.xtreamStore.setSelectedItem(null);
}
playVod(vodItem: XtreamVodDetails): void {
playVod(vodItem: XtreamVodDetails | null): void {
if (!vodItem) {
return;
}
const playlist = this.xtreamStore.currentPlaylist();
if (!playlist) {
return;
}
const info = getXtreamVodInfo(vodItem);
this.addToRecentlyViewed();
const streamUrl = this.xtreamStore.constructVodStreamUrl(vodItem);
@@ -350,13 +374,13 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
this.logger.debug('playVod resolved ID', { id, vodItem });
const contentInfo: PlayerContentInfo = {
playlistId: this.xtreamStore.currentPlaylist().id,
playlistId: playlist.id,
contentXtreamId: id,
contentType: 'vod',
};
const playback: ResolvedPortalPlayback = {
streamUrl,
title: info?.name ?? vodItem.movie_data?.name,
title: info?.name ?? vodItem.movie_data?.name ?? 'Unknown',
thumbnail: info?.movie_image,
contentInfo,
};
@@ -364,7 +388,16 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
this.startPlayback(playback);
}
resumeVod(vodItem: XtreamVodDetails): void {
resumeVod(vodItem: XtreamVodDetails | null): void {
if (!vodItem) {
return;
}
const playlist = this.xtreamStore.currentPlaylist();
if (!playlist) {
return;
}
const info = getXtreamVodInfo(vodItem);
this.addToRecentlyViewed();
const vodId = Number(this.route.snapshot.params.vodId);
@@ -372,13 +405,13 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
const streamUrl = this.xtreamStore.constructVodStreamUrl(vodItem);
const contentInfo: PlayerContentInfo = {
playlistId: this.xtreamStore.currentPlaylist().id,
playlistId: playlist.id,
contentXtreamId: vodId,
contentType: 'vod',
};
const playback: ResolvedPortalPlayback = {
streamUrl,
title: info?.name ?? vodItem.movie_data?.name,
title: info?.name ?? vodItem.movie_data?.name ?? 'Unknown',
thumbnail: info?.movie_image,
startTime: position?.positionSeconds,
contentInfo,
@@ -387,7 +420,11 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
this.startPlayback(playback);
}
onPrimaryAction(vodItem: XtreamVodDetails): void {
onPrimaryAction(vodItem: XtreamVodDetails | null): void {
if (!vodItem) {
return;
}
if (this.isExternalStopAction()) {
void this.stopExternalPlayback();
return;
@@ -402,7 +439,9 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
}
async stopExternalPlayback(): Promise<void> {
await this.externalPlayback.closeSession(this.matchedExternalPlayback());
await this.externalPlayback.closeSession(
this.matchedExternalPlayback()
);
}
formatPosition(): string {
@@ -412,15 +451,18 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
const date = new Date(0);
date.setSeconds(position.positionSeconds);
const timeString = date.toISOString().substr(11, 8);
return timeString.startsWith('00:')
? timeString.substr(3)
: timeString;
return timeString.startsWith('00:') ? timeString.substr(3) : timeString;
}
toggleFavorite(): void {
const playlist = this.xtreamStore.currentPlaylist();
if (!playlist) {
return;
}
this.xtreamStore.toggleFavorite(
this.route.snapshot.params.vodId,
this.xtreamStore.currentPlaylist().id,
playlist.id,
'movie',
this.selectedVodInfo()?.backdrop_path?.[0]
);
@@ -462,7 +504,7 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
showCopyNotification(): void {
this.snackBar.open(
this.translateService.instant('PORTALS.STREAM_URL_COPIED'),
null,
undefined,
{
duration: 2000,
}
@@ -476,7 +518,11 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
);
}
async downloadVod(vodItem: XtreamVodDetails): Promise<void> {
async downloadVod(vodItem: XtreamVodDetails | null): Promise<void> {
if (!vodItem) {
return;
}
const info = getXtreamVodInfo(vodItem);
const streamUrl = this.xtreamStore.constructVodStreamUrl(vodItem);
const routeVodId = this.route.snapshot.params.vodId;
@@ -488,6 +534,9 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
);
const playlist = this.xtreamStore.currentPlaylist();
if (!playlist) {
return;
}
await this.downloadsService.startDownload({
playlistId: playlist.id,
@@ -41,7 +41,7 @@ interface XtreamCollectionStateSnapshot {
<ng-container
*ngComponentOutlet="
detailComponent();
injector: detailInjector()
injector: detailInjector() ?? undefined
"
/>
} @else {
+25 -1
View File
@@ -4,12 +4,36 @@ type ElectronProcessWindow = Window & {
};
};
export interface ElectronRemoteProcess {
argv: string[];
platform:
| 'aix'
| 'android'
| 'darwin'
| 'freebsd'
| 'haiku'
| 'linux'
| 'openbsd'
| 'sunos'
| 'win32'
| 'cygwin'
| 'netbsd'
| string;
}
export interface ElectronRemote {
app?: {
getVersion: () => string;
};
process: ElectronRemoteProcess;
}
export abstract class DataService {
get isElectron(): boolean {
const currentWindow = window as ElectronProcessWindow;
return Boolean(currentWindow.process?.type);
}
get remote() {
get remote(): ElectronRemote | null {
return null;
}
get ipcRenderer() {
@@ -93,7 +93,7 @@
(contextMenuRequested)="onChannelContextMenu(channel, $event)"
[selected]="activeChannelUrl() === channel?.url"
[showFavoriteButton]="true"
[isFavorite]="favoriteIds().has(channel?.url)"
[isFavorite]="favoriteIds().has(channel?.url ?? '')"
(favoriteToggled)="onFavoriteToggle(channel, $event)"
/>
</cdk-virtual-scroll-viewport>
@@ -74,7 +74,10 @@
}
</div>
} @else if (showSeasonEmptyState()) {
<div class="empty-state-panel empty-state-panel--season" role="status">
<div
class="empty-state-panel empty-state-panel--season"
role="status"
>
<div class="empty-state-panel__icon" aria-hidden="true">
<mat-icon>video_library</mat-icon>
</div>
@@ -109,7 +112,7 @@
<div class="episode-card__thumbnail">
@if (episodeInfo?.movie_image) {
<img
[src]="episodeInfo.movie_image"
[src]="episodeInfo?.movie_image ?? ''"
(error)="
$event.target.src =
'./assets/images/default-episode.png'
@@ -126,8 +129,13 @@
<app-progress-capsule
[progress]="getEpisodeProgress(episode)"
/>
@if (getEpisodePositionText(episode); as time) {
<div class="episode-card__position-text">
@if (
getEpisodePositionText(episode);
as time
) {
<div
class="episode-card__position-text"
>
{{ time }}
</div>
}
@@ -141,17 +149,30 @@
<button
mat-icon-button
class="download-btn download-btn--completed"
(click)="playFromLocal($event, episode)"
[matTooltip]="'DOWNLOADS.PLAY_LOCAL' | translate"
(click)="
playFromLocal(
$event,
episode
)
"
[matTooltip]="
'DOWNLOADS.PLAY_LOCAL'
| translate
"
>
<mat-icon>play_circle</mat-icon>
</button>
} @else if (isEpisodeDownloading(episode)) {
} @else if (
isEpisodeDownloading(episode)
) {
<button
mat-icon-button
class="download-btn download-btn--downloading"
disabled
[matTooltip]="'DOWNLOADS.STATUS.DOWNLOADING' | translate"
[matTooltip]="
'DOWNLOADS.STATUS.DOWNLOADING'
| translate
"
>
<mat-icon>downloading</mat-icon>
</button>
@@ -159,8 +180,16 @@
<button
mat-icon-button
class="download-btn"
(click)="downloadEpisode($event, episode)"
[matTooltip]="'DOWNLOADS.DOWNLOAD' | translate"
(click)="
downloadEpisode(
$event,
episode
)
"
[matTooltip]="
'DOWNLOADS.DOWNLOAD'
| translate
"
>
<mat-icon>download</mat-icon>
</button>
@@ -173,7 +202,8 @@
[matTooltip]="
(isEpisodeWatched(episode)
? 'XTREAM.MARK_UNWATCHED'
: 'XTREAM.MARK_WATCHED') | translate
: 'XTREAM.MARK_WATCHED'
) | translate
"
>
<mat-icon>{{
@@ -186,7 +216,9 @@
<div class="episode-card__overlay">
<div class="episode-card__play">
@if (isEpisodeLaunching(episode)) {
<mat-spinner diameter="26"></mat-spinner>
<mat-spinner
diameter="26"
></mat-spinner>
} @else {
<mat-icon>{{
isEpisodeActiveExternal(episode)
@@ -202,16 +234,17 @@
</div>
<div class="episode-card__info">
<h4 class="episode-card__title">
{{ episode.episode_num }}. {{ episode.title }}
{{ episode.episode_num }}.
{{ episode.title }}
</h4>
@if (episodeInfo?.plot) {
<p class="episode-card__plot">
{{ episodeInfo.plot }}
{{ episodeInfo?.plot }}
</p>
}
@if (episodeInfo?.duration) {
<span class="episode-card__duration">
{{ episodeInfo.duration }}
{{ episodeInfo?.duration }}
</span>
}
</div>
@@ -226,10 +259,18 @@
<article
class="episode-list-item"
(click)="selectEpisode(episode)"
[class.episode-list-item--watched]="isEpisodeWatched(episode)"
[class.episode-list-item--in-progress]="isEpisodeInProgress(episode)"
[class.episode-list-item--launching]="isEpisodeLaunching(episode)"
[class.episode-list-item--active-external]="isEpisodeActiveExternal(episode)"
[class.episode-list-item--watched]="
isEpisodeWatched(episode)
"
[class.episode-list-item--in-progress]="
isEpisodeInProgress(episode)
"
[class.episode-list-item--launching]="
isEpisodeLaunching(episode)
"
[class.episode-list-item--active-external]="
isEpisodeActiveExternal(episode)
"
>
<div class="episode-list-item__number">
{{ episode.episode_num }}
@@ -239,17 +280,26 @@
<h4 class="episode-list-item__title">
{{ episode.title }}
@if (isEpisodeWatched(episode)) {
<mat-icon class="watched-icon">check_circle</mat-icon>
<mat-icon class="watched-icon"
>check_circle</mat-icon
>
}
</h4>
<div class="episode-list-item__meta">
@if (episodeInfo?.duration) {
<span class="episode-list-item__duration">
{{ episodeInfo.duration }}
<span
class="episode-list-item__duration"
>
{{ episodeInfo?.duration }}
</span>
}
@if (getEpisodePositionText(episode); as time) {
<span class="episode-list-item__progress-text">
@if (
getEpisodePositionText(episode);
as time
) {
<span
class="episode-list-item__progress-text"
>
{{ time }}
</span>
}
@@ -257,14 +307,18 @@
</div>
@if (episodeInfo?.plot) {
<p class="episode-list-item__description">
{{ episodeInfo.plot }}
{{ episodeInfo?.plot }}
</p>
}
@if (getEpisodeProgress(episode) > 0) {
<div class="episode-list-item__progress-bar">
<div
class="episode-list-item__progress-bar"
>
<div
class="episode-list-item__progress-fill"
[style.width.%]="getEpisodeProgress(episode)"
[style.width.%]="
getEpisodeProgress(episode)
"
></div>
</div>
}
@@ -273,12 +327,17 @@
<button
mat-icon-button
class="action-btn play-btn"
(click)="selectEpisode(episode); $event.stopPropagation()"
(click)="
selectEpisode(episode);
$event.stopPropagation()
"
[matTooltip]="'Play'"
[disabled]="isEpisodeLaunching(episode)"
>
@if (isEpisodeLaunching(episode)) {
<mat-spinner diameter="18"></mat-spinner>
<mat-spinner
diameter="18"
></mat-spinner>
} @else {
<mat-icon>{{
isEpisodeActiveExternal(episode)
@@ -292,8 +351,13 @@
<button
mat-icon-button
class="action-btn action-btn--success"
(click)="playFromLocal($event, episode)"
[matTooltip]="'DOWNLOADS.PLAY_LOCAL' | translate"
(click)="
playFromLocal($event, episode)
"
[matTooltip]="
'DOWNLOADS.PLAY_LOCAL'
| translate
"
>
<mat-icon>folder_open</mat-icon>
</button>
@@ -302,7 +366,10 @@
mat-icon-button
class="action-btn"
disabled
[matTooltip]="'DOWNLOADS.STATUS.DOWNLOADING' | translate"
[matTooltip]="
'DOWNLOADS.STATUS.DOWNLOADING'
| translate
"
>
<mat-icon>downloading</mat-icon>
</button>
@@ -310,8 +377,12 @@
<button
mat-icon-button
class="action-btn"
(click)="downloadEpisode($event, episode)"
[matTooltip]="'DOWNLOADS.DOWNLOAD' | translate"
(click)="
downloadEpisode($event, episode)
"
[matTooltip]="
'DOWNLOADS.DOWNLOAD' | translate
"
>
<mat-icon>download</mat-icon>
</button>
@@ -324,9 +395,12 @@
[matTooltip]="
(isEpisodeWatched(episode)
? 'XTREAM.MARK_UNWATCHED'
: 'XTREAM.MARK_WATCHED') | translate
: 'XTREAM.MARK_WATCHED'
) | translate
"
[class.action-btn--success]="
isEpisodeWatched(episode)
"
[class.action-btn--success]="isEpisodeWatched(episode)"
>
<mat-icon>{{
isEpisodeWatched(episode)
@@ -51,7 +51,7 @@ export class EmbeddedMpvSessionController {
this.session.set(session);
});
if (window.electron?.getEmbeddedMpvSupport) {
if (typeof window.electron?.getEmbeddedMpvSupport === 'function') {
void this.loadSupport();
} else {
this.support.set({
@@ -0,0 +1,3 @@
export * from './embedded-mpv-overlay-visibility.service';
export * from './embedded-mpv-player.component';
export * from './embedded-mpv-session-controller';
@@ -174,7 +174,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
audioTracks.addEventListener('addtrack', () => {
debugVjsPlayer(
'[AudioTrack] addtrack event fired, total tracks:',
this.player.audioTracks().length
audioTracks.length
);
this.logAudioTracks();
this.setupAudioTrackMenu();
@@ -182,7 +182,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
audioTracks.addEventListener('removetrack', () => {
debugVjsPlayer(
'[AudioTrack] removetrack event fired, total tracks:',
this.player.audioTracks().length
audioTracks.length
);
this.logAudioTracks();
this.setupAudioTrackMenu();
@@ -193,19 +193,19 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
}
this.player.on('volumechange', () => {
const currentVolume = this.player.volume();
const currentVolume = this.player.volume() ?? this.volume();
localStorage.setItem('volume', currentVolume.toString());
});
this.player.on('timeupdate', () => {
this.timeUpdate.emit({
currentTime: this.player.currentTime(),
duration: this.player.duration(),
currentTime: this.player.currentTime() ?? 0,
duration: this.player.duration() ?? 0,
});
});
// Attach mpegts.js after Video.js is ready
if (isMpegTs) {
if (isMpegTs && source) {
this.initMpegTs(source.src);
}
}) as unknown as VideoJsPlayer;
@@ -311,7 +311,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
private initMpegTs(url: string): void {
const videoEl = this.player
.tech({ IWillNotUseThisInPlugins: true })
?.el();
?.el?.();
if (!videoEl) return;
debugVjsPlayer('Using mpegts.js for TS stream:', url);
@@ -473,7 +473,11 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
'[AudioTrack] Audio tracks count:',
audioTracks?.length ?? 0
);
for (let i = 0; i < (audioTracks?.length ?? 0); i++) {
if (!audioTracks) {
return;
}
for (let i = 0; i < audioTracks.length; i++) {
const t = audioTracks[i];
debugVjsPlayer(
`[AudioTrack] Track ${i}: label="${t.label}", language="${t.language}", enabled=${t.enabled}, kind="${t.kind}"`
@@ -529,9 +533,9 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
}
let audioButton =
controlBar.getChild('audioTrackButton') ??
controlBar.getChild('AudioTrackButton');
if (!audioButton) {
controlBar.getChild?.('audioTrackButton') ??
controlBar.getChild?.('AudioTrackButton');
if (!audioButton && controlBar.addChild) {
audioButton = controlBar.addChild('audioTrackButton', {});
}
@@ -1,4 +1,4 @@
@if (items()?.length > 0) {
@if ((items()?.length ?? 0) > 0) {
<div class="nav-list">
@if (statusText()) {
<div class="context-inline-status">
+3
View File
@@ -45,6 +45,9 @@
"@iptvnator/ui/playback/external-player-info-dialog": [
"libs/ui/playback/src/lib/external-player-info-dialog/external-player-info-dialog.component.ts"
],
"@iptvnator/ui/playback/embedded-mpv-player": [
"libs/ui/playback/src/lib/embedded-mpv-player/index.ts"
],
"@iptvnator/ui/playback": ["libs/ui/playback/src/index.ts"],
"@iptvnator/playlist/import/feature": [
"libs/playlist/import/feature/src/index.ts"