mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
chore(web): enable strict TypeScript mode
This commit is contained in:
1 parent
ff9c13058c
commit
4ab8915483
55 files changed
+675
-369
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
)
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
Vendored
+2
-1
@@ -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;
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"typeRoots": ["../../node_modules/@types"],
|
||||
"lib": ["es2018", "es2017", "es2016", "es2015", "dom"],
|
||||
"resolveJsonModule": true,
|
||||
"strict": false,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"useDefineForClassFields": false
|
||||
},
|
||||
|
||||
+1
-1
@@ -109,7 +109,7 @@
|
||||
[disabled]="!textImport()?.textForm?.valid"
|
||||
(click)="
|
||||
textImport()?.textAdded?.emit(
|
||||
textImport()?.textForm?.value?.text
|
||||
textImport()?.textForm?.value?.text ?? undefined
|
||||
)
|
||||
"
|
||||
>
|
||||
|
||||
+7
-7
@@ -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);
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+9
-2
@@ -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
|
||||
|
||||
+2
-2
@@ -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);
|
||||
|
||||
+5
-5
@@ -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
|
||||
|
||||
+7
-6
@@ -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())
|
||||
|
||||
+2
-2
@@ -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()"
|
||||
|
||||
+35
-17
@@ -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 &&
|
||||
|
||||
+9
-17
@@ -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
|
||||
/>
|
||||
}
|
||||
|
||||
+4
-3
@@ -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 (
|
||||
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
+1
-1
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
@if (items()?.length > 0) {
|
||||
@if ((items()?.length ?? 0) > 0) {
|
||||
<div class="nav-list">
|
||||
@if (statusText()) {
|
||||
<div class="context-inline-status">
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user