Merge pull request #651 from 4gray/feat-account-info-add-translations

feat-account-info-add-translations
This commit is contained in:
4gray authored and GitHub committed 2025-11-15 18:56:11 +01:00
commit ba5662edaa
27 files changed
+635 -575

No files matched your search

+3 -1
View File
@@ -3,7 +3,9 @@
"allow": [
"Bash(tree:*)",
"WebSearch",
"WebFetch(domain:www.electronjs.org)"
"WebFetch(domain:www.electronjs.org)",
"Bash(npm run build:frontend:*)",
"Bash(cat:*)"
],
"deny": [],
"ask": []
+5 -61
View File
@@ -12,20 +12,16 @@ import { provideMockStore } from '@ngrx/store/testing';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { MockComponent, MockModule, MockPipe, MockProviders } from 'ng-mocks';
import { NgxIndexedDBService } from 'ngx-indexed-db';
import { NgxWhatsNewComponent } from 'ngx-whats-new';
import { of } from 'rxjs';
import { DataService } from '../../../../libs/services/src/lib/data.service';
import { PlaylistsService } from '../../../../libs/services/src/lib/playlists.service';
import { Language } from '../../../../libs/shared/interfaces/src/lib/language.enum';
import { STORE_KEY } from '../../../../libs/shared/interfaces/src/lib/store-keys.enum';
import { Theme } from '../../../../libs/shared/interfaces/src/lib/theme.enum';
import { DataService, PlaylistsService } from 'services';
import { Language, STORE_KEY, Theme } from 'shared-interfaces';
import { AppComponent } from './app.component';
import { ElectronServiceStub } from './services/electron.service.stub';
import { SettingsService } from './services/settings.service';
import { WhatsNewService } from './services/whats-new.service';
import { WhatsNewServiceStub } from './services/whats-new.service.stub';
jest.spyOn(global.console, 'error').mockImplementation(() => {});
jest.spyOn(global.console, 'error').mockImplementation(() => {
// suppress console.error output during tests
});
describe('AppComponent', () => {
let component: AppComponent;
@@ -33,14 +29,12 @@ describe('AppComponent', () => {
let fixture: ComponentFixture<AppComponent>;
let settingsService: SettingsService;
let translateService: TranslateService;
let whatsNewService: WhatsNewService;
const defaultLanguage = 'en';
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [AppComponent, MockPipe(TranslatePipe)],
providers: [
{ provide: WhatsNewService, useClass: WhatsNewServiceStub },
MockProviders(
TranslateService,
PlaylistsService,
@@ -68,7 +62,6 @@ describe('AppComponent', () => {
fixture = TestBed.createComponent(AppComponent);
settingsService = TestBed.inject(SettingsService);
translateService = TestBed.inject(TranslateService);
whatsNewService = TestBed.inject(WhatsNewService);
component = fixture.componentInstance;
// TODO: investigate in detail
@@ -128,14 +121,6 @@ describe('AppComponent', () => {
expect(router.navigateByUrl).toHaveBeenCalledWith(route);
}
));
it('show show whats new dialog', () => {
jest.spyOn(whatsNewService, 'getModalsByVersion');
jest.spyOn(component, 'setDialogVisibility');
component.showWhatsNewDialog();
expect(whatsNewService.getModalsByVersion).toHaveBeenCalledTimes(1);
expect(component.setDialogVisibility).toHaveBeenCalledWith(true);
});
});
describe('Test version handling', () => {
@@ -144,17 +129,8 @@ describe('AppComponent', () => {
const spyOnSettingsGet = jest
.spyOn(settingsService, 'getValueFromLocalStorage')
.mockReturnValue(of(currentAppVersion));
jest.spyOn(whatsNewService, 'getModalsByVersion').mockReturnValue([
{},
]);
jest.spyOn(whatsNewService, 'changeDialogVisibleState');
component.handleWhatsNewDialog();
expect(spyOnSettingsGet).toHaveBeenCalled();
expect(whatsNewService.getModalsByVersion).toHaveBeenCalled();
expect(
whatsNewService.changeDialogVisibleState
).toHaveBeenCalledWith(true);
});
it('should get actual app version which is not outdated and do not shop updates dialog', () => {
@@ -162,40 +138,8 @@ describe('AppComponent', () => {
const spyOnSettingsGet = jest
.spyOn(settingsService, 'getValueFromLocalStorage')
.mockReturnValue(of(currentAppVersion));
jest.spyOn(whatsNewService, 'getModalsByVersion').mockReturnValue([
{},
]);
jest.spyOn(whatsNewService, 'changeDialogVisibleState');
component.handleWhatsNewDialog();
expect(spyOnSettingsGet).toHaveBeenCalled();
expect(whatsNewService.getModalsByVersion).toHaveBeenCalledTimes(0);
expect(
whatsNewService.changeDialogVisibleState
).toHaveBeenCalledTimes(0);
});
it('should change the visibility of the whats new dialog', () => {
jest.spyOn(whatsNewService, 'changeDialogVisibleState');
component.modals = [{}, {}];
const visibilityFlag = true;
component.setDialogVisibility(true);
expect(
whatsNewService.changeDialogVisibleState
).toHaveBeenCalledWith(visibilityFlag);
expect(
whatsNewService.changeDialogVisibleState
).toHaveBeenCalledTimes(1);
});
it('should not change the visibility of the whats new dialog', () => {
jest.spyOn(whatsNewService, 'changeDialogVisibleState');
component.modals = [];
component.setDialogVisibility(true);
expect(
whatsNewService.changeDialogVisibleState
).toHaveBeenCalledTimes(0);
});
});
+69 -8
View File
@@ -2,6 +2,7 @@ import { HttpClient } from '@angular/common/http';
import { inject, Injectable } from '@angular/core';
import { StorageMap } from '@ngx-pwa/local-storage';
import { catchError, map, Observable } from 'rxjs';
import * as semver from 'semver';
import { STORE_KEY, Theme } from 'shared-interfaces';
@Injectable({
@@ -52,6 +53,7 @@ export class SettingsService {
/**
* Returns the version of the released app
* Filters out pre-release versions (beta, alpha, rc)
*/
getAppVersion() {
return this.http
@@ -59,14 +61,48 @@ export class SettingsService {
'https://api.github.com/repos/4gray/iptvnator/releases'
)
.pipe(
map(
(response) =>
response.sort(
(a, b) =>
new Date(b.created_at).getTime() -
new Date(a.created_at).getTime()
)[0]
),
map((response) => {
// Filter out pre-release versions (beta, alpha, rc, etc.)
const stableReleases = response.filter((release) => {
const releaseName = release.name.toLowerCase();
// Check for beta/alpha/rc keywords in the release name
const prereleaseKeywords = [
'beta',
'alpha',
'rc',
'preview',
'dev',
'canary',
'nightly',
];
const hasPrereleaseKeyword = prereleaseKeywords.some(
(keyword) => releaseName.includes(keyword)
);
if (hasPrereleaseKeyword) {
return false;
}
// Validate version format
const version = semver.valid(
semver.coerce(release.name)
);
if (!version) return false;
// Check if version has prerelease tags in semver format
return !semver.prerelease(release.name);
});
// Sort stable releases by creation date
const sortedReleases = stableReleases.sort(
(a, b) =>
new Date(b.created_at).getTime() -
new Date(a.created_at).getTime()
);
return sortedReleases[0];
}),
map((response) => response.name),
catchError((err) => {
console.error(err);
@@ -74,4 +110,29 @@ export class SettingsService {
})
);
}
/**
* Compares current version with latest version
* @param currentVersion current version of the app
* @param latestVersion latest stable version from GitHub
* @returns true if current version is outdated
*/
isVersionOutdated(
currentVersion: string,
latestVersion: string
): boolean {
// Clean and coerce versions to handle invalid formats
const cleanCurrent = semver.coerce(currentVersion);
const cleanLatest = semver.coerce(latestVersion);
if (!cleanCurrent || !cleanLatest) {
console.warn('Invalid version format:', {
currentVersion,
latestVersion,
});
return false;
}
return semver.lt(cleanCurrent, cleanLatest);
}
}
@@ -36,7 +36,6 @@ import { DialogService } from 'components';
import * as PlaylistActions from 'm3u-state';
import { selectIsEpgAvailable } from 'm3u-state';
import { take } from 'rxjs';
import * as semver from 'semver';
import { DataService, EpgService, PlaylistsService } from 'services';
import {
Language,
@@ -242,7 +241,10 @@ export class SettingsComponent implements OnInit {
*/
isCurrentVersionOutdated(latestVersion: string): boolean {
this.version = this.dataService.getAppVersion();
return semver.lt(this.version, latestVersion);
return this.settingsService.isVersionOutdated(
this.version,
latestVersion
);
}
/**
@@ -0,0 +1,114 @@
<h2 mat-dialog-title>{{ 'XTREAM.ACCOUNT_INFO.TITLE' | translate }}</h2>
<mat-dialog-content class="mat-typography">
@if (accountInfo?.user_info?.message) {
<div class="welcome-message">
{{ accountInfo?.user_info?.message }}
</div>
}
<div class="info-grid">
<div class="info-section">
<h3>{{ 'XTREAM.ACCOUNT_INFO.USER_INFO' | translate }}</h3>
<mat-list>
<mat-list-item>
{{ 'XTREAM.ACCOUNT_INFO.STATUS' | translate }}:
<span [class.active]="isActive">{{
accountInfo?.user_info?.status
}}</span>
</mat-list-item>
<mat-list-item>
{{ 'XTREAM.ACCOUNT_INFO.USERNAME' | translate }}:
{{ accountInfo?.user_info?.username }}
</mat-list-item>
<mat-list-item>
{{ 'XTREAM.ACCOUNT_INFO.ACTIVE_CONNECTIONS' | translate }}:
{{ accountInfo?.user_info?.active_cons }}/{{
accountInfo?.user_info?.max_connections
}}
</mat-list-item>
<mat-list-item>
{{ 'XTREAM.ACCOUNT_INFO.CREATED' | translate }}:
{{ formattedCreatedDate }}
</mat-list-item>
<mat-list-item>
{{ 'XTREAM.ACCOUNT_INFO.EXPIRES' | translate }}:
{{ formattedExpDate }}
</mat-list-item>
<mat-list-item>
{{ 'XTREAM.ACCOUNT_INFO.TRIAL_ACCOUNT' | translate }}:
<span [class.trial]="isTrial">{{
(isTrial
? 'XTREAM.ACCOUNT_INFO.YES'
: 'XTREAM.ACCOUNT_INFO.NO'
) | translate
}}</span>
</mat-list-item>
<mat-list-item>
{{ 'XTREAM.ACCOUNT_INFO.ALLOWED_FORMATS' | translate }}:
<span class="formats">
{{
accountInfo?.user_info?.allowed_output_formats?.join(
', '
)
}}
</span>
</mat-list-item>
</mat-list>
</div>
<div class="info-section">
<h3>{{ 'XTREAM.ACCOUNT_INFO.SERVER_INFO' | translate }}</h3>
<mat-list>
<mat-list-item>
{{ 'XTREAM.ACCOUNT_INFO.LIVE_TV' | translate }}:
{{ liveStreamsCount }}
</mat-list-item>
<mat-list-item>
{{ 'XTREAM.ACCOUNT_INFO.MOVIES' | translate }}:
{{ vodStreamsCount }}
</mat-list-item>
<mat-list-item>
{{ 'XTREAM.ACCOUNT_INFO.TV_SERIES' | translate }}:
{{ seriesCount }}
</mat-list-item>
<mat-list-item>
{{ 'XTREAM.ACCOUNT_INFO.URL' | translate }}:
{{ accountInfo?.server_info?.url }}
</mat-list-item>
<mat-list-item>
{{ 'XTREAM.ACCOUNT_INFO.PROTOCOL' | translate }}:
{{ accountInfo?.server_info?.server_protocol }}
</mat-list-item>
<mat-list-item>
{{ 'XTREAM.ACCOUNT_INFO.TIMEZONE' | translate }}:
{{ accountInfo?.server_info?.timezone }}
</mat-list-item>
<mat-list-item>
{{ 'XTREAM.ACCOUNT_INFO.SERVER_TIME' | translate }}:
{{ accountInfo?.server_info?.time_now }}
</mat-list-item>
<mat-list-item>
{{ 'XTREAM.ACCOUNT_INFO.PORTS' | translate }}:
<div class="ports">
<span
>{{ 'XTREAM.ACCOUNT_INFO.HTTP_PORT' | translate }}:
{{ accountInfo?.server_info?.port }}</span
>
<span
>{{ 'XTREAM.ACCOUNT_INFO.HTTPS_PORT' | translate }}:
{{ accountInfo?.server_info?.https_port }}</span
>
<span
>{{ 'XTREAM.ACCOUNT_INFO.RTMP_PORT' | translate }}:
{{ accountInfo?.server_info?.rtmp_port }}</span
>
</div>
</mat-list-item>
</mat-list>
</div>
</div>
</mat-dialog-content>
<mat-dialog-actions>
<button mat-button mat-dialog-close color="accent">
{{ 'CLOSE' | translate }}
</button>
</mat-dialog-actions>
@@ -1,4 +1,4 @@
import { Component, Inject, inject } from '@angular/core';
import { Component, inject } from '@angular/core';
import { MatButton } from '@angular/material/button';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { MatListModule } from '@angular/material/list';
@@ -11,150 +11,32 @@ import { XtreamAccountInfo } from './account-info.interface';
@Component({
selector: 'app-account-info',
imports: [MatButton, MatDialogModule, MatListModule, TranslatePipe],
template: `
<h2 mat-dialog-title>Account Information</h2>
<mat-dialog-content class="mat-typography">
@if (accountInfo?.user_info?.message) {
<div class="welcome-message">
{{ accountInfo?.user_info?.message }}
</div>
}
<div class="info-grid">
<div class="info-section">
<h3>User Information</h3>
<mat-list>
<mat-list-item>
Status:
<span [class.active]="isActive">{{
accountInfo?.user_info?.status
}}</span>
</mat-list-item>
<mat-list-item>
Username:
{{ accountInfo?.user_info?.username }}
</mat-list-item>
<mat-list-item>
Active Connections:
{{ accountInfo?.user_info?.active_cons }}/{{
accountInfo?.user_info?.max_connections
}}
</mat-list-item>
<mat-list-item>
Created:
{{ formattedCreatedDate }}
</mat-list-item>
<mat-list-item>
Expires: {{ formattedExpDate }}
</mat-list-item>
<mat-list-item>
Trial Account:
<span [class.trial]="isTrial">{{
isTrial ? 'Yes' : 'No'
}}</span>
</mat-list-item>
<mat-list-item>
Allowed Formats:
<span class="formats">
{{
accountInfo?.user_info?.allowed_output_formats?.join(
', '
)
}}
</span>
</mat-list-item>
</mat-list>
</div>
<div class="info-section">
<h3>Server Information</h3>
<mat-list>
<mat-list-item>
Live TV:
{{ liveStreamsCount }}
</mat-list-item>
<mat-list-item>
Movies:
{{ vodStreamsCount }}
</mat-list-item>
<mat-list-item>
TV Series:
{{ seriesCount }}
</mat-list-item>
<mat-list-item>
URL:
{{ accountInfo?.server_info?.url }}
</mat-list-item>
<mat-list-item>
Protocol:
{{ accountInfo?.server_info?.server_protocol }}
</mat-list-item>
<mat-list-item>
Timezone:
{{ accountInfo?.server_info?.timezone }}
</mat-list-item>
<mat-list-item>
Server Time:
{{ accountInfo?.server_info?.time_now }}
</mat-list-item>
<mat-list-item>
Ports:
<div class="ports">
<span
>HTTP:
{{ accountInfo?.server_info?.port }}</span
>
<span
>HTTPS:
{{
accountInfo?.server_info?.https_port
}}</span
>
<span
>RTMP:
{{
accountInfo?.server_info?.rtmp_port
}}</span
>
</div>
</mat-list-item>
</mat-list>
</div>
</div>
</mat-dialog-content>
<mat-dialog-actions>
<button mat-button mat-dialog-close color="accent">
{{ 'CLOSE' | translate }}
</button>
</mat-dialog-actions>
`,
templateUrl: './account-info.component.html',
styleUrl: './account-info.component.scss',
})
export class AccountInfoComponent {
accountInfo: XtreamAccountInfo;
formattedExpDate: string;
formattedCreatedDate: string;
readonly data = inject<{
vodStreamsCount: number;
liveStreamsCount: number;
seriesCount: number;
}>(MAT_DIALOG_DATA);
private readonly dataService = inject(DataService);
private readonly store = inject(Store);
accountInfo: XtreamAccountInfo;
formattedExpDate: string;
formattedCreatedDate: string;
vodStreamsCount: number;
liveStreamsCount: number;
seriesCount: number;
readonly currentPlaylist = this.store.selectSignal(selectActivePlaylist);
constructor(
@Inject(MAT_DIALOG_DATA)
data: {
vodStreamsCount: number;
liveStreamsCount: number;
seriesCount: number;
}
) {
constructor() {
this.setAccountInfo();
this.vodStreamsCount = data.vodStreamsCount;
this.liveStreamsCount = data.liveStreamsCount;
this.seriesCount = data.seriesCount;
this.vodStreamsCount = this.data.vodStreamsCount;
this.liveStreamsCount = this.data.liveStreamsCount;
this.seriesCount = this.data.seriesCount;
}
async setAccountInfo() {
@@ -170,7 +52,7 @@ export class AccountInfoComponent {
action: 'get_account_info',
}
);
console.log(this.accountInfo);
if (this.accountInfo) {
this.formattedExpDate = new Date(
parseInt(this.accountInfo.user_info.exp_date) * 1000
@@ -1,6 +1,5 @@
import { Component, inject, OnInit } from '@angular/core';
import { MatCardModule } from '@angular/material/card';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { PageEvent } from '@angular/material/paginator';
import { ActivatedRoute, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslatePipe } from '@ngx-translate/core';
@@ -16,13 +15,11 @@ import { XtreamStore } from '../xtream.store';
templateUrl: './category-content-view.component.html',
styleUrls: ['./category-content-view.component.scss'],
imports: [
MatCardModule,
MatPaginatorModule,
GridListComponent,
PlaylistErrorViewComponent,
StalkerSeriesViewComponent,
TranslatePipe,
VodDetailsComponent,
GridListComponent,
StalkerSeriesViewComponent,
],
})
export class CategoryContentViewComponent implements OnInit {
@@ -49,7 +46,11 @@ export class CategoryContentViewComponent implements OnInit {
ngOnInit() {
const { categoryId } = this.activatedRoute.snapshot.params;
if (categoryId) this.store.setSelectedCategory(categoryId);
// Only set category if it's different from the currently selected one
// This preserves the page state when navigating back from detail view
if (categoryId && this.store.selectedCategoryId() !== Number(categoryId)) {
this.store.setSelectedCategory(categoryId);
}
}
onPageChange(event: PageEvent) {
@@ -40,6 +40,9 @@
</div>
} @else {
<div class="no-channel-selected">
<button mat-icon-button disabled>
<mat-icon>category</mat-icon>
</button>
{{ 'PORTALS.SELECT_CATEGORY' | translate }}
</div>
}
@@ -53,6 +53,9 @@
margin: 0;
font-size: 1.2rem;
font-weight: 500;
display: flex;
align-items: center;
gap: 10px;
}
::ng-deep {
@@ -1,20 +1,13 @@
<h2 mat-dialog-title>{{ title }}</h2>
<mat-dialog-content class="content">
<app-web-player-view [streamUrl]="streamUrl" />
<mat-form-field class="link-input">
<input
matInput
formControlName="streamUrl"
[value]="streamUrl"
disabled
/>
</mat-form-field>
</mat-dialog-content>
<mat-dialog-actions class="align-actions">
<button
mat-button
[cdkCopyToClipboard]="streamUrl"
(click)="showCopyNotification()"
[matTooltip]="streamUrl"
>
<mat-icon>content_copy</mat-icon>
{{ 'PORTALS.COPY_STREAM_URL' | translate }}
@@ -5,12 +5,6 @@
padding: 10px !important;
}
.link-input {
width: 100%;
padding-top: 5px;
@include mat.form-field-density(-5);
}
.align-actions {
justify-content: space-between;
}
@@ -1,12 +1,11 @@
import { ClipboardModule } from '@angular/cdk/clipboard';
import { Component, Inject, ViewEncapsulation } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { Component, inject, ViewEncapsulation } from '@angular/core';
import { MatButton } from '@angular/material/button';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatIcon } from '@angular/material/icon';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { MatTooltip } from '@angular/material/tooltip';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { WebPlayerViewComponent } from 'shared-portals';
export interface PlayerDialogData {
@@ -18,28 +17,27 @@ export interface PlayerDialogData {
templateUrl: './player-dialog.component.html',
imports: [
ClipboardModule,
MatButtonModule,
MatButton,
MatDialogModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
TranslateModule,
MatIcon,
MatTooltip,
TranslatePipe,
WebPlayerViewComponent,
],
styleUrl: './player-dialog.component.scss',
encapsulation: ViewEncapsulation.None
encapsulation: ViewEncapsulation.None,
})
export class PlayerDialogComponent {
title: string;
streamUrl: string;
readonly data = inject<PlayerDialogData>(MAT_DIALOG_DATA);
private snackBar = inject(MatSnackBar);
private translateService = inject(TranslateService);
constructor(
@Inject(MAT_DIALOG_DATA) data: PlayerDialogData,
private snackBar: MatSnackBar,
private translateService: TranslateService
) {
this.streamUrl = data.streamUrl;
this.title = data.title;
readonly title: string;
readonly streamUrl: string;
constructor() {
this.streamUrl = this.data.streamUrl;
this.title = this.data.title;
}
showCopyNotification() {
@@ -1,9 +1,9 @@
import { CommonModule, KeyValuePipe } from '@angular/common';
import { DatePipe, KeyValuePipe, TitleCasePipe } from '@angular/common';
import { Component, computed, inject, Optional } from '@angular/core';
import { MatButtonModule, MatIconButton } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatButton, MatIconButton } from '@angular/material/button';
import { MatCard } from '@angular/material/card';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatIcon } from '@angular/material/icon';
import { ActivatedRoute, Router } from '@angular/router';
import groupBy from 'lodash/groupBy';
import { XtreamStore } from '../xtream.store';
@@ -11,12 +11,14 @@ import { XtreamStore } from '../xtream.store';
@Component({
selector: 'app-recently-viewed',
imports: [
CommonModule,
MatCardModule,
MatButtonModule,
MatIconModule,
DatePipe,
KeyValuePipe,
MatButton,
MatCard,
MatIcon,
MatIconButton,
MatIconButton,
TitleCasePipe,
],
templateUrl: './recently-viewed.component.html',
styleUrl: './recently-viewed.component.scss',
@@ -21,37 +21,35 @@
(error)="
$event.target.src = './assets/images/default-poster.png'
"
alt="Movie poster"
/>
} @else {
<img src="./assets/images/default-poster.png" />
<img
src="./assets/images/default-poster.png"
alt="Default poster"
/>
}
</div>
<div class="details">
<h2>{{ item?.info?.name || item?.movie_data?.name }}</h2>
@if (item?.info?.description || item?.info?.plot) {
<label>
<div>
{{ item?.info?.description || item?.info?.plot }}
</label>
</div>
}
@if (item?.info?.releasedate) {
<label>
<div class="label">
{{ 'XTREAM.RELEASE_DATE' | translate }}:
</div>
{{ item?.info?.releasedate }}
</label>
<div class="label">
{{ 'XTREAM.RELEASE_DATE' | translate }}:
</div>
{{ item?.info?.releasedate }}
}
@if (item?.info?.genre) {
<label>
<div class="label">{{ 'XTREAM.GENRE' | translate }}:</div>
{{ item?.info?.genre }}
</label>
<div class="label">{{ 'XTREAM.GENRE' | translate }}:</div>
{{ item?.info?.genre }}
}
@if (item?.info?.country) {
<label>
<div class="label">{{ 'XTREAM.COUNTRY' | translate }}:</div>
{{ item?.info?.country }}
</label>
<div class="label">{{ 'XTREAM.COUNTRY' | translate }}:</div>
{{ item?.info?.country }}
}
@if (item?.info?.actors || item?.info?.cast) {
<div>
@@ -60,36 +58,22 @@
</div>
}
@if (item?.info?.director) {
<label>
<div class="label">
{{ 'XTREAM.DIRECTOR' | translate }}:
</div>
{{ item?.info?.director }}
</label>
<div class="label">{{ 'XTREAM.DIRECTOR' | translate }}:</div>
{{ item?.info?.director }}
}
@if (item?.info?.duration) {
<label>
<div class="label">
{{ 'XTREAM.DURATION' | translate }}:
</div>
{{ item?.info?.duration }}
</label>
<div class="label">{{ 'XTREAM.DURATION' | translate }}:</div>
{{ item?.info?.duration }}
}
@if (item?.info?.rating_imdb) {
<label>
<div class="label">
{{ 'XTREAM.IMDB_RATING' | translate }}:
</div>
{{ item?.info?.rating_imdb }}
</label>
<div class="label">{{ 'XTREAM.IMDB_RATING' | translate }}:</div>
{{ item?.info?.rating_imdb }}
}
@if (item?.info?.rating_kinopoisk) {
<label>
<div class="label">
{{ 'XTREAM.KINOPOISK_RATING' | translate }}:
</div>
{{ item?.info?.rating_kinopoisk }}
</label>
<div class="label">
{{ 'XTREAM.KINOPOISK_RATING' | translate }}:
</div>
{{ item?.info?.rating_kinopoisk }}
}
<div class="action-buttons">
<button mat-flat-button color="accent" (click)="playVod(item)">
@@ -53,9 +53,19 @@ export class XtreamMainContainerComponent implements OnInit {
return this.translateService.instant('PORTALS.SELECT_CATEGORY');
} else {
const selectedCategory = this.xtreamStore.getSelectedCategory();
return selectedCategory
? `Content for ${(selectedCategory as any).name}`
const categoryName = selectedCategory
? (selectedCategory as any).name
: 'Category Content';
// Show page number when viewing category content (not detail view)
if (!this.xtreamStore.selectedItem() && this.xtreamStore.getTotalPages() > 1) {
const currentPage = this.xtreamStore.page() + 1; // +1 because page is 0-indexed
const totalPages = this.xtreamStore.getTotalPages();
const pageLabel = this.translateService.instant('PORTALS.PAGE');
return `${categoryName} (${pageLabel} ${currentPage}/${totalPages})`;
}
return `Content for ${categoryName}`;
}
}
@@ -1,25 +0,0 @@
<h2 mat-dialog-title>{{ title }}</h2>
<mat-dialog-content class="content">
<app-web-player-view [streamUrl]="streamUrl" />
<mat-form-field class="link-input">
<input
matInput
formControlName="streamUrl"
[value]="streamUrl"
disabled
/>
</mat-form-field>
</mat-dialog-content>
<mat-dialog-actions class="align-actions">
<button
mat-button
[cdkCopyToClipboard]="streamUrl"
(click)="showCopyNotification()"
>
<mat-icon>content_copy</mat-icon>
{{ 'PORTALS.COPY_STREAM_URL' | translate }}
</button>
<button mat-button mat-dialog-close color="accent">
{{ 'CLOSE' | translate }}
</button>
</mat-dialog-actions>
@@ -1,22 +0,0 @@
@use '@angular/material' as mat;
.content {
overflow: hidden;
padding: 10px !important;
}
.link-input {
width: 98%;
padding-top: 5px;
@include mat.form-field-density(-5);
}
.align-actions {
justify-content: space-between;
}
mat-dialog-content {
.video-js {
height: 500px !important;
}
}
@@ -1,54 +0,0 @@
import { ClipboardModule } from '@angular/cdk/clipboard';
import { Component, inject, ViewEncapsulation } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { WebPlayerViewComponent } from 'shared-portals';
export interface PlayerDialogData {
streamUrl: string;
title: string;
}
@Component({
templateUrl: './player-dialog.component.html',
imports: [
ClipboardModule,
MatButtonModule,
MatDialogModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
TranslateModule,
WebPlayerViewComponent,
],
styleUrl: './player-dialog.component.scss',
encapsulation: ViewEncapsulation.None,
})
export class PlayerDialogComponent {
readonly data = inject(MAT_DIALOG_DATA) as PlayerDialogData;
private snackBar = inject(MatSnackBar);
private translateService = inject(TranslateService);
readonly title: string;
readonly streamUrl: string;
constructor() {
this.streamUrl = this.data.streamUrl;
this.title = this.data.title;
}
showCopyNotification() {
this.snackBar.open(
this.translateService.instant('PORTALS.STREAM_URL_COPIED'),
null,
{
duration: 2000,
}
);
}
}
@@ -1,10 +0,0 @@
// should represent the content of a card element - xtream category or VOD or any item from stalker, vportal etc
export interface PortalCardItem {
// TODO
id: string;
type: 'category' | 'vod' | 'serial' | 'live'; // TODO: enum
name: string;
coverUrl?: string;
streamType?: 'live' | 'movie'; // TODO: maybe not needed, since type is there
categoryId?: string;
}
@@ -1,11 +0,0 @@
import { Pipe, PipeTransform } from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser';
@Pipe({ name: 'safe', standalone: true })
export class SafePipe implements PipeTransform {
constructor(private sanitizer: DomSanitizer) {}
transform(url: string) {
return this.sanitizer.bypassSecurityTrustResourceUrl(url);
}
}
@@ -1,5 +1,5 @@
<div
class="container"
class="container"
[style.background]="
item?.info?.backdrop_path && item?.info?.backdrop_path.length > 0
? 'linear-gradient(to top, rgba(29,29,29,1) 0%, rgba(0,0,0,0.5) 100%), url(' +
@@ -7,122 +7,109 @@
') repeat-x'
: 'none'
"
>
<div class="image">
@if (item.info?.movie_image) {
<img
[src]="item.info?.movie_image"
>
<div class="image">
@if (item.info?.movie_image) {
<img
[src]="item.info?.movie_image"
(error)="
$event.target.src = './assets/images/default-poster.png'
"
/>
} @else {
<div class="placeholder-cover"></div>
}
</div>
<div class="details">
<h2>{{ item.info.name }}</h2>
@if (item.info.description) {
<label>
{{ item.info.description }}
</label>
}
@if (item.info.releasedate) {
<label>
<div class="label">{{ 'XTREAM.RELEASE_DATE' | translate }}:</div>
{{ item.info.releasedate }}
</label>
}
@if (item.info.genre) {
<label>
<div class="label">{{ 'XTREAM.GENRE' | translate }}:</div>
{{ item.info.genre }}
</label>
}
@if (item.info.country) {
<label>
<div class="label">{{ 'XTREAM.COUNTRY' | translate }}:</div>
{{ item.info.country }}
</label>
}
@if (item.info.actors) {
<div>
<div class="label">{{ 'XTREAM.ACTORS' | translate }}:</div>
{{ item.info.actors }}
</div>
}
@if (item.info.director) {
<label>
<div class="label">{{ 'XTREAM.DIRECTOR' | translate }}:</div>
{{ item.info.director }}
</label>
}
@if (item.info.duration) {
<label>
<div class="label">{{ 'XTREAM.DURATION' | translate }}:</div>
{{ item.info.duration }}
</label>
}
@if (item.info.rating_imdb) {
<label>
<div class="label">{{ 'XTREAM.IMDB_RATING' | translate }}:</div>
{{ item.info.rating_imdb }}
</label>
}
@if (item.info.rating_kinopoisk) {
<label>
<div class="label">
{{ 'XTREAM.KINOPOISK_RATING' | translate }}:
</div>
{{ item.info.rating_kinopoisk }}
</label>
}
<div class="action-buttons">
<button
mat-flat-button
color="accent"
(click)="playClicked.emit(item)"
>
<mat-icon>play_arrow</mat-icon>
{{ 'XTREAM.PLAY' | translate }}</button
>&nbsp;
@if (!isFavorite) {
<button
mat-stroked-button
color="accent"
(click)="toggleFavorite()"
>
<mat-icon>star_outline</mat-icon>
{{ 'PORTALS.ADD_TO_FAVORITES' | translate }}
</button>
alt="Movie poster"
/>
} @else {
<button
mat-stroked-button
color="accent"
(click)="toggleFavorite()"
>
<mat-icon>star</mat-icon>
{{ 'PORTALS.REMOVE_FROM_FAVORITES' | translate }}
</button>
<div class="placeholder-cover"></div>
}
</div>
</div>
</div>
@if (item.info.youtube_trailer) {
<div class="details">
<h2>{{ item.info.name }}</h2>
@if (item.info.description) {
<div>
{{ item.info.description }}
</div>
}
@if (item.info.releasedate) {
<div class="label">{{ 'XTREAM.RELEASE_DATE' | translate }}:</div>
{{ item.info.releasedate }}
}
@if (item.info.genre) {
<div class="label">{{ 'XTREAM.GENRE' | translate }}:</div>
{{ item.info.genre }}
}
@if (item.info.country) {
<div class="label">{{ 'XTREAM.COUNTRY' | translate }}:</div>
{{ item.info.country }}
}
@if (item.info.actors) {
<div>
<div class="label">{{ 'XTREAM.ACTORS' | translate }}:</div>
{{ item.info.actors }}
</div>
}
@if (item.info.director) {
<div class="label">{{ 'XTREAM.DIRECTOR' | translate }}:</div>
{{ item.info.director }}
}
@if (item.info.duration) {
<div class="label">{{ 'XTREAM.DURATION' | translate }}:</div>
{{ item.info.duration }}
}
@if (item.info.rating_imdb) {
<div class="label">{{ 'XTREAM.IMDB_RATING' | translate }}:</div>
{{ item.info.rating_imdb }}
}
@if (item.info.rating_kinopoisk) {
<div class="label">
{{ 'XTREAM.KINOPOISK_RATING' | translate }}:
</div>
{{ item.info.rating_kinopoisk }}
}
<div class="action-buttons">
<button
mat-flat-button
color="accent"
(click)="playClicked.emit(item)"
>
<mat-icon>play_arrow</mat-icon>
{{ 'XTREAM.PLAY' | translate }}</button
>&nbsp;
@if (!isFavorite) {
<button
mat-stroked-button
color="accent"
(click)="toggleFavorite()"
>
<mat-icon>star_outline</mat-icon>
{{ 'PORTALS.ADD_TO_FAVORITES' | translate }}
</button>
} @else {
<button
mat-stroked-button
color="accent"
(click)="toggleFavorite()"
>
<mat-icon>star</mat-icon>
{{ 'PORTALS.REMOVE_FROM_FAVORITES' | translate }}
</button>
}
</div>
</div>
</div>
@if (item.info.youtube_trailer) {
<div class="youtube-trailer">
<h3>{{ 'XTREAM.YOUTUBE_TRAILER' | translate }}</h3>
<iframe
width="100%"
height="450"
[src]="
'https://www.youtube.com/embed/' + item.info?.youtube_trailer | safe
"
title="YouTube video player"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerpolicy="strict-origin-when-cross-origin"
allowfullscreen
></iframe>
<h3>{{ 'XTREAM.YOUTUBE_TRAILER' | translate }}</h3>
<iframe
width="100%"
height="450"
[src]="
'https://www.youtube.com/embed/' + item.info?.youtube_trailer
| safe
"
title="YouTube video player"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerpolicy="strict-origin-when-cross-origin"
allowfullscreen
></iframe>
</div>
}
}
@@ -13,7 +13,7 @@ import { ActivatedRoute } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import { PlaylistsService } from 'services';
import { XtreamVodDetails } from 'shared-interfaces';
import { SafePipe } from './safe.pipe';
import { SafePipe } from '../../xtream-tauri/vod-details/safe.pipe';
@Component({
selector: 'app-vod-details',
@@ -41,6 +41,10 @@ import {
import { LiveStreamLayoutComponent } from 'shared-portals';
import { SettingsStore } from '../services/settings-store.service';
import { ExternalPlayerInfoDialogComponent } from '../shared/components/external-player-info-dialog/external-player-info-dialog.component';
import {
PlayerDialogComponent,
PlayerDialogData,
} from '../xtream-tauri/player-dialog/player-dialog.component';
import { PlaylistErrorViewComponent } from '../xtream/playlist-error-view/playlist-error-view.component';
import { Breadcrumb, PortalActions } from './breadcrumb.interface';
import { CategoryContentViewComponent } from './category-content-view/category-content-view.component';
@@ -48,10 +52,6 @@ import { CategoryViewComponent } from './category-view/category-view.component';
import { ContentTypeNavigationItem } from './content-type-navigation-item.interface';
import { ContentType } from './content-type.enum';
import { NavigationBarComponent } from './navigation-bar/navigation-bar.component';
import {
PlayerDialogComponent,
PlayerDialogData,
} from './player-dialog/player-dialog.component';
import { PortalStore } from './portal.store';
import { SerialDetailsComponent } from './serial-details/serial-details.component';
import { VodDetailsComponent } from './vod-details/vod-details.component';
@@ -395,7 +395,8 @@ export class XtreamMainContainerComponent implements OnInit, OnDestroy {
playEpisode(episode: XtreamSerieEpisode) {
const playlist = this.currentPlaylist() as Playlist;
const { serverUrl, username, password, userAgent, referrer, origin } = playlist;
const { serverUrl, username, password, userAgent, referrer, origin } =
playlist;
const player = this.settingsStore.player();
const streamUrl = `${serverUrl}/series/${username}/${password}/${episode.id}.${episode.container_extension}`;
if (player === VideoPlayer.MPV) {
+89 -11
View File
@@ -12,9 +12,14 @@
"UNNAMED_CHANNEL": "Sender ohne Namen",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Neue Playlist hochladen oder eine andere aussuchen",
"USE_STAR_TO_FAVORITE": "Nutzen Sie das Star-Icon um Sender als Favoriten zu markieren",
"FAVORITES_UPDATED": "Favoriten wurden aktualisiert!"
"FAVORITES_UPDATED": "Favoriten wurden aktualisiert!",
"SORT_BY": "Sortieren nach",
"SORT_BY_DATE": "Nach Datum sortieren (Neueste zuerst)",
"SORT_BY_RATING": "Nach Bewertung sortieren",
"SORT_BY_NAME": "Nach Name sortieren (A-Z)"
},
"EPG": {
"TITLE": "Elektronischer Programmführer",
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Versuchen Sie bitte die TV-Guide Einstellungen zu ändern.",
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "Ups, leider ist das TV-Programm nicht verfügbar.",
"EPG_NOT_AVAILABLE_DATE": "Ups, leider ist das TV-Programm für das ausgewählte Datum nicht verfügbar",
@@ -26,6 +31,7 @@
"FETCH_EPG": "EPG-Daten werden geladen...",
"ERROR": "Ooops, EPG konnte nicht geladen werden",
"DOWNLOAD_SUCCESS": "EPG wurde erfolgreich geladen.",
"FETCH_SUCCESS": "EPG wurde erfolgreich abgerufen",
"PROGRAM_DIALOG": {
"PROGRAM_DETAILS": "Details zum TV-Programm",
"TITLE": "Titel",
@@ -74,19 +80,32 @@
"PASSWORD": "Passwort",
"SERVER_URL": "Server-URL",
"USERNAME": "Nutzername",
"MAC_ADDRESS": "MAC-Adresse"
"MAC_ADDRESS": "MAC-Adresse",
"PLAYLIST_EXPORT_SUCCESS": "Playlist wurde erfolgreich exportiert.",
"PLAYLIST_EXPORT_ERROR": "Fehler: Playlist-Export fehlgeschlagen."
},
"UPDATED": "Aktualisiert am",
"REFRESH": "Aktualisieren",
"REFRESH_XTREAM": "Xtream-Playlist vom Server aktualisieren",
"REFRESH_XTREAM_DIALOG": {
"TITLE": "Xtream-Playlist aktualisieren",
"MESSAGE": "Dadurch werden lokal zwischengespeicherte Kategorien und Streams gelöscht und alles vom Remote-Server erneut importiert. Ihre Favoriten und kürzlich angesehenen Elemente bleiben erhalten. Möchten Sie wirklich fortfahren?",
"STARTED": "Aktualisierung gestartet. Navigation zur Playlist...",
"ERROR": "Playlist-Aktualisierung fehlgeschlagen. Bitte versuchen Sie es erneut."
},
"REMOVE_DIALOG": {
"MESSAGE": "Sind Sie sicher, dass Sie diese Wiedergabeliste vollständig löschen möchten",
"TITLE": "Wiedergabeliste entfernen"
"TITLE": "Wiedergabeliste entfernen",
"SUCCESS": "Playlist wurde erfolgreich entfernt"
},
"GLOBAL_FAVORITES": "Globale Favoriten",
"GLOBAL_FAVORITES_DESCRIPTION": "Auto-generated playlist with aggregated favorites from all playlists",
"GLOBAL_FAVORITES_DESCRIPTION": "Automatisch generierte Playlist mit aggregierten Favoriten aus allen Playlists",
"MY_PLAYLISTS": "Meine Playlisten",
"MY_PLAYLISTS_SUBTITLE": "Alle verfügbaren Playlisten",
"PLAYLIST_UPDATE_SUCCESS": "Erfolg! Die Playlist wurde erfolgreich aktualisiert.",
"PLAYLIST_UPDATE_ERROR": "Fehler beim Aktualisieren der Playlist-Details.",
"AUTO_REFRESH_UPDATE_SUCCESS": "Erfolg! Die Playlists wurden erfolgreich aktualisiert",
"AUTO_REFRESH_ENABLED": "Auto-Refresh aktiviert",
"STALKER_PORTAL": "Stalker-Portal",
"XTREAM_PLAYLIST": "Xtream-Playlist"
},
@@ -100,7 +119,8 @@
},
"URL_UPLOAD": {
"ADD_PLAYLIST": "Playlist hinzufügen",
"PLAYLIST_URL": "URL der Playlist"
"PLAYLIST_URL": "URL der Playlist",
"CORS_NOTE": "Hinweis: Um CORS-Probleme zu vermeiden, verwendet die Anwendung an dieser Stelle einen öffentlichen CORS-Proxy-Dienst. Wenn Sie eine Playlist mit sensiblen Daten hochladen möchten, ist es sicherer, sie aus einer Datei zu importieren."
},
"TEXT_IMPORT": {
"LABEL": "Füge m3u(8)-Wiedergabeliste hier als Text ein",
@@ -123,8 +143,16 @@
"URL_VALIDATION_ERROR": "Sollte eine gültige URL mit Protokoll sein (z. B. http://example.com/c oder https://example.com/stalker_portal/c)"
},
"FILTER_BY_TYPE": "Nach Typ filtern",
"IMPORTED_AS_TEXT": "Als Text importiert",
"FILTER_BY_NAME": "Nach Namen filtern",
"SORT_PLAYLISTS": "Playlisten sortieren",
"SORT_BY": "Sortieren nach",
"SORT_OPTIONS": {
"NAME_ASC": "Name (A-Z)",
"NAME_DESC": "Name (Z-A)",
"NEWEST": "Hinzugefügt (Neueste zuerst)",
"OLDEST": "Hinzugefügt (Älteste zuerst)"
},
"IMPORTED_AS_TEXT": "Als Text importiert",
"PARSING_ERROR": "Fehler: Parsen fehlgeschlagen, keine gültige Playlist:"
},
"LANGUAGES": {
@@ -161,9 +189,11 @@
"VERSION": "Version",
"VIDEO_PLAYER_LABEL": "Video player",
"VIDEO_PLAYER_PLACEHOLDER": "Option auswählen",
"STREAM_FORMAT": "Stream-Format",
"SETTINGS_SAVED": "Einstellungen wurden erfolgreich gespeichert!",
"THEME": "Farbdesign",
"SHOW_CAPTIONS": "Untertitel anzeigen",
"STREAM_FORMAT_DESCRIPTION": "Standard-Streamformat auswählen (Xtream)",
"REMOVE_ALL": "Playlist entfernen",
"REMOVE_ALL_BUTTON": "Entfernen",
"REMOVE_DIALOG": {
@@ -190,7 +220,14 @@
"VLC_PLAYER_PATH": "VLC-Playerpfad",
"IMPORT_EXPORT_DATA_DESCRIPTION": "Exportieren oder importieren Sie Wiedergabelisten im JSON-Format, das in der Anwendung verwendet wird.",
"EPG_NOTE_URL_TEXT": "Überprüfen Sie es hier",
"MPV_PLAYER_PATH_LABEL": "MPV-Pfad"
"MPV_PLAYER_PATH_LABEL": "MPV-Pfad",
"MPV_PLAYER_PATH_DESCRIPTION": "Legen Sie den Pfad zum MPV-Player in Ihrem System fest. Es wird der bereitgestellte Pfad zu einer MPV-Binärdatei anstelle des in $PATH gefundenen verwendet.",
"MPV_REUSE_INSTANCE_LABEL": "MPV-Instanz wiederverwenden",
"MPV_REUSE_INSTANCE_DESCRIPTION": "Wenn aktiviert, wird dasselbe MPV-Fenster zum Abspielen verschiedener Streams wiederverwendet. Wenn deaktiviert, wird jeder Stream in einem neuen MPV-Fenster geöffnet.",
"REMOTE_CONTROL": "Fernbedienung",
"REMOTE_CONTROL_DESCRIPTION": "Fernsteuerung der Anwendung aktivieren.",
"REMOTE_CONTROL_PORT": "Fernsteuerungs-Port",
"REMOTE_CONTROL_PORT_DESCRIPTION": "Port für die Fernsteuerung der Anwendung. Standardmäßig wird Port 3000 verwendet."
},
"THEMES": {
"DARK_THEME": "Dunkel",
@@ -219,6 +256,7 @@
"RELEASE_DATE": "Veröffentlichungsdatum",
"ACTORS": "Schauspieler",
"PLAY": "Abspielen",
"OPENING_PLAYER": "Player wird geöffnet...",
"GENRE": "Genre",
"DURATION": "Dauer",
"COUNTRY": "Land",
@@ -233,7 +271,32 @@
"DESCRIPTION": "Beschreibung",
"KINOPOISK_RATING": "Bewertung Kinopoisk",
"IMDB_RATING": "IMDB-Bewertung",
"EPISODE_RUN_TIME": "Laufzeit der Episode"
"EPISODE_RUN_TIME": "Laufzeit der Episode",
"ACCOUNT_INFO": {
"TITLE": "Kontoinformationen",
"USER_INFO": "Benutzerinformationen",
"STATUS": "Status",
"USERNAME": "Benutzername",
"ACTIVE_CONNECTIONS": "Aktive Verbindungen",
"CREATED": "Erstellt",
"EXPIRES": "Läuft ab",
"TRIAL_ACCOUNT": "Testkonto",
"YES": "Ja",
"NO": "Nein",
"ALLOWED_FORMATS": "Zulässige Formate",
"SERVER_INFO": "Serverinformationen",
"LIVE_TV": "Live-TV",
"MOVIES": "Filme",
"TV_SERIES": "Serien",
"URL": "URL",
"PROTOCOL": "Protokoll",
"TIMEZONE": "Zeitzone",
"SERVER_TIME": "Serverzeit",
"PORTS": "Ports",
"HTTP_PORT": "HTTP",
"HTTPS_PORT": "HTTPS",
"RTMP_PORT": "RTMP"
}
},
"PORTALS": {
"SIDEBAR": {
@@ -265,6 +328,10 @@
"TITLE": "Leere Kategorie",
"DESCRIPTION": "Diese Kategorie hat keinen Inhalt. Bitte wählen Sie eine andere Kategorie aus oder ändern Sie den Inhaltstyp"
},
"NO_CATEGORY_SELECTED": {
"TITLE": "Keine Kategorie ausgewählt",
"DESCRIPTION": "Bitte wählen Sie eine Kategorie aus, um den Inhalt anzuzeigen"
},
"UNAUTHORIZED": {
"TITLE": "Nicht autorisiert",
"DESCRIPTION": "Bitte überprüfen Sie die Anmeldeinformationen in den Playlist-Einstellungen oder entfernen Sie sie."
@@ -281,19 +348,30 @@
},
"STREAM_URL_COPIED": "Stream-URL in die Zwischenablage kopiert",
"EMPTY_LIST_VIEW": {
"TITLE": "Keine Ergebnisse gefunden",
"NO_SEARCH_RESULTS": "Nichts gefunden. Versuchen Sie, Ihre Suchanfrage zu ändern"
},
"REMOVED_FROM_FAVORITES": "Aus den Favoriten entfernt",
"ADDED_TO_FAVORITES": "Zu den Favoriten hinzugefügt"
"ADDED_TO_FAVORITES": "Zu den Favoriten hinzugefügt",
"SELECT_CATEGORY": "Wählen Sie eine Kategorie aus",
"PAGE": "Seite"
},
"ABOUT": {
"TITLE": "Über IPTVnator",
"VERSION": "Ausführung",
"DESCRIPTION": "Plattformübergreifende IPTV-Player-Anwendung mit mehreren Funktionen wie Unterstützung von M3U- und M3U8-Wiedergabelisten, Favoriten, TV-Guide, TV-Archiv/Catchup und mehr.",
"VERSION": "Version",
"TWITTER_TOOLTIP": "Auf Twitter teilen",
"GITHUB_TOOLTIP": "ITPVnator-Repository auf GitHub",
"BUY_ME_A_COFFEE_TOOLTIP": "Buy me a coffee"
},
"REFRESH": "Aktualisieren",
"UPDATE_AVAILABLE": "Update verfügbar",
"INFORMATION": "Information"
"INFORMATION": "Information",
"REMOTE_CONTROL": {
"TITLE": "Fernbedienung",
"UP_CHANNEL": "Sender aufwärts",
"DOWN_CHANNEL": "Sender abwärts",
"HEADER": "IPTVNator Fernbedienung",
"FOOTER": "IPTVNator Fernbedienung"
}
}
+29 -2
View File
@@ -264,6 +264,7 @@
"REFRESH": "Refresh",
"XTREAM": {
"PLAY": "Play",
"OPENING_PLAYER": "Opening player...",
"NAME": "Name",
"RELEASE_DATE": "Release date",
"GENRE": "Genre",
@@ -280,7 +281,32 @@
"YEAR": "Year",
"AGE": "Age",
"YOUTUBE_TRAILER": "YouTube Trailer",
"EPISODE": "Episode"
"EPISODE": "Episode",
"ACCOUNT_INFO": {
"TITLE": "Account Information",
"USER_INFO": "User Information",
"STATUS": "Status",
"USERNAME": "Username",
"ACTIVE_CONNECTIONS": "Active Connections",
"CREATED": "Created",
"EXPIRES": "Expires",
"TRIAL_ACCOUNT": "Trial Account",
"YES": "Yes",
"NO": "No",
"ALLOWED_FORMATS": "Allowed Formats",
"SERVER_INFO": "Server Information",
"LIVE_TV": "Live TV",
"MOVIES": "Movies",
"TV_SERIES": "TV Series",
"URL": "URL",
"PROTOCOL": "Protocol",
"TIMEZONE": "Timezone",
"SERVER_TIME": "Server Time",
"PORTS": "Ports",
"HTTP_PORT": "HTTP",
"HTTPS_PORT": "HTTPS",
"RTMP_PORT": "RTMP"
}
},
"PORTALS": {
"ALL_CATEGORIES": "All categories",
@@ -337,7 +363,8 @@
},
"STREAM_URL_COPIED": "Stream URL copied to clipboard",
"COPY_STREAM_URL": "Copy Stream URL",
"SELECT_CATEGORY": "Select a category"
"SELECT_CATEGORY": "Select a category",
"PAGE": "Page"
},
"INFORMATION": "Information",
"REMOTE_CONTROL": {
+104 -13
View File
@@ -41,19 +41,32 @@
"PASSWORD": "Пароль",
"SERVER_URL": "URL-адрес сервера",
"USERNAME": "Имя пользователя",
"MAC_ADDRESS": "MAC-адрес"
"MAC_ADDRESS": "MAC-адрес",
"PLAYLIST_EXPORT_SUCCESS": "Плейлист был успешно экспортирован.",
"PLAYLIST_EXPORT_ERROR": "Ошибка: не удалось экспортировать плейлист."
},
"UPDATED": "Обновлен",
"REFRESH": "Обновить плейлист",
"REFRESH_XTREAM": "Обновить Xtream плейлист с сервера",
"REFRESH_XTREAM_DIALOG": {
"TITLE": "Обновить Xtream плейлист",
"MESSAGE": "Это удалит локально кэшированные категории и потоки и повторно импортирует все с удаленного сервера. Ваши избранные и недавно просмотренные элементы будут сохранены. Вы уверены, что хотите продолжить?",
"STARTED": "Обновление начато. Переход к плейлисту...",
"ERROR": "Не удалось обновить плейлист. Пожалуйста, попробуйте снова."
},
"REMOVE_DIALOG": {
"MESSAGE": "Вы уверены, что хотите удалить этот плейлист?",
"TITLE": "Удалить плейлист"
"TITLE": "Удалить плейлист",
"SUCCESS": "Плейлист был успешно удален"
},
"GLOBAL_FAVORITES": "Все фавориты",
"GLOBAL_FAVORITES_DESCRIPTION": "Автоматически сгенерированный плейлист, включающий в себя фавориты из всех плейлистов",
"MY_PLAYLISTS": "Мои плейлисты",
"MY_PLAYLISTS_SUBTITLE": "Все имеющиеся плейлисты",
"PLAYLIST_UPDATE_SUCCESS": "Плейлист был успешно обновлен!",
"PLAYLIST_UPDATE_ERROR": "Ошибка при обновлении деталей плейлиста.",
"AUTO_REFRESH_UPDATE_SUCCESS": "Успешно! Плейлисты были успешно обновлены",
"AUTO_REFRESH_ENABLED": "Автообновление включено",
"STALKER_PORTAL": "Stalker портал",
"XTREAM_PLAYLIST": "Xtream плейлист"
},
@@ -65,7 +78,8 @@
},
"URL_UPLOAD": {
"PLAYLIST_URL": "Ссылка на плейлист (*.m3u или *.m3u8)",
"ADD_PLAYLIST": "Добавить плейлист"
"ADD_PLAYLIST": "Добавить плейлист",
"CORS_NOTE": "Примечание: Чтобы избежать проблем с CORS, приложение использует публичный CORS-прокси сервис. Если вы хотите загрузить плейлист с конфиденциальными данными, безопаснее импортировать его из файла."
},
"TEXT_IMPORT": {
"LABEL": "Вставьте плейлист формата m3u(8) в данное поле",
@@ -88,8 +102,16 @@
"URL_VALIDATION_ERROR": "Должен быть действительный URL-адрес с протоколом (например, http://example.com/c или https://example.com/stalker_portal/c)."
},
"FILTER_BY_TYPE": "Фильтровать по типу",
"IMPORTED_AS_TEXT": "Импортировано как текст",
"FILTER_BY_NAME": "Фильтровать по имени",
"SORT_PLAYLISTS": "Сортировать плейлисты",
"SORT_BY": "Сортировать по",
"SORT_OPTIONS": {
"NAME_ASC": "Имени (А-Я)",
"NAME_DESC": "Имени (Я-А)",
"NEWEST": "Дате добавления (сначала новые)",
"OLDEST": "Дате добавления (сначала старые)"
},
"IMPORTED_AS_TEXT": "Импортировано как текст",
"PARSING_ERROR": "Ошибка парсинга, недействительный плейлист:"
},
"SETTINGS": {
@@ -106,6 +128,7 @@
"BACK_TO_HOME": "Назад",
"NEW_VERSION_AVAILABLE": "Доступна новая версия приложения",
"LATEST_VERSION": "Вы используете последнюю версию приложения",
"STREAM_FORMAT": "Формат потока",
"LANGUAGE": "Язык",
"SETTINGS_SAVED": "Новые настройки были успешно сохранены!",
"THEME": "Тема оформления",
@@ -113,6 +136,7 @@
"ADD_EPG_SOURCE": "Добавить источник EPG",
"EPG_URL_ERROR": "Введенное значение не является URL-адресом.",
"VIDEO_PLAYER_DESCRIPTION": "Выбрать стандартный видео плеер",
"STREAM_FORMAT_DESCRIPTION": "Выбрать формат потока по умолчанию (Xtream)",
"LANGUAGE_DESCRIPTION": "Выбрать язык приложения",
"THEME_DESCRIPTION": "Выбрать визуальную тему оформления",
"SHOW_CAPTIONS_DESCRIPTION": "Показать или скрыть субтитры по умолчанию.",
@@ -120,7 +144,7 @@
"EPG_NOTE": "На данный момент EPG доступен только в нативной версии IPTVnator.",
"EPG_NOTE_URL_TEXT": "Подробнее здесь",
"IMPORT_EXPORT_DATA": "Импорт/экспорт плейлистов",
"IMPORT_EXPORT_DATA_DESCRIPTION": "Export or import playlists in the JSON format used within the application.",
"IMPORT_EXPORT_DATA_DESCRIPTION": "Экспорт или импорт плейлистов в формате JSON, используемом в приложении.",
"IMPORT_DATA": "Импорт",
"EXPORT_DATA": "Экспорт",
"REMOVE_ALL": "Удалить плейлист",
@@ -134,9 +158,16 @@
"PLAYLISTS_REMOVED": "Все плейлисты были удалены.",
"VLC_PLAYER_PATH_LABEL": "Путь к VLC плееру",
"VLC_PLAYER_PATH": "Путь к VLC плееру",
"VLC_PLAYER_PATH_DESCRIPTION": "Установите путь к проигрывателю VLC в вашей системе. \nОн будет использовать предоставленный путь к binary файлу VLC вместо пути по умолчанию.",
"MPV_PLAYER_PATH_LABEL": "Путь к mpv плееру",
"MPV_PLAYER_PATH": "Путь к mpv плееру",
"VLC_PLAYER_PATH_DESCRIPTION": "Установите путь к проигрывателю VLC в вашей системе. \nОн будет использовать предоставленный путь к binary файлу VLC вместо пути по умолчанию."
"MPV_PLAYER_PATH_DESCRIPTION": "Установите путь к проигрывателю MPV в вашей системе. Он будет использовать предоставленный путь к бинарному файлу MPV вместо найденного в $PATH.",
"MPV_REUSE_INSTANCE_LABEL": "Переиспользовать экземпляр MPV",
"MPV_REUSE_INSTANCE_DESCRIPTION": "Когда включено, одно и то же окно MPV будет использоваться для воспроизведения разных потоков. Когда отключено, каждый поток открывается в новом окне MPV.",
"REMOTE_CONTROL": "Дистанционное управление",
"REMOTE_CONTROL_DESCRIPTION": "Включить дистанционное управление приложением.",
"REMOTE_CONTROL_PORT": "Порт дистанционного управления",
"REMOTE_CONTROL_PORT_DESCRIPTION": "Порт, используемый для дистанционного управления приложением. По умолчанию используется порт 3000."
},
"THEMES": {
"DARK_THEME": "Тёмная тема",
@@ -153,15 +184,20 @@
"NO_FAVORITES": "Избранных каналов пока нет",
"USE_STAR_TO_FAVORITE": "Используйте иконку звезды, чтобы добавлять каналы в избранное",
"REMOVE_FAVORITE": "Удалить из списка фаворитов",
"FAVORITES_UPDATED": "Список фаворитов был обновлен!"
"FAVORITES_UPDATED": "Список фаворитов был обновлен!",
"SORT_BY": "Сортировать по",
"SORT_BY_DATE": "Сортировать по дате добавления (сначала новые)",
"SORT_BY_RATING": "Сортировать по рейтингу",
"SORT_BY_NAME": "Сортировать по имени (А-Я)"
},
"TOP_MENU": {
"OPEN_CHANNELS_LIST": "Открыть список каналов",
"TOGGLE_FAVORITE_FLAG": "Избранный",
"OPEN_EPG_LIST": "Открыть программу (EPG)",
"OPEN_MULTI_EPG": "Open Multi-EPG view"
"OPEN_MULTI_EPG": "Открыть Multi-EPG вид"
},
"EPG": {
"TITLE": "Электронный программный гид",
"NEXT_DAY": "Следующий день",
"PREVIOUS_DAY": "Предыдущий день",
"LIVE_NOW": "Сейчас",
@@ -173,6 +209,7 @@
"FETCH_EPG": "Загружаю программу ТВ-передач (EPG)...",
"ERROR": "Ой, не удалось загрузить программу каналов (EPG)",
"DOWNLOAD_SUCCESS": "Программа каналов (EPG) была успешно загружена.",
"FETCH_SUCCESS": "Программа каналов (EPG) была успешно получена",
"PROGRAM_DIALOG": {
"PROGRAM_DETAILS": "Подробности передачи",
"TITLE": "Название",
@@ -217,7 +254,7 @@
},
"ABOUT": {
"TITLE": "О приложении",
"DESCRIPTION": "Cross-platform IPTV player application with multiple features, such as support of m3u and m3u8 playlists, favorites, TV guide, TV archive/catchup and more.",
"DESCRIPTION": "Кроссплатформенное приложение IPTV-плеера с множеством функций, таких как поддержка плейлистов m3u и m3u8, избранное, телегид, ТВ-архив/catchup и многое другое.",
"VERSION": "Версия",
"GITHUB_TOOLTIP": "Репозиторий приложения на GitHub",
"BUY_ME_A_COFFEE_TOOLTIP": "Поддержать разработчика",
@@ -230,6 +267,7 @@
"RELEASE_DATE": "Дата выпуска",
"ACTORS": "Актеры",
"PLAY": "Воспроизвести",
"OPENING_PLAYER": "Открытие плеера...",
"GENRE": "Жанр",
"DURATION": "Продолжительность",
"COUNTRY": "Страна",
@@ -243,7 +281,32 @@
"YOUTUBE_TRAILER": "YouTube-трейлер",
"DESCRIPTION": "Описание",
"KINOPOISK_RATING": "Рейтинг на Кинопоиск",
"IMDB_RATING": "Рейтинг на IMDB"
"IMDB_RATING": "Рейтинг на IMDB",
"ACCOUNT_INFO": {
"TITLE": "Информация об аккаунте",
"USER_INFO": "Информация о пользователе",
"STATUS": "Статус",
"USERNAME": "Имя пользователя",
"ACTIVE_CONNECTIONS": "Активные подключения",
"CREATED": "Создано",
"EXPIRES": "Истекает",
"TRIAL_ACCOUNT": "Пробный аккаунт",
"YES": "Да",
"NO": "Нет",
"ALLOWED_FORMATS": "Разрешенные форматы",
"SERVER_INFO": "Информация о сервере",
"LIVE_TV": "Прямой эфир",
"MOVIES": "Фильмы",
"TV_SERIES": "Сериалы",
"URL": "URL",
"PROTOCOL": "Протокол",
"TIMEZONE": "Часовой пояс",
"SERVER_TIME": "Время сервера",
"PORTS": "Порты",
"HTTP_PORT": "HTTP",
"HTTPS_PORT": "HTTPS",
"RTMP_PORT": "RTMP"
}
},
"PORTALS": {
"ALL_CATEGORIES": "Все категории",
@@ -267,7 +330,12 @@
"DESCRIPTION": "Пожалуйста, проверьте учетные данные в настройках плейлиста или удалите их."
},
"NOT_FOUND": {
"TITLE": "Портал не найден"
"TITLE": "Портал не найден",
"DESCRIPTION": "Пожалуйста, проверьте учетные данные и URL портала в настройках плейлиста или удалите его."
},
"NO_CATEGORY_SELECTED": {
"TITLE": "Категория не выбрана",
"DESCRIPTION": "Пожалуйста, выберите категорию для просмотра содержимого"
},
"UNKNOWN_ERROR": {
"TITLE": "Что-то пошло не так",
@@ -277,10 +345,33 @@
},
"STREAM_URL_COPIED": "URL-адрес потока был скопирован в буфер обмена.",
"EMPTY_LIST_VIEW": {
"TITLE": "Результаты не найдены",
"NO_SEARCH_RESULTS": "Ничего не найдено, попробуйте изменить поисковый запрос"
},
"SIDEBAR": {
"MAIN": "Главная",
"MOVIES": "Фильмы",
"LIVE_TV": "Прямой эфир",
"SERIES": "Сериалы",
"LIBRARY": "Библиотека",
"SEARCH": "Поиск",
"RECENT": "Недавние",
"FAVORITES": "Избранное",
"HOME": "Домой",
"PLAYLIST_INFO": "Информация о плейлисте",
"ACCOUNT_INFO": "Информация об аккаунте"
},
"REMOVED_FROM_FAVORITES": "Удалено из избранного",
"ADDED_TO_FAVORITES": "Добавлено в избранное"
"ADDED_TO_FAVORITES": "Добавлено в избранное",
"SELECT_CATEGORY": "Выберите категорию",
"PAGE": "Страница"
},
"INFORMATION": "Инфо"
"INFORMATION": "Инфо",
"REMOTE_CONTROL": {
"TITLE": "Дистанционное управление",
"UP_CHANNEL": "Канал вверх",
"DOWN_CHANNEL": "Канал вниз",
"HEADER": "IPTVNator Дистанционное управление",
"FOOTER": "IPTVNator Дистанционное управление"
}
}
@@ -26,7 +26,7 @@
<mat-list-option
*cdkVirtualFor="
let item of channels
| filterBy : searchString() : 'name';
| filterBy: searchString() : 'name';
trackBy: trackBy;
templateCacheSize: 0
"
@@ -51,17 +51,22 @@
</mat-selection-list>
</div>
</div>
@if (player === 'videojs' || player === 'html5') { @if (streamUrl) {
<div class="video-player">
<app-web-player-view [streamUrl]="streamUrl" />
</div>
} } @if (streamUrl) {
<div class="epg">
<div class="epg-content">
<app-epg-view [epgItems]="epgItems" />
@if (player === 'videojs' || player === 'html5') {
@if (streamUrl) {
<div class="video-player">
<app-web-player-view [streamUrl]="streamUrl" />
</div>
}
}
@if (streamUrl) {
<div class="epg">
<div class="epg-content">
<app-epg-view [epgItems]="epgItems" />
</div>
</div>
</div>
} @else {
<div class="no-channel-selected">Please select a channel</div>
<div class="no-channel-selected">
{{ 'PORTALS.SELECT_CATEGORY' | translate }}
</div>
}
</div>