mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
refactor(web): split settings component into sections
Entire-Checkpoint: c2cea9c530e6
This commit is contained in:
1 parent
d24c67c6e8
commit
c51d8161ea
22 files changed
+1759
-1581
No files matched your search
@@ -0,0 +1,179 @@
|
||||
<section
|
||||
class="settings-group"
|
||||
[class.settings-group--active]="activeSection() === 'about'"
|
||||
id="about"
|
||||
>
|
||||
<header class="settings-group__header">
|
||||
<div class="settings-group__header-icon">
|
||||
<mat-icon>info</mat-icon>
|
||||
</div>
|
||||
<div class="settings-group__header-text">
|
||||
<h3>{{ 'SETTINGS.ABOUT' | translate }}</h3>
|
||||
<p>{{ 'SETTINGS.ABOUT_SUBTITLE' | translate }}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.VERSION' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.VERSION_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
<div class="setting-item__control version-block">
|
||||
<span>{{ version() }}</span>
|
||||
<small>{{ updateMessage() }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (isDesktop()) {
|
||||
<div class="settings-note">
|
||||
<strong>{{
|
||||
'SETTINGS.EMBEDDED_MPV_NOTICE_TITLE' | translate
|
||||
}}</strong>
|
||||
<span>{{
|
||||
'SETTINGS.EMBEDDED_MPV_NOTICE_DESCRIPTION' | translate
|
||||
}}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="setting-item setting-item--full-control">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.COMMUNITY' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.COMMUNITY_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<div class="link-cards">
|
||||
<a
|
||||
class="link-card link-card--github"
|
||||
href="https://github.com/4gray/iptvnator"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
data-test-id="link-github"
|
||||
>
|
||||
<span class="link-card__glyph">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 0C5.374 0 0 5.373 0 12c0 5.302 3.438 9.8 8.207 11.387.6.112.793-.26.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23A11.52 11.52 0 0 1 12 5.803c1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576C20.565 21.796 24 17.3 24 12c0-6.627-5.373-12-12-12z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="link-card__text">
|
||||
<strong>GitHub</strong>
|
||||
<span>{{
|
||||
'SETTINGS.LINK_GITHUB_DESCRIPTION' | translate
|
||||
}}</span>
|
||||
</span>
|
||||
<mat-icon class="link-card__arrow">arrow_outward</mat-icon>
|
||||
</a>
|
||||
<a
|
||||
class="link-card link-card--blog"
|
||||
href="https://4gray.github.io/iptvnator/"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
data-test-id="link-blog"
|
||||
>
|
||||
<span class="link-card__glyph">
|
||||
<mat-icon>menu_book</mat-icon>
|
||||
</span>
|
||||
<span class="link-card__text">
|
||||
<strong>{{ 'SETTINGS.LINK_BLOG' | translate }}</strong>
|
||||
<span>{{
|
||||
'SETTINGS.LINK_BLOG_DESCRIPTION' | translate
|
||||
}}</span>
|
||||
</span>
|
||||
<mat-icon class="link-card__arrow">arrow_outward</mat-icon>
|
||||
</a>
|
||||
<a
|
||||
class="link-card link-card--telegram"
|
||||
href="https://t.me/iptvnator"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
data-test-id="link-telegram"
|
||||
>
|
||||
<span class="link-card__glyph">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M9.78 18.65l.28-4.23 7.68-6.92c.34-.31-.07-.46-.52-.19L7.74 13.3 3.64 12c-.88-.25-.89-.86.2-1.3l15.97-6.16c.73-.33 1.43.18 1.15 1.3l-2.72 12.81c-.19.91-.74 1.13-1.5.71L12.6 16.3l-1.99 1.93c-.23.23-.42.42-.83.42z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="link-card__text">
|
||||
<strong>Telegram</strong>
|
||||
<span>{{
|
||||
'SETTINGS.LINK_TELEGRAM_DESCRIPTION' | translate
|
||||
}}</span>
|
||||
</span>
|
||||
<mat-icon class="link-card__arrow">arrow_outward</mat-icon>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item setting-item--full-control">
|
||||
<div class="support-card" data-test-id="support-card">
|
||||
<div class="support-card__intro">
|
||||
<div class="support-card__emblem">
|
||||
<mat-icon>favorite</mat-icon>
|
||||
</div>
|
||||
<div class="support-card__copy">
|
||||
<h4>{{ 'SETTINGS.SUPPORT_TITLE' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.SUPPORT_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="support-card__actions">
|
||||
<a
|
||||
class="support-btn support-btn--sponsors"
|
||||
href="https://github.com/sponsors/4gray"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
data-test-id="support-sponsors"
|
||||
>
|
||||
<span class="support-btn__glyph">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="support-btn__text">
|
||||
<strong>GitHub Sponsors</strong>
|
||||
<small>{{
|
||||
'SETTINGS.SUPPORT_SPONSORS_HINT' | translate
|
||||
}}</small>
|
||||
</span>
|
||||
</a>
|
||||
<a
|
||||
class="support-btn support-btn--kofi"
|
||||
href="https://ko-fi.com/4gray"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
data-test-id="support-kofi"
|
||||
>
|
||||
<span class="support-btn__glyph">
|
||||
<mat-icon>local_cafe</mat-icon>
|
||||
</span>
|
||||
<span class="support-btn__text">
|
||||
<strong>Ko-fi</strong>
|
||||
<small>{{
|
||||
'SETTINGS.SUPPORT_KOFI_HINT' | translate
|
||||
}}</small>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Component, input, ViewEncapsulation } from '@angular/core';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings-about-section',
|
||||
imports: [MatIconModule, TranslateModule],
|
||||
templateUrl: './settings-about-section.component.html',
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
styles: [':host { display: contents; }'],
|
||||
})
|
||||
export class SettingsAboutSectionComponent {
|
||||
readonly activeSection = input.required<string>();
|
||||
readonly isDesktop = input(false);
|
||||
readonly version = input<string | undefined>();
|
||||
readonly updateMessage = input<string | undefined>();
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<section
|
||||
class="settings-group"
|
||||
[class.settings-group--active]="activeSection() === 'backup'"
|
||||
id="backup"
|
||||
>
|
||||
<header class="settings-group__header">
|
||||
<div class="settings-group__header-icon">
|
||||
<mat-icon>backup</mat-icon>
|
||||
</div>
|
||||
<div class="settings-group__header-text">
|
||||
<h3>{{ 'SETTINGS.BACKUP' | translate }}</h3>
|
||||
<p>{{ 'SETTINGS.BACKUP_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.IMPORT_EXPORT_DATA' | translate }}</h4>
|
||||
<p>
|
||||
{{ 'SETTINGS.IMPORT_EXPORT_DATA_DESCRIPTION' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control inline-actions">
|
||||
<button
|
||||
mat-button
|
||||
class="inline-actions__button"
|
||||
[disabled]="isRemovingAllPlaylists() || isExportingData()"
|
||||
(click)="
|
||||
$event.preventDefault();
|
||||
$event.stopPropagation();
|
||||
importData.emit()
|
||||
"
|
||||
>
|
||||
{{ 'SETTINGS.IMPORT_DATA' | translate }}
|
||||
</button>
|
||||
<button
|
||||
mat-button
|
||||
class="inline-actions__button"
|
||||
[class.inline-actions__button--busy]="isExportingData()"
|
||||
[disabled]="isRemovingAllPlaylists() || isExportingData()"
|
||||
[attr.aria-busy]="isExportingData()"
|
||||
(click)="
|
||||
$event.preventDefault();
|
||||
$event.stopPropagation();
|
||||
exportData.emit()
|
||||
"
|
||||
>
|
||||
@if (isExportingData()) {
|
||||
<mat-progress-spinner
|
||||
class="button-spinner"
|
||||
diameter="16"
|
||||
mode="indeterminate"
|
||||
/>
|
||||
<span class="inline-actions__label">{{
|
||||
'SETTINGS.EXPORTING_DATA' | translate
|
||||
}}</span>
|
||||
} @else {
|
||||
<span class="inline-actions__label">{{
|
||||
'SETTINGS.EXPORT_DATA' | translate
|
||||
}}</span>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (isPwa()) {
|
||||
<div class="settings-note">
|
||||
{{ 'SETTINGS.EPG_NOTE' | translate }}
|
||||
<a
|
||||
href="https://github.com/4gray/iptvnator/releases"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>{{ 'SETTINGS.EPG_NOTE_URL_TEXT' | translate }}</a
|
||||
>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Component, input, output, ViewEncapsulation } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings-backup-section',
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatProgressSpinnerModule,
|
||||
TranslateModule,
|
||||
],
|
||||
templateUrl: './settings-backup-section.component.html',
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
styles: [':host { display: contents; }'],
|
||||
})
|
||||
export class SettingsBackupSectionComponent {
|
||||
readonly activeSection = input.required<string>();
|
||||
readonly isPwa = input(false);
|
||||
readonly isRemovingAllPlaylists = input(false);
|
||||
readonly isExportingData = input(false);
|
||||
|
||||
readonly importData = output<void>();
|
||||
readonly exportData = output<void>();
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
<section
|
||||
class="settings-group"
|
||||
[class.settings-group--active]="activeSection() === 'epg'"
|
||||
id="epg"
|
||||
[formGroup]="form()"
|
||||
>
|
||||
<header class="settings-group__header">
|
||||
<div class="settings-group__header-icon">
|
||||
<mat-icon>list_alt</mat-icon>
|
||||
</div>
|
||||
<div class="settings-group__header-text">
|
||||
<h3>{{ 'SETTINGS.EPG_SOURCES' | translate }}</h3>
|
||||
<p>{{ 'SETTINGS.EPG_SOURCES_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="setting-item setting-item--full-control">
|
||||
<div class="setting-item__control">
|
||||
<ng-container formArrayName="epgUrl">
|
||||
@if (epgUrl().length === 0) {
|
||||
<div
|
||||
class="epg-sources-empty"
|
||||
data-test-id="epg-sources-empty"
|
||||
>
|
||||
<mat-icon class="epg-sources-empty__icon"
|
||||
>event_busy</mat-icon
|
||||
>
|
||||
<h4 class="epg-sources-empty__title">
|
||||
{{ 'SETTINGS.EPG_EMPTY_TITLE' | translate }}
|
||||
</h4>
|
||||
<p class="epg-sources-empty__hint">
|
||||
{{ 'SETTINGS.EPG_EMPTY_HINT' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="epg-sources">
|
||||
@for (_ of epgUrl().controls; track _; let i = $index) {
|
||||
<div class="epg-source-row">
|
||||
<mat-form-field
|
||||
appearance="outline"
|
||||
subscriptSizing="dynamic"
|
||||
class="w-full"
|
||||
>
|
||||
<mat-label>{{
|
||||
'SETTINGS.EPG_URL_PLACEHOLDER'
|
||||
| translate
|
||||
}}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="url"
|
||||
[formControlName]="i"
|
||||
#epgField
|
||||
/>
|
||||
<mat-error>{{
|
||||
'SETTINGS.EPG_URL_ERROR' | translate
|
||||
}}</mat-error>
|
||||
</mat-form-field>
|
||||
@if (epgField.value) {
|
||||
<app-epg-source-status
|
||||
[url]="epgField.value"
|
||||
/>
|
||||
}
|
||||
<button
|
||||
mat-icon-button
|
||||
[matTooltip]="
|
||||
'SETTINGS.REFRESH_EPG' | translate
|
||||
"
|
||||
type="button"
|
||||
[disabled]="epgField.value === ''"
|
||||
(click)="refreshEpg.emit(epgUrl().value[i])"
|
||||
>
|
||||
<mat-icon>refresh</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
[matTooltip]="
|
||||
'SETTINGS.REMOVE_EPG_SOURCE' | translate
|
||||
"
|
||||
type="button"
|
||||
(click)="removeEpgSource.emit(i)"
|
||||
>
|
||||
<mat-icon>remove</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="settings-group__footer">
|
||||
<div class="inline-actions">
|
||||
<button mat-button type="button" (click)="addEpgSource.emit()">
|
||||
<mat-icon>add</mat-icon>
|
||||
{{ 'SETTINGS.ADD_EPG_SOURCE' | translate }}
|
||||
</button>
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
[disabled]="epgUrl().length === 0 || isClearingEpgData()"
|
||||
(click)="refreshAllEpg.emit()"
|
||||
>
|
||||
<mat-icon>refresh</mat-icon>
|
||||
{{ 'SETTINGS.REFRESH_ALL_EPG' | translate }}
|
||||
</button>
|
||||
<button
|
||||
mat-button
|
||||
color="warn"
|
||||
type="button"
|
||||
[disabled]="isClearingEpgData()"
|
||||
(click)="clearEpgData.emit()"
|
||||
>
|
||||
@if (isClearingEpgData()) {
|
||||
<mat-progress-spinner
|
||||
class="button-spinner"
|
||||
diameter="16"
|
||||
mode="indeterminate"
|
||||
/>
|
||||
{{ 'SETTINGS.CLEARING_EPG_DATA' | translate }}
|
||||
} @else {
|
||||
{{ 'SETTINGS.CLEAR_EPG_DATA' | translate }}
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</section>
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Component, input, output, ViewEncapsulation } from '@angular/core';
|
||||
import { FormArray, FormGroup, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { EpgSourceStatusComponent } from '@iptvnator/ui/epg';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings-epg-section',
|
||||
imports: [
|
||||
EpgSourceStatusComponent,
|
||||
MatButtonModule,
|
||||
MatFormFieldModule,
|
||||
MatIconModule,
|
||||
MatInputModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatTooltipModule,
|
||||
ReactiveFormsModule,
|
||||
TranslateModule,
|
||||
],
|
||||
templateUrl: './settings-epg-section.component.html',
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
styles: [':host { display: contents; }'],
|
||||
})
|
||||
export class SettingsEpgSectionComponent {
|
||||
readonly form = input.required<FormGroup>();
|
||||
readonly activeSection = input.required<string>();
|
||||
readonly epgUrl = input.required<FormArray>();
|
||||
readonly isClearingEpgData = input(false);
|
||||
|
||||
readonly refreshEpg = output<string>();
|
||||
readonly removeEpgSource = output<number>();
|
||||
readonly addEpgSource = output<void>();
|
||||
readonly refreshAllEpg = output<void>();
|
||||
readonly clearEpgData = output<void>();
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { FormControl, Validators } from '@angular/forms';
|
||||
|
||||
export const EPG_URL_PATTERN = /^(http|https|file):\/\/[^ "]+$/;
|
||||
|
||||
export function createEpgUrlControl(value = ''): FormControl<string | null> {
|
||||
return new FormControl(value, [Validators.pattern(EPG_URL_PATTERN)]);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
<section
|
||||
class="settings-group"
|
||||
[class.settings-group--active]="activeSection() === 'general'"
|
||||
id="general"
|
||||
[formGroup]="form()"
|
||||
>
|
||||
<header class="settings-group__header">
|
||||
<div class="settings-group__header-icon">
|
||||
<mat-icon>settings</mat-icon>
|
||||
</div>
|
||||
<div class="settings-group__header-text">
|
||||
<h3>{{ 'SETTINGS.GENERAL' | translate }}</h3>
|
||||
<p>{{ 'SETTINGS.GENERAL_SUBTITLE' | translate }}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.LANGUAGE' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.LANGUAGE_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>{{
|
||||
'SETTINGS.VIDEO_PLAYER_PLACEHOLDER' | translate
|
||||
}}</mat-label>
|
||||
<mat-select
|
||||
formControlName="language"
|
||||
data-test-id="select-language"
|
||||
>
|
||||
@for (
|
||||
language of languageEnum() | keyvalue;
|
||||
track language
|
||||
) {
|
||||
<mat-option
|
||||
[value]="language.value"
|
||||
[attr.data-test-id]="language.value"
|
||||
>{{
|
||||
'LANGUAGES.' + language.key | translate
|
||||
}}</mat-option
|
||||
>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item setting-item--full-control">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.THEME' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.THEME_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<div
|
||||
class="theme-switcher"
|
||||
role="radiogroup"
|
||||
[attr.aria-label]="'SETTINGS.THEME' | translate"
|
||||
data-test-id="select-theme"
|
||||
>
|
||||
@for (themeOption of themeOptions(); track themeOption.value) {
|
||||
<button
|
||||
type="button"
|
||||
class="theme-switcher__option"
|
||||
[class.theme-switcher__option--selected]="
|
||||
form().value.theme === themeOption.value
|
||||
"
|
||||
[attr.aria-checked]="
|
||||
form().value.theme === themeOption.value
|
||||
"
|
||||
[attr.data-test-id]="themeOption.value"
|
||||
role="radio"
|
||||
(click)="selectTheme.emit(themeOption.value)"
|
||||
>
|
||||
<mat-icon>{{ themeOption.icon }}</mat-icon>
|
||||
<span>{{ themeOption.labelKey | translate }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item setting-item--full-control">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.COVER_SIZE' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.COVER_SIZE_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<div
|
||||
class="theme-switcher"
|
||||
role="radiogroup"
|
||||
[attr.aria-label]="'SETTINGS.COVER_SIZE' | translate"
|
||||
data-test-id="select-cover-size"
|
||||
>
|
||||
@for (option of coverSizeOptions(); track option.value) {
|
||||
<button
|
||||
type="button"
|
||||
class="theme-switcher__option"
|
||||
[class.theme-switcher__option--selected]="
|
||||
form().value.coverSize === option.value
|
||||
"
|
||||
[attr.aria-checked]="
|
||||
form().value.coverSize === option.value
|
||||
"
|
||||
[attr.data-test-id]="'cover-size-' + option.value"
|
||||
role="radio"
|
||||
(click)="selectCoverSize.emit(option.value)"
|
||||
>
|
||||
<mat-icon>{{ option.icon }}</mat-icon>
|
||||
<span>{{ option.labelKey | translate }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.SHOW_DASHBOARD' | translate }}</h4>
|
||||
<p>
|
||||
{{ 'SETTINGS.SHOW_DASHBOARD_DESCRIPTION' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox
|
||||
formControlName="showDashboard"
|
||||
data-test-id="toggle-show-dashboard"
|
||||
></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.STARTUP_BEHAVIOR' | translate }}</h4>
|
||||
<p>
|
||||
{{ 'SETTINGS.STARTUP_BEHAVIOR_DESCRIPTION' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>{{
|
||||
'SETTINGS.STARTUP_BEHAVIOR' | translate
|
||||
}}</mat-label>
|
||||
<mat-select
|
||||
formControlName="startupBehavior"
|
||||
data-test-id="select-startup-behavior"
|
||||
>
|
||||
@for (
|
||||
option of startupBehaviorOptions();
|
||||
track option.value
|
||||
) {
|
||||
<mat-option
|
||||
[value]="option.value"
|
||||
[attr.data-test-id]="option.value"
|
||||
>
|
||||
{{ option.labelKey | translate }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.SHOW_CAPTIONS' | translate }}</h4>
|
||||
<p>
|
||||
{{ 'SETTINGS.SHOW_CAPTIONS_DESCRIPTION' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox formControlName="showCaptions"></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,41 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, input, output, ViewEncapsulation } from '@angular/core';
|
||||
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { CoverSize, Language, Theme } from 'shared-interfaces';
|
||||
import {
|
||||
CoverSizeOption,
|
||||
StartupBehaviorOption,
|
||||
ThemeOption,
|
||||
} from './settings.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings-general-section',
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatCheckboxModule,
|
||||
MatFormFieldModule,
|
||||
MatIconModule,
|
||||
MatSelectModule,
|
||||
ReactiveFormsModule,
|
||||
TranslateModule,
|
||||
],
|
||||
templateUrl: './settings-general-section.component.html',
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
styles: [':host { display: contents; }'],
|
||||
})
|
||||
export class SettingsGeneralSectionComponent {
|
||||
readonly form = input.required<FormGroup>();
|
||||
readonly activeSection = input.required<string>();
|
||||
readonly languageEnum = input.required<typeof Language>();
|
||||
readonly themeOptions = input.required<ThemeOption[]>();
|
||||
readonly coverSizeOptions = input.required<CoverSizeOption[]>();
|
||||
readonly startupBehaviorOptions = input.required<StartupBehaviorOption[]>();
|
||||
|
||||
readonly selectTheme = output<Theme>();
|
||||
readonly selectCoverSize = output<CoverSize>();
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import {
|
||||
CoverSize,
|
||||
StartupBehavior,
|
||||
Theme,
|
||||
VideoPlayer,
|
||||
} from 'shared-interfaces';
|
||||
import {
|
||||
CoverSizeOption,
|
||||
SettingsPlayerOption,
|
||||
SettingsSection,
|
||||
StartupBehaviorOption,
|
||||
ThemeOption,
|
||||
} from './settings.models';
|
||||
|
||||
export const SETTINGS_THEME_OPTIONS: ThemeOption[] = [
|
||||
{
|
||||
value: Theme.LightTheme,
|
||||
icon: 'light_mode',
|
||||
labelKey: 'THEMES.LIGHT_THEME',
|
||||
},
|
||||
{
|
||||
value: Theme.DarkTheme,
|
||||
icon: 'dark_mode',
|
||||
labelKey: 'THEMES.DARK_THEME',
|
||||
},
|
||||
{
|
||||
value: Theme.SystemTheme,
|
||||
icon: 'desktop_windows',
|
||||
labelKey: 'THEMES.SYSTEM_THEME',
|
||||
},
|
||||
];
|
||||
|
||||
export const SETTINGS_COVER_SIZE_OPTIONS: CoverSizeOption[] = [
|
||||
{
|
||||
value: 'small' satisfies CoverSize,
|
||||
icon: 'view_module',
|
||||
labelKey: 'SETTINGS.COVER_SIZE_SMALL',
|
||||
},
|
||||
{
|
||||
value: 'medium' satisfies CoverSize,
|
||||
icon: 'view_comfy',
|
||||
labelKey: 'SETTINGS.COVER_SIZE_MEDIUM',
|
||||
},
|
||||
{
|
||||
value: 'large' satisfies CoverSize,
|
||||
icon: 'view_quilt',
|
||||
labelKey: 'SETTINGS.COVER_SIZE_LARGE',
|
||||
},
|
||||
];
|
||||
|
||||
export const SETTINGS_STARTUP_BEHAVIOR_OPTIONS: StartupBehaviorOption[] = [
|
||||
{
|
||||
value: StartupBehavior.FirstView,
|
||||
labelKey: 'SETTINGS.STARTUP_BEHAVIOR_FIRST_VIEW',
|
||||
},
|
||||
{
|
||||
value: StartupBehavior.RestoreLastView,
|
||||
labelKey: 'SETTINGS.STARTUP_BEHAVIOR_RESTORE_LAST_VIEW',
|
||||
},
|
||||
];
|
||||
|
||||
export const SETTINGS_OS_PLAYER_OPTIONS: SettingsPlayerOption[] = [
|
||||
{
|
||||
id: VideoPlayer.MPV,
|
||||
labelKey: 'SETTINGS.PLAYER_MPV',
|
||||
},
|
||||
{
|
||||
id: VideoPlayer.VLC,
|
||||
labelKey: 'SETTINGS.PLAYER_VLC',
|
||||
},
|
||||
];
|
||||
|
||||
export const SETTINGS_EMBEDDED_PLAYER_OPTIONS: SettingsPlayerOption[] = [
|
||||
{
|
||||
id: VideoPlayer.Html5Player,
|
||||
labelKey: 'SETTINGS.PLAYER_HTML5',
|
||||
},
|
||||
{
|
||||
id: VideoPlayer.VideoJs,
|
||||
labelKey: 'SETTINGS.PLAYER_VIDEOJS',
|
||||
},
|
||||
{
|
||||
id: VideoPlayer.ArtPlayer,
|
||||
labelKey: 'SETTINGS.PLAYER_ARTPLAYER',
|
||||
},
|
||||
];
|
||||
|
||||
export function buildSettingsSectionNavItems(
|
||||
isDesktop: boolean
|
||||
): SettingsSection[] {
|
||||
return [
|
||||
{
|
||||
id: 'general',
|
||||
label: 'SETTINGS.NAV_GENERAL',
|
||||
icon: 'tune',
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
id: 'playback',
|
||||
label: 'SETTINGS.NAV_PLAYBACK',
|
||||
icon: 'play_circle',
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
id: 'epg',
|
||||
label: 'SETTINGS.NAV_EPG',
|
||||
icon: 'calendar_month',
|
||||
visible: isDesktop,
|
||||
},
|
||||
{
|
||||
id: 'remote-control',
|
||||
label: 'SETTINGS.NAV_REMOTE',
|
||||
icon: 'smartphone',
|
||||
visible: isDesktop,
|
||||
},
|
||||
{
|
||||
id: 'backup',
|
||||
label: 'SETTINGS.NAV_BACKUP',
|
||||
icon: 'backup',
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
id: 'reset',
|
||||
label: 'SETTINGS.NAV_RESET',
|
||||
icon: 'delete_sweep',
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
id: 'about',
|
||||
label: 'SETTINGS.NAV_ABOUT',
|
||||
icon: 'info',
|
||||
visible: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
<section
|
||||
class="settings-group"
|
||||
[class.settings-group--active]="activeSection() === 'playback'"
|
||||
id="playback"
|
||||
[formGroup]="form()"
|
||||
>
|
||||
<header class="settings-group__header">
|
||||
<div class="settings-group__header-icon">
|
||||
<mat-icon>play_circle</mat-icon>
|
||||
</div>
|
||||
<div class="settings-group__header-text">
|
||||
<h3>{{ 'SETTINGS.PLAYBACK' | translate }}</h3>
|
||||
<p>{{ 'SETTINGS.VIDEO_PLAYER_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.VIDEO_PLAYER_LABEL' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.VIDEO_PLAYER_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>{{
|
||||
'SETTINGS.VIDEO_PLAYER_PLACEHOLDER' | translate
|
||||
}}</mat-label>
|
||||
<mat-select
|
||||
formControlName="player"
|
||||
data-test-id="select-video-player"
|
||||
>
|
||||
@for (player of players(); track player.id) {
|
||||
<mat-option
|
||||
[value]="player.id"
|
||||
[attr.data-test-id]="player.id"
|
||||
>{{ player.labelKey | translate }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.STREAM_FORMAT' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.STREAM_FORMAT_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>{{
|
||||
'SETTINGS.VIDEO_PLAYER_PLACEHOLDER' | translate
|
||||
}}</mat-label>
|
||||
<mat-select
|
||||
formControlName="streamFormat"
|
||||
data-test-id="select-stream-format"
|
||||
>
|
||||
@for (
|
||||
streamFormat of streamFormatEnum() | keyvalue;
|
||||
track streamFormat
|
||||
) {
|
||||
<mat-option
|
||||
[value]="streamFormat.value"
|
||||
[attr.data-test-id]="streamFormat.value"
|
||||
>{{ streamFormat.value }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (isDesktop()) {
|
||||
<div class="setting-item">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.SHOW_EXTERNAL_PLAYBACK_BAR' | translate }}</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox
|
||||
formControlName="showExternalPlaybackBar"
|
||||
></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (form().value.player === 'mpv') {
|
||||
<div class="setting-item">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.MPV_PLAYER_PATH_LABEL' | translate }}</h4>
|
||||
<p>
|
||||
{{ 'SETTINGS.MPV_PLAYER_PATH_DESCRIPTION' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label for="mpvPlayerPath">{{
|
||||
'SETTINGS.MPV_PLAYER_PATH' | translate
|
||||
}}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="text"
|
||||
id="mpvPlayerPath"
|
||||
formControlName="mpvPlayerPath"
|
||||
/>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.MPV_REUSE_INSTANCE_LABEL' | translate }}</h4>
|
||||
<p>
|
||||
{{ 'SETTINGS.MPV_REUSE_INSTANCE_DESCRIPTION' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox formControlName="mpvReuseInstance"></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (form().value.player === 'vlc') {
|
||||
<div class="setting-item">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.VLC_PLAYER_PATH_LABEL' | translate }}</h4>
|
||||
<p>
|
||||
{{ 'SETTINGS.VLC_PLAYER_PATH_DESCRIPTION' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label for="vlcPlayerPath">{{
|
||||
'SETTINGS.VLC_PLAYER_PATH' | translate
|
||||
}}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="text"
|
||||
id="vlcPlayerPath"
|
||||
formControlName="vlcPlayerPath"
|
||||
/>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
@@ -0,0 +1,35 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, input, ViewEncapsulation } from '@angular/core';
|
||||
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { StreamFormat } from 'shared-interfaces';
|
||||
import { SettingsPlayerOption } from './settings.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings-playback-section',
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatCheckboxModule,
|
||||
MatFormFieldModule,
|
||||
MatIconModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
ReactiveFormsModule,
|
||||
TranslateModule,
|
||||
],
|
||||
templateUrl: './settings-playback-section.component.html',
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
styles: [':host { display: contents; }'],
|
||||
})
|
||||
export class SettingsPlaybackSectionComponent {
|
||||
readonly form = input.required<FormGroup>();
|
||||
readonly activeSection = input.required<string>();
|
||||
readonly players = input.required<SettingsPlayerOption[]>();
|
||||
readonly streamFormatEnum = input.required<typeof StreamFormat>();
|
||||
readonly isDesktop = input(false);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
<section
|
||||
class="settings-group"
|
||||
[class.settings-group--active]="activeSection() === 'remote-control'"
|
||||
id="remote-control"
|
||||
[formGroup]="form()"
|
||||
>
|
||||
<header class="settings-group__header">
|
||||
<div class="settings-group__header-icon">
|
||||
<mat-icon>settings_remote</mat-icon>
|
||||
</div>
|
||||
<div class="settings-group__header-text">
|
||||
<h3>{{ 'SETTINGS.REMOTE_CONTROL' | translate }}</h3>
|
||||
<p>{{ 'SETTINGS.REMOTE_CONTROL_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.REMOTE_CONTROL' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.REMOTE_CONTROL_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox
|
||||
formControlName="remoteControl"
|
||||
data-test-id="remote-control-enabled"
|
||||
></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (form().value.remoteControl === true) {
|
||||
<div class="setting-item">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.REMOTE_CONTROL_PORT' | translate }}</h4>
|
||||
<p>
|
||||
{{ 'SETTINGS.REMOTE_CONTROL_PORT_DESCRIPTION' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>{{
|
||||
'SETTINGS.PORT_RANGE_LABEL' | translate
|
||||
}}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="number"
|
||||
id="remoteControlPort"
|
||||
data-test-id="remote-control-port"
|
||||
formControlName="remoteControlPort"
|
||||
min="1"
|
||||
max="65535"
|
||||
step="1"
|
||||
/>
|
||||
@if (
|
||||
form().get('remoteControlPort')?.hasError('required')
|
||||
) {
|
||||
<mat-error>{{
|
||||
'SETTINGS.PORT_REQUIRED' | translate
|
||||
}}</mat-error>
|
||||
}
|
||||
@if (form().get('remoteControlPort')?.hasError('min')) {
|
||||
<mat-error>{{
|
||||
'SETTINGS.PORT_MIN' | translate
|
||||
}}</mat-error>
|
||||
}
|
||||
@if (form().get('remoteControlPort')?.hasError('max')) {
|
||||
<mat-error>{{
|
||||
'SETTINGS.PORT_MAX' | translate
|
||||
}}</mat-error>
|
||||
}
|
||||
@if (form().get('remoteControlPort')?.hasError('pattern')) {
|
||||
<mat-error>{{
|
||||
'SETTINGS.PORT_PATTERN' | translate
|
||||
}}</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (localIpAddresses().length > 0) {
|
||||
<div class="setting-item">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.REMOTE_CONTROL_URL' | translate }}</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.REMOTE_CONTROL_URL_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control remote-control-urls">
|
||||
@for (ip of localIpAddresses(); track ip) {
|
||||
<div class="remote-control-url-item">
|
||||
<div class="url-row">
|
||||
<a
|
||||
[href]="
|
||||
'http://' +
|
||||
ip +
|
||||
':' +
|
||||
form().value.remoteControlPort
|
||||
"
|
||||
target="_blank"
|
||||
class="remote-control-url"
|
||||
>
|
||||
http://{{ ip }}:{{
|
||||
form().value.remoteControlPort
|
||||
}}
|
||||
</a>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
(click)="toggleQrCode.emit(ip)"
|
||||
[matTooltip]="
|
||||
'SETTINGS.SHOW_QR_CODE' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>qr_code_2</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
@if (visibleQrCodeIp() === ip) {
|
||||
<div class="qr-code-container">
|
||||
<qrcode
|
||||
[qrdata]="
|
||||
'http://' +
|
||||
ip +
|
||||
':' +
|
||||
form().value.remoteControlPort
|
||||
"
|
||||
[width]="150"
|
||||
errorCorrectionLevel="M"
|
||||
></qrcode>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Component, input, output, ViewEncapsulation } from '@angular/core';
|
||||
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { QRCodeComponent } from 'angularx-qrcode';
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings-remote-control-section',
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
MatCheckboxModule,
|
||||
MatFormFieldModule,
|
||||
MatIconModule,
|
||||
MatInputModule,
|
||||
MatTooltipModule,
|
||||
QRCodeComponent,
|
||||
ReactiveFormsModule,
|
||||
TranslateModule,
|
||||
],
|
||||
templateUrl: './settings-remote-control-section.component.html',
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
styles: [':host { display: contents; }'],
|
||||
})
|
||||
export class SettingsRemoteControlSectionComponent {
|
||||
readonly form = input.required<FormGroup>();
|
||||
readonly activeSection = input.required<string>();
|
||||
readonly localIpAddresses = input.required<string[]>();
|
||||
readonly visibleQrCodeIp = input<string | null>(null);
|
||||
|
||||
readonly toggleQrCode = output<string>();
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<section
|
||||
class="settings-group"
|
||||
[class.settings-group--active]="activeSection() === 'reset'"
|
||||
id="reset"
|
||||
>
|
||||
<header class="settings-group__header">
|
||||
<div class="settings-group__header-icon">
|
||||
<mat-icon>delete_sweep</mat-icon>
|
||||
</div>
|
||||
<div class="settings-group__header-text">
|
||||
<h3>{{ 'SETTINGS.RESET' | translate }}</h3>
|
||||
<p>{{ 'SETTINGS.RESET_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="setting-item danger-zone">
|
||||
<div class="danger-zone__header">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.REMOVE_ALL' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.REMOVE_ALL_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
<button
|
||||
mat-button
|
||||
class="danger-zone__button"
|
||||
[class.danger-zone__button--busy]="isRemovingAllPlaylists()"
|
||||
[disabled]="!canRemoveAllPlaylists()"
|
||||
[attr.aria-busy]="isRemovingAllPlaylists()"
|
||||
(click)="
|
||||
$event.preventDefault();
|
||||
$event.stopPropagation();
|
||||
removeAll.emit()
|
||||
"
|
||||
color="warn"
|
||||
>
|
||||
@if (isRemovingAllPlaylists()) {
|
||||
<mat-progress-spinner
|
||||
class="button-spinner"
|
||||
diameter="16"
|
||||
mode="indeterminate"
|
||||
/>
|
||||
<span class="inline-actions__label">{{
|
||||
'SETTINGS.REMOVE_ALL_IN_PROGRESS' | translate
|
||||
}}</span>
|
||||
} @else {
|
||||
<span class="inline-actions__label">{{
|
||||
'SETTINGS.REMOVE_ALL_BUTTON' | translate
|
||||
}}</span>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
<div class="danger-zone__summary" aria-live="polite">
|
||||
<span class="danger-zone__summary-pill">
|
||||
<mat-icon class="danger-zone__summary-icon"
|
||||
>folder_open</mat-icon
|
||||
>
|
||||
<span class="danger-zone__summary-label">{{
|
||||
'HOME.PLAYLIST_TYPES.M3U' | translate
|
||||
}}</span>
|
||||
<span class="danger-zone__summary-count">{{
|
||||
playlistDeleteSummary().m3u
|
||||
}}</span>
|
||||
</span>
|
||||
<span class="danger-zone__summary-pill">
|
||||
<mat-icon class="danger-zone__summary-icon">cloud</mat-icon>
|
||||
<span class="danger-zone__summary-label">{{
|
||||
'HOME.PLAYLIST_TYPES.XTREAM' | translate
|
||||
}}</span>
|
||||
<span class="danger-zone__summary-count">{{
|
||||
playlistDeleteSummary().xtream
|
||||
}}</span>
|
||||
</span>
|
||||
<span class="danger-zone__summary-pill">
|
||||
<mat-icon class="danger-zone__summary-icon">cast</mat-icon>
|
||||
<span class="danger-zone__summary-label">{{
|
||||
'HOME.PLAYLIST_TYPES.STALKER' | translate
|
||||
}}</span>
|
||||
<span class="danger-zone__summary-count">{{
|
||||
playlistDeleteSummary().stalker
|
||||
}}</span>
|
||||
</span>
|
||||
</div>
|
||||
@if (removeAllProgressLabel(); as progressLabel) {
|
||||
<p class="danger-zone__progress">
|
||||
{{ progressLabel }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Component, input, output, ViewEncapsulation } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { SettingsPlaylistDeleteSummary } from './settings.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings-reset-section',
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatProgressSpinnerModule,
|
||||
TranslateModule,
|
||||
],
|
||||
templateUrl: './settings-reset-section.component.html',
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
styles: [':host { display: contents; }'],
|
||||
})
|
||||
export class SettingsResetSectionComponent {
|
||||
readonly activeSection = input.required<string>();
|
||||
readonly isRemovingAllPlaylists = input(false);
|
||||
readonly canRemoveAllPlaylists = input(false);
|
||||
readonly playlistDeleteSummary =
|
||||
input.required<SettingsPlaylistDeleteSummary>();
|
||||
readonly removeAllProgressLabel = input<string | null>(null);
|
||||
|
||||
readonly removeAll = output<void>();
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import {
|
||||
AfterViewInit,
|
||||
Directive,
|
||||
effect,
|
||||
ElementRef,
|
||||
inject,
|
||||
Injector,
|
||||
OnDestroy,
|
||||
} from '@angular/core';
|
||||
import { SettingsContextService } from '@iptvnator/workspace/shell/util';
|
||||
import { ObservedSettingsSection } from './settings.models';
|
||||
|
||||
@Directive({
|
||||
selector: '[appSettingsSectionScroll]',
|
||||
})
|
||||
export class SettingsSectionScrollDirective
|
||||
implements AfterViewInit, OnDestroy
|
||||
{
|
||||
private static readonly SECTION_SCROLL_TOP_GUTTER = 112;
|
||||
private static readonly SECTION_SCROLL_BOTTOM_GUTTER = 124;
|
||||
private static readonly PENDING_SCROLL_CLEAR_DELAY_MS = 600;
|
||||
|
||||
private readonly elementRef = inject(ElementRef<HTMLElement>);
|
||||
private readonly injector = inject(Injector);
|
||||
private readonly settingsCtx = inject(SettingsContextService);
|
||||
|
||||
private sectionObserver?: IntersectionObserver;
|
||||
private pendingScrollClearTimer: ReturnType<
|
||||
typeof window.setTimeout
|
||||
> | null = null;
|
||||
private pendingScrollClearRoot: HTMLElement | null = null;
|
||||
private pendingScrollEndListener: (() => void) | null = null;
|
||||
|
||||
constructor() {
|
||||
effect(
|
||||
() => {
|
||||
const sectionId = this.settingsCtx.pendingScrollTarget();
|
||||
if (!sectionId || typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollRoot = this.scrollToSection(sectionId);
|
||||
this.schedulePendingScrollTargetClear(scrollRoot);
|
||||
},
|
||||
{ injector: this.injector }
|
||||
);
|
||||
|
||||
effect(
|
||||
(onCleanup) => {
|
||||
const activeSectionId = this.settingsCtx.activeSection();
|
||||
const activeSectionElement =
|
||||
this.elementRef.nativeElement.querySelector(
|
||||
`#${activeSectionId}`
|
||||
) as HTMLElement | null;
|
||||
|
||||
if (!activeSectionElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const animation = activeSectionElement.animate(
|
||||
[
|
||||
{
|
||||
boxShadow:
|
||||
'inset 0 0 0 1px var(--settings-group-active-ring), 0 8px 18px -24px var(--settings-group-active-glow)',
|
||||
},
|
||||
{
|
||||
boxShadow:
|
||||
'inset 0 0 0 1px var(--settings-group-active-ring), 0 12px 22px -24px var(--settings-group-active-glow)',
|
||||
},
|
||||
{
|
||||
boxShadow:
|
||||
'inset 0 0 0 1px var(--settings-group-active-ring), 0 8px 18px -24px var(--settings-group-active-glow)',
|
||||
},
|
||||
],
|
||||
{
|
||||
duration: 260,
|
||||
easing: 'ease-out',
|
||||
}
|
||||
);
|
||||
|
||||
onCleanup(() => animation.cancel());
|
||||
},
|
||||
{ injector: this.injector }
|
||||
);
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
requestAnimationFrame(() => this.setupSectionObserver());
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.cancelPendingScrollTargetClear();
|
||||
this.sectionObserver?.disconnect();
|
||||
}
|
||||
|
||||
private setupSectionObserver(): void {
|
||||
if (typeof IntersectionObserver === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollRoot = this.getScrollRoot();
|
||||
const contentSections = Array.from(
|
||||
this.elementRef.nativeElement.querySelectorAll(
|
||||
'.settings-group[id]'
|
||||
)
|
||||
) as HTMLElement[];
|
||||
const sections: ObservedSettingsSection[] = contentSections.map(
|
||||
(section) => ({
|
||||
id: section.id,
|
||||
element: section,
|
||||
})
|
||||
);
|
||||
|
||||
if (sections.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.sectionObserver?.disconnect();
|
||||
this.sectionObserver = new IntersectionObserver(
|
||||
() => {
|
||||
if (this.settingsCtx.pendingScrollTarget()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeSection = this.resolveActiveSection(sections);
|
||||
if (activeSection) {
|
||||
this.settingsCtx.setActiveSection(activeSection);
|
||||
}
|
||||
},
|
||||
{
|
||||
root: scrollRoot,
|
||||
threshold: [0.12, 0.24, 0.4, 0.6],
|
||||
rootMargin: '-18% 0px -52% 0px',
|
||||
}
|
||||
);
|
||||
|
||||
sections.forEach((section) =>
|
||||
this.sectionObserver?.observe(section.element)
|
||||
);
|
||||
|
||||
const initialSection = this.resolveActiveSection(sections);
|
||||
if (initialSection) {
|
||||
this.settingsCtx.setActiveSection(initialSection);
|
||||
}
|
||||
}
|
||||
|
||||
private resolveActiveSection(
|
||||
sections: ObservedSettingsSection[]
|
||||
): string | null {
|
||||
const scrollRoot = this.getScrollRoot();
|
||||
const rootTop = scrollRoot?.getBoundingClientRect().top ?? 0;
|
||||
const rootHeight = scrollRoot?.clientHeight ?? window.innerHeight;
|
||||
const activationLine = rootTop + Math.min(rootHeight * 0.28, 220);
|
||||
const sectionAtActivationLine = sections.find((section) => {
|
||||
const rect = section.element.getBoundingClientRect();
|
||||
return rect.top <= activationLine && rect.bottom >= activationLine;
|
||||
});
|
||||
|
||||
if (sectionAtActivationLine) {
|
||||
return sectionAtActivationLine.id;
|
||||
}
|
||||
|
||||
const nearestSection = sections
|
||||
.map((section) => ({
|
||||
id: section.id,
|
||||
distance: Math.abs(
|
||||
section.element.getBoundingClientRect().top - activationLine
|
||||
),
|
||||
}))
|
||||
.sort((a, b) => a.distance - b.distance)[0];
|
||||
|
||||
return nearestSection?.id ?? null;
|
||||
}
|
||||
|
||||
private getScrollRoot(): HTMLElement | null {
|
||||
return this.elementRef.nativeElement.closest(
|
||||
'main.workspace-content'
|
||||
) as HTMLElement | null;
|
||||
}
|
||||
|
||||
private schedulePendingScrollTargetClear(
|
||||
scrollRoot: HTMLElement | null
|
||||
): void {
|
||||
const clearPendingScrollTarget = () => {
|
||||
this.cancelPendingScrollTargetClear();
|
||||
this.settingsCtx.clearPendingScrollTarget();
|
||||
};
|
||||
|
||||
this.cancelPendingScrollTargetClear();
|
||||
this.pendingScrollClearTimer = window.setTimeout(
|
||||
clearPendingScrollTarget,
|
||||
SettingsSectionScrollDirective.PENDING_SCROLL_CLEAR_DELAY_MS
|
||||
);
|
||||
this.pendingScrollClearRoot = scrollRoot;
|
||||
this.pendingScrollEndListener = clearPendingScrollTarget;
|
||||
scrollRoot?.addEventListener?.('scrollend', clearPendingScrollTarget, {
|
||||
once: true,
|
||||
});
|
||||
}
|
||||
|
||||
private cancelPendingScrollTargetClear(): void {
|
||||
if (this.pendingScrollClearTimer) {
|
||||
clearTimeout(this.pendingScrollClearTimer);
|
||||
this.pendingScrollClearTimer = null;
|
||||
}
|
||||
|
||||
if (this.pendingScrollClearRoot && this.pendingScrollEndListener) {
|
||||
this.pendingScrollClearRoot.removeEventListener?.(
|
||||
'scrollend',
|
||||
this.pendingScrollEndListener
|
||||
);
|
||||
}
|
||||
|
||||
this.pendingScrollClearRoot = null;
|
||||
this.pendingScrollEndListener = null;
|
||||
}
|
||||
|
||||
private scrollToSection(sectionId: string): HTMLElement | null {
|
||||
const sectionElement = document.getElementById(sectionId);
|
||||
if (!sectionElement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const scrollRoot = this.getScrollRoot();
|
||||
if (!scrollRoot) {
|
||||
sectionElement.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const rootRect = scrollRoot.getBoundingClientRect();
|
||||
const sectionRect = sectionElement.getBoundingClientRect();
|
||||
const sectionTop =
|
||||
scrollRoot.scrollTop + (sectionRect.top - rootRect.top);
|
||||
const sectionBottom = sectionTop + sectionRect.height;
|
||||
const visibleTop =
|
||||
scrollRoot.scrollTop +
|
||||
SettingsSectionScrollDirective.SECTION_SCROLL_TOP_GUTTER;
|
||||
const visibleBottom =
|
||||
scrollRoot.scrollTop +
|
||||
scrollRoot.clientHeight -
|
||||
SettingsSectionScrollDirective.SECTION_SCROLL_BOTTOM_GUTTER;
|
||||
let nextScrollTop = scrollRoot.scrollTop;
|
||||
|
||||
if (sectionTop < visibleTop) {
|
||||
nextScrollTop =
|
||||
sectionTop -
|
||||
SettingsSectionScrollDirective.SECTION_SCROLL_TOP_GUTTER;
|
||||
} else if (sectionBottom > visibleBottom) {
|
||||
nextScrollTop =
|
||||
sectionBottom -
|
||||
scrollRoot.clientHeight +
|
||||
SettingsSectionScrollDirective.SECTION_SCROLL_BOTTOM_GUTTER;
|
||||
}
|
||||
|
||||
const maxScrollTop = Math.max(
|
||||
0,
|
||||
scrollRoot.scrollHeight - scrollRoot.clientHeight
|
||||
);
|
||||
|
||||
scrollRoot.scrollTo({
|
||||
top: Math.min(Math.max(nextScrollTop, 0), maxScrollTop),
|
||||
behavior: 'smooth',
|
||||
});
|
||||
|
||||
return scrollRoot;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,4 +1,4 @@
|
||||
:host {
|
||||
.settings-page-host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -745,11 +745,7 @@
|
||||
var(--mat-sys-on-surface) 12%,
|
||||
transparent
|
||||
);
|
||||
color: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-on-surface) 38%,
|
||||
transparent
|
||||
);
|
||||
color: color-mix(in srgb, var(--mat-sys-on-surface) 38%, transparent);
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { MatListModule } from '@angular/material/list';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { Router } from '@angular/router';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { EpgService } from '@iptvnator/epg/data-access';
|
||||
@@ -52,6 +53,7 @@ import { from, of } from 'rxjs';
|
||||
import { ElectronServiceStub } from '../services/electron.service.stub';
|
||||
import { SettingsStore } from '../services/settings-store.service';
|
||||
import { SettingsService } from '../services/settings.service';
|
||||
import { SettingsSectionScrollDirective } from './settings-section-scroll.directive';
|
||||
|
||||
class MatSnackBarStub {
|
||||
open = jest.fn();
|
||||
@@ -111,6 +113,10 @@ class MockSettingsService {
|
||||
);
|
||||
}
|
||||
|
||||
interface SettingsSectionScrollDirectiveTestApi {
|
||||
getScrollRoot(): HTMLElement | null;
|
||||
}
|
||||
|
||||
describe('SettingsComponent', () => {
|
||||
let component: SettingsComponent;
|
||||
let fixture: ComponentFixture<SettingsComponent>;
|
||||
@@ -118,7 +124,6 @@ describe('SettingsComponent', () => {
|
||||
let router: Router;
|
||||
let settingsStore: unknown;
|
||||
let translate: TranslateService;
|
||||
let epgService: EpgService;
|
||||
let dialogService: DialogService;
|
||||
let playlistsService: PlaylistsService;
|
||||
let playlistBackupService: PlaylistBackupService;
|
||||
@@ -245,7 +250,6 @@ describe('SettingsComponent', () => {
|
||||
settingsStore = TestBed.inject(SettingsStore);
|
||||
router = TestBed.inject(Router);
|
||||
translate = TestBed.inject(TranslateService);
|
||||
epgService = TestBed.inject(EpgService);
|
||||
dialogService = TestBed.inject(DialogService);
|
||||
playlistsService = TestBed.inject(PlaylistsService);
|
||||
playlistBackupService = TestBed.inject(PlaylistBackupService);
|
||||
@@ -333,10 +337,14 @@ describe('SettingsComponent', () => {
|
||||
scrollComponent.fetchLocalIpAddresses = jest
|
||||
.fn()
|
||||
.mockResolvedValue(undefined);
|
||||
jest.spyOn(scrollComponent as any, 'getScrollRoot').mockReturnValue(
|
||||
scrollRoot
|
||||
);
|
||||
scrollFixture.detectChanges();
|
||||
const scrollDirective = scrollFixture.debugElement
|
||||
.query(By.directive(SettingsSectionScrollDirective))
|
||||
.injector.get(SettingsSectionScrollDirective);
|
||||
jest.spyOn(
|
||||
scrollDirective as unknown as SettingsSectionScrollDirectiveTestApi,
|
||||
'getScrollRoot'
|
||||
).mockReturnValue(scrollRoot);
|
||||
|
||||
const getElementByIdSpy = jest
|
||||
.spyOn(document, 'getElementById')
|
||||
@@ -846,13 +854,35 @@ describe('SettingsComponent', () => {
|
||||
expect(router.navigateByUrl).toHaveBeenCalledWith('/');
|
||||
});
|
||||
|
||||
it('should update the selected theme and mark the form dirty', () => {
|
||||
component.selectTheme(Theme.DarkTheme);
|
||||
it('updates the selected theme through the general section and marks the form dirty', () => {
|
||||
const darkThemeButton = (
|
||||
fixture.nativeElement as HTMLElement
|
||||
).querySelector('[data-test-id="DARK_THEME"]') as HTMLButtonElement;
|
||||
|
||||
darkThemeButton.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.settingsForm.value.theme).toBe(Theme.DarkTheme);
|
||||
expect(component.settingsForm.dirty).toBeTruthy();
|
||||
});
|
||||
|
||||
it('updates cover size through the general section output', () => {
|
||||
const mockStore = settingsStore as unknown as MockSettingsStore;
|
||||
const largeCoverButton = (
|
||||
fixture.nativeElement as HTMLElement
|
||||
).querySelector(
|
||||
'[data-test-id="cover-size-large"]'
|
||||
) as HTMLButtonElement;
|
||||
|
||||
largeCoverButton.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.settingsForm.value.coverSize).toBe('large');
|
||||
expect(mockStore.updateSettings).toHaveBeenCalledWith({
|
||||
coverSize: 'large',
|
||||
});
|
||||
});
|
||||
|
||||
it('renders workspace startup controls with the expected defaults', () => {
|
||||
const nativeElement = fixture.nativeElement as HTMLElement;
|
||||
|
||||
|
||||
@@ -2,45 +2,32 @@ import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
ElementRef,
|
||||
inject,
|
||||
Injector,
|
||||
Input,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
signal,
|
||||
ViewEncapsulation,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
FormArray,
|
||||
FormBuilder,
|
||||
FormControl,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
Validators,
|
||||
} from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatButtonToggleModule } from '@angular/material/button-toggle';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import {
|
||||
MAT_DIALOG_DATA,
|
||||
MatDialog,
|
||||
MatDialogModule,
|
||||
} from '@angular/material/dialog';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatSnackBar, MatSnackBarConfig } from '@angular/material/snack-bar';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { Router } from '@angular/router';
|
||||
import { EpgService } from '@iptvnator/epg/data-access';
|
||||
import { EpgSourceStatusComponent } from '@iptvnator/ui/epg';
|
||||
import { SettingsContextService } from '@iptvnator/workspace/shell/util';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import { QRCodeComponent } from 'angularx-qrcode';
|
||||
import { DialogService } from 'components';
|
||||
import {
|
||||
PlaylistActions,
|
||||
@@ -67,70 +54,57 @@ import {
|
||||
} from 'shared-interfaces';
|
||||
import { SettingsStore } from '../services/settings-store.service';
|
||||
import { SettingsService } from './../services/settings.service';
|
||||
import { SettingsAboutSectionComponent } from './settings-about-section.component';
|
||||
import { SettingsBackupSectionComponent } from './settings-backup-section.component';
|
||||
import {
|
||||
SettingsDeleteAllPlaylistsDialogComponent,
|
||||
SettingsDeleteAllPlaylistsDialogData,
|
||||
} from './settings-delete-all-playlists-dialog.component';
|
||||
|
||||
interface SettingsSection {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
interface ObservedSettingsSection {
|
||||
id: string;
|
||||
element: HTMLElement;
|
||||
}
|
||||
|
||||
interface ThemeOption {
|
||||
value: Theme;
|
||||
icon: string;
|
||||
labelKey: string;
|
||||
}
|
||||
|
||||
interface StartupBehaviorOption {
|
||||
value: StartupBehavior;
|
||||
labelKey: string;
|
||||
}
|
||||
|
||||
interface CoverSizeOption {
|
||||
value: CoverSize;
|
||||
icon: string;
|
||||
labelKey: string;
|
||||
}
|
||||
|
||||
type SettingsPlaylistDeleteSummary =
|
||||
SettingsDeleteAllPlaylistsDialogData['summary'];
|
||||
import { SettingsEpgSectionComponent } from './settings-epg-section.component';
|
||||
import { createEpgUrlControl } from './settings-form.utils';
|
||||
import { SettingsGeneralSectionComponent } from './settings-general-section.component';
|
||||
import {
|
||||
SettingsPlaylistDeleteSummary,
|
||||
SettingsSection,
|
||||
} from './settings.models';
|
||||
import {
|
||||
buildSettingsSectionNavItems,
|
||||
SETTINGS_COVER_SIZE_OPTIONS,
|
||||
SETTINGS_EMBEDDED_PLAYER_OPTIONS,
|
||||
SETTINGS_OS_PLAYER_OPTIONS,
|
||||
SETTINGS_STARTUP_BEHAVIOR_OPTIONS,
|
||||
SETTINGS_THEME_OPTIONS,
|
||||
} from './settings-options';
|
||||
import { SettingsPlaybackSectionComponent } from './settings-playback-section.component';
|
||||
import { SettingsRemoteControlSectionComponent } from './settings-remote-control-section.component';
|
||||
import { SettingsResetSectionComponent } from './settings-reset-section.component';
|
||||
import { SettingsSectionScrollDirective } from './settings-section-scroll.directive';
|
||||
|
||||
@Component({
|
||||
templateUrl: './settings.component.html',
|
||||
styleUrls: ['./settings.component.scss'],
|
||||
host: {
|
||||
class: 'settings-page-host',
|
||||
},
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
imports: [
|
||||
CommonModule,
|
||||
EpgSourceStatusComponent,
|
||||
FormsModule,
|
||||
MatButtonModule,
|
||||
MatButtonToggleModule,
|
||||
MatCheckboxModule,
|
||||
MatDividerModule,
|
||||
MatIconModule,
|
||||
MatInputModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatSelectModule,
|
||||
MatTooltipModule,
|
||||
ReactiveFormsModule,
|
||||
TranslateModule,
|
||||
MatDialogModule,
|
||||
QRCodeComponent,
|
||||
SettingsAboutSectionComponent,
|
||||
SettingsBackupSectionComponent,
|
||||
SettingsEpgSectionComponent,
|
||||
SettingsGeneralSectionComponent,
|
||||
SettingsPlaybackSectionComponent,
|
||||
SettingsRemoteControlSectionComponent,
|
||||
SettingsResetSectionComponent,
|
||||
SettingsSectionScrollDirective,
|
||||
],
|
||||
})
|
||||
export class SettingsComponent implements OnInit, OnDestroy {
|
||||
private static readonly SECTION_SCROLL_TOP_GUTTER = 112;
|
||||
private static readonly SECTION_SCROLL_BOTTOM_GUTTER = 124;
|
||||
private static readonly PENDING_SCROLL_CLEAR_DELAY_MS = 600;
|
||||
|
||||
private dialogService = inject(DialogService);
|
||||
public dataService = inject(DataService);
|
||||
private epgService = inject(EpgService);
|
||||
@@ -143,8 +117,6 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
private translate = inject(TranslateService);
|
||||
private matDialog = inject(MatDialog);
|
||||
private playlistBackupService = inject(PlaylistBackupService);
|
||||
private readonly elementRef = inject(ElementRef<HTMLElement>);
|
||||
private readonly injector = inject(Injector);
|
||||
private readonly databaseService = inject(DatabaseService);
|
||||
private readonly dialogData = inject<{ isDialog: boolean } | null>(
|
||||
MAT_DIALOG_DATA,
|
||||
@@ -179,30 +151,12 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
},
|
||||
]
|
||||
: []), */
|
||||
{
|
||||
id: VideoPlayer.MPV,
|
||||
labelKey: 'SETTINGS.PLAYER_MPV',
|
||||
},
|
||||
{
|
||||
id: VideoPlayer.VLC,
|
||||
labelKey: 'SETTINGS.PLAYER_VLC',
|
||||
},
|
||||
...SETTINGS_OS_PLAYER_OPTIONS,
|
||||
]);
|
||||
|
||||
/** Player options */
|
||||
readonly players = computed(() => [
|
||||
{
|
||||
id: VideoPlayer.Html5Player,
|
||||
labelKey: 'SETTINGS.PLAYER_HTML5',
|
||||
},
|
||||
{
|
||||
id: VideoPlayer.VideoJs,
|
||||
labelKey: 'SETTINGS.PLAYER_VIDEOJS',
|
||||
},
|
||||
{
|
||||
id: VideoPlayer.ArtPlayer,
|
||||
labelKey: 'SETTINGS.PLAYER_ARTPLAYER',
|
||||
},
|
||||
...SETTINGS_EMBEDDED_PLAYER_OPTIONS,
|
||||
...(this.isDesktop ? this.osPlayers() : []),
|
||||
]);
|
||||
|
||||
@@ -216,52 +170,9 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
epgAvailable$ = this.store.select(selectIsEpgAvailable);
|
||||
readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta);
|
||||
|
||||
readonly themeOptions: ThemeOption[] = [
|
||||
{
|
||||
value: Theme.LightTheme,
|
||||
icon: 'light_mode',
|
||||
labelKey: 'THEMES.LIGHT_THEME',
|
||||
},
|
||||
{
|
||||
value: Theme.DarkTheme,
|
||||
icon: 'dark_mode',
|
||||
labelKey: 'THEMES.DARK_THEME',
|
||||
},
|
||||
{
|
||||
value: Theme.SystemTheme,
|
||||
icon: 'desktop_windows',
|
||||
labelKey: 'THEMES.SYSTEM_THEME',
|
||||
},
|
||||
];
|
||||
|
||||
readonly coverSizeOptions: CoverSizeOption[] = [
|
||||
{
|
||||
value: 'small',
|
||||
icon: 'view_module',
|
||||
labelKey: 'SETTINGS.COVER_SIZE_SMALL',
|
||||
},
|
||||
{
|
||||
value: 'medium',
|
||||
icon: 'view_comfy',
|
||||
labelKey: 'SETTINGS.COVER_SIZE_MEDIUM',
|
||||
},
|
||||
{
|
||||
value: 'large',
|
||||
icon: 'view_quilt',
|
||||
labelKey: 'SETTINGS.COVER_SIZE_LARGE',
|
||||
},
|
||||
];
|
||||
|
||||
readonly startupBehaviorOptions: StartupBehaviorOption[] = [
|
||||
{
|
||||
value: StartupBehavior.FirstView,
|
||||
labelKey: 'SETTINGS.STARTUP_BEHAVIOR_FIRST_VIEW',
|
||||
},
|
||||
{
|
||||
value: StartupBehavior.RestoreLastView,
|
||||
labelKey: 'SETTINGS.STARTUP_BEHAVIOR_RESTORE_LAST_VIEW',
|
||||
},
|
||||
];
|
||||
readonly themeOptions = SETTINGS_THEME_OPTIONS;
|
||||
readonly coverSizeOptions = SETTINGS_COVER_SIZE_OPTIONS;
|
||||
readonly startupBehaviorOptions = SETTINGS_STARTUP_BEHAVIOR_OPTIONS;
|
||||
|
||||
/** Settings form object */
|
||||
settingsForm = this.formBuilder.group({
|
||||
@@ -304,57 +215,9 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
readonly removeAllProgress = signal<DbOperationEvent | null>(null);
|
||||
|
||||
private settingsStore = inject(SettingsStore);
|
||||
private sectionObserver?: IntersectionObserver;
|
||||
private pendingScrollClearTimer: ReturnType<
|
||||
typeof window.setTimeout
|
||||
> | null = null;
|
||||
private pendingScrollClearRoot: HTMLElement | null = null;
|
||||
private pendingScrollEndListener: (() => void) | null = null;
|
||||
|
||||
readonly sectionNavItems: SettingsSection[] = [
|
||||
{
|
||||
id: 'general',
|
||||
label: 'SETTINGS.NAV_GENERAL',
|
||||
icon: 'tune',
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
id: 'playback',
|
||||
label: 'SETTINGS.NAV_PLAYBACK',
|
||||
icon: 'play_circle',
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
id: 'epg',
|
||||
label: 'SETTINGS.NAV_EPG',
|
||||
icon: 'calendar_month',
|
||||
visible: this.isDesktop,
|
||||
},
|
||||
{
|
||||
id: 'remote-control',
|
||||
label: 'SETTINGS.NAV_REMOTE',
|
||||
icon: 'smartphone',
|
||||
visible: this.isDesktop,
|
||||
},
|
||||
{
|
||||
id: 'backup',
|
||||
label: 'SETTINGS.NAV_BACKUP',
|
||||
icon: 'backup',
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
id: 'reset',
|
||||
label: 'SETTINGS.NAV_RESET',
|
||||
icon: 'delete_sweep',
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
id: 'about',
|
||||
label: 'SETTINGS.NAV_ABOUT',
|
||||
icon: 'info',
|
||||
visible: true,
|
||||
},
|
||||
];
|
||||
readonly sectionNavItems: SettingsSection[] = buildSettingsSectionNavItems(
|
||||
this.isDesktop
|
||||
);
|
||||
|
||||
readonly playlistDeleteSummary = computed<SettingsPlaylistDeleteSummary>(
|
||||
() => {
|
||||
@@ -400,59 +263,6 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
return this.translate.instant('SETTINGS.REMOVE_ALL_IN_PROGRESS');
|
||||
});
|
||||
|
||||
constructor() {
|
||||
effect(
|
||||
() => {
|
||||
const sectionId = this.settingsCtx.pendingScrollTarget();
|
||||
if (!sectionId || typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollRoot = this.scrollToSection(sectionId);
|
||||
this.schedulePendingScrollTargetClear(scrollRoot);
|
||||
},
|
||||
{ injector: this.injector }
|
||||
);
|
||||
|
||||
effect(
|
||||
(onCleanup) => {
|
||||
const activeSectionId = this.activeSection();
|
||||
const activeSectionElement =
|
||||
this.elementRef.nativeElement.querySelector(
|
||||
`#${activeSectionId}`
|
||||
) as HTMLElement | null;
|
||||
|
||||
if (!activeSectionElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const animation = activeSectionElement.animate(
|
||||
[
|
||||
{
|
||||
boxShadow:
|
||||
'inset 0 0 0 1px var(--settings-group-active-ring), 0 8px 18px -24px var(--settings-group-active-glow)',
|
||||
},
|
||||
{
|
||||
boxShadow:
|
||||
'inset 0 0 0 1px var(--settings-group-active-ring), 0 12px 22px -24px var(--settings-group-active-glow)',
|
||||
},
|
||||
{
|
||||
boxShadow:
|
||||
'inset 0 0 0 1px var(--settings-group-active-ring), 0 8px 18px -24px var(--settings-group-active-glow)',
|
||||
},
|
||||
],
|
||||
{
|
||||
duration: 260,
|
||||
easing: 'ease-out',
|
||||
}
|
||||
);
|
||||
|
||||
onCleanup(() => animation.cancel());
|
||||
},
|
||||
{ injector: this.injector }
|
||||
);
|
||||
}
|
||||
|
||||
get sectionNav(): SettingsSection[] {
|
||||
return this.sectionNavItems.filter((section) => section.visible);
|
||||
}
|
||||
@@ -472,8 +282,6 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
if (!this.isDialog) {
|
||||
this.settingsCtx.setSections(this.sectionNav);
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => this.setupSectionObserver());
|
||||
}
|
||||
|
||||
/* private async loadEmbeddedMpvSupport(): Promise<void> {
|
||||
@@ -512,8 +320,6 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
} */
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.cancelPendingScrollTargetClear();
|
||||
this.sectionObserver?.disconnect();
|
||||
this.settingsCtx.reset();
|
||||
}
|
||||
|
||||
@@ -578,17 +384,13 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
* @param epgUrls urls of the EPG sources
|
||||
*/
|
||||
setEpgUrls(epgUrls: string[] | string): void {
|
||||
const URL_REGEX = /^(http|https|file):\/\/[^ "]+$/;
|
||||
|
||||
const urls = Array.isArray(epgUrls) ? epgUrls : [epgUrls];
|
||||
const filteredUrls = urls
|
||||
.map((url) => url.trim())
|
||||
.filter((url) => url !== '');
|
||||
|
||||
filteredUrls.forEach((url) => {
|
||||
this.epgUrl.push(
|
||||
new FormControl(url, [Validators.pattern(URL_REGEX)])
|
||||
);
|
||||
this.epgUrl.push(createEpgUrlControl(url));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -734,14 +536,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
* Initializes new entry in form array for EPG URL
|
||||
*/
|
||||
addEpgSource(): void {
|
||||
this.epgUrl.insert(
|
||||
this.epgUrl.length,
|
||||
new FormControl('', {
|
||||
validators: [
|
||||
Validators.pattern(/^(http|https|file):\/\/[^ "]+$/),
|
||||
],
|
||||
})
|
||||
);
|
||||
this.epgUrl.insert(this.epgUrl.length, createEpgUrlControl());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -992,179 +787,6 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
this.removeAllProgress.set(event);
|
||||
}
|
||||
|
||||
private setupSectionObserver(): void {
|
||||
if (typeof IntersectionObserver === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollRoot = this.getScrollRoot();
|
||||
const contentSections = Array.from(
|
||||
this.elementRef.nativeElement.querySelectorAll(
|
||||
'.settings-group[id]'
|
||||
)
|
||||
) as HTMLElement[];
|
||||
const sections: ObservedSettingsSection[] = contentSections.map(
|
||||
(section) => ({
|
||||
id: section.id,
|
||||
element: section,
|
||||
})
|
||||
);
|
||||
|
||||
if (sections.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.sectionObserver?.disconnect();
|
||||
this.sectionObserver = new IntersectionObserver(
|
||||
() => {
|
||||
if (this.settingsCtx.pendingScrollTarget()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeSection = this.resolveActiveSection(sections);
|
||||
if (activeSection) {
|
||||
this.settingsCtx.setActiveSection(activeSection);
|
||||
}
|
||||
},
|
||||
{
|
||||
root: scrollRoot,
|
||||
threshold: [0.12, 0.24, 0.4, 0.6],
|
||||
rootMargin: '-18% 0px -52% 0px',
|
||||
}
|
||||
);
|
||||
|
||||
sections.forEach((section) =>
|
||||
this.sectionObserver?.observe(section.element)
|
||||
);
|
||||
|
||||
const initialSection = this.resolveActiveSection(sections);
|
||||
if (initialSection) {
|
||||
this.settingsCtx.setActiveSection(initialSection);
|
||||
}
|
||||
}
|
||||
|
||||
private resolveActiveSection(
|
||||
sections: ObservedSettingsSection[]
|
||||
): string | null {
|
||||
const scrollRoot = this.getScrollRoot();
|
||||
const rootTop = scrollRoot?.getBoundingClientRect().top ?? 0;
|
||||
const rootHeight = scrollRoot?.clientHeight ?? window.innerHeight;
|
||||
const activationLine = rootTop + Math.min(rootHeight * 0.28, 220);
|
||||
const sectionAtActivationLine = sections.find((section) => {
|
||||
const rect = section.element.getBoundingClientRect();
|
||||
return rect.top <= activationLine && rect.bottom >= activationLine;
|
||||
});
|
||||
|
||||
if (sectionAtActivationLine) {
|
||||
return sectionAtActivationLine.id;
|
||||
}
|
||||
|
||||
const nearestSection = sections
|
||||
.map((section) => ({
|
||||
id: section.id,
|
||||
distance: Math.abs(
|
||||
section.element.getBoundingClientRect().top - activationLine
|
||||
),
|
||||
}))
|
||||
.sort((a, b) => a.distance - b.distance)[0];
|
||||
|
||||
return nearestSection?.id ?? null;
|
||||
}
|
||||
|
||||
private getScrollRoot(): HTMLElement | null {
|
||||
return this.elementRef.nativeElement.closest(
|
||||
'main.workspace-content'
|
||||
) as HTMLElement | null;
|
||||
}
|
||||
|
||||
private schedulePendingScrollTargetClear(
|
||||
scrollRoot: HTMLElement | null
|
||||
): void {
|
||||
const clearPendingScrollTarget = () => {
|
||||
this.cancelPendingScrollTargetClear();
|
||||
this.settingsCtx.clearPendingScrollTarget();
|
||||
};
|
||||
|
||||
this.cancelPendingScrollTargetClear();
|
||||
this.pendingScrollClearTimer = window.setTimeout(
|
||||
clearPendingScrollTarget,
|
||||
SettingsComponent.PENDING_SCROLL_CLEAR_DELAY_MS
|
||||
);
|
||||
this.pendingScrollClearRoot = scrollRoot;
|
||||
this.pendingScrollEndListener = clearPendingScrollTarget;
|
||||
scrollRoot?.addEventListener?.('scrollend', clearPendingScrollTarget, {
|
||||
once: true,
|
||||
});
|
||||
}
|
||||
|
||||
private cancelPendingScrollTargetClear(): void {
|
||||
if (this.pendingScrollClearTimer) {
|
||||
clearTimeout(this.pendingScrollClearTimer);
|
||||
this.pendingScrollClearTimer = null;
|
||||
}
|
||||
|
||||
if (this.pendingScrollClearRoot && this.pendingScrollEndListener) {
|
||||
this.pendingScrollClearRoot.removeEventListener?.(
|
||||
'scrollend',
|
||||
this.pendingScrollEndListener
|
||||
);
|
||||
}
|
||||
|
||||
this.pendingScrollClearRoot = null;
|
||||
this.pendingScrollEndListener = null;
|
||||
}
|
||||
|
||||
private scrollToSection(sectionId: string): HTMLElement | null {
|
||||
const sectionElement = document.getElementById(sectionId);
|
||||
if (!sectionElement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const scrollRoot = this.getScrollRoot();
|
||||
if (!scrollRoot) {
|
||||
sectionElement.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const rootRect = scrollRoot.getBoundingClientRect();
|
||||
const sectionRect = sectionElement.getBoundingClientRect();
|
||||
const sectionTop =
|
||||
scrollRoot.scrollTop + (sectionRect.top - rootRect.top);
|
||||
const sectionBottom = sectionTop + sectionRect.height;
|
||||
const visibleTop =
|
||||
scrollRoot.scrollTop + SettingsComponent.SECTION_SCROLL_TOP_GUTTER;
|
||||
const visibleBottom =
|
||||
scrollRoot.scrollTop +
|
||||
scrollRoot.clientHeight -
|
||||
SettingsComponent.SECTION_SCROLL_BOTTOM_GUTTER;
|
||||
let nextScrollTop = scrollRoot.scrollTop;
|
||||
|
||||
if (sectionTop < visibleTop) {
|
||||
nextScrollTop =
|
||||
sectionTop - SettingsComponent.SECTION_SCROLL_TOP_GUTTER;
|
||||
} else if (sectionBottom > visibleBottom) {
|
||||
nextScrollTop =
|
||||
sectionBottom -
|
||||
scrollRoot.clientHeight +
|
||||
SettingsComponent.SECTION_SCROLL_BOTTOM_GUTTER;
|
||||
}
|
||||
|
||||
const maxScrollTop = Math.max(
|
||||
0,
|
||||
scrollRoot.scrollHeight - scrollRoot.clientHeight
|
||||
);
|
||||
|
||||
scrollRoot.scrollTo({
|
||||
top: Math.min(Math.max(nextScrollTop, 0), maxScrollTop),
|
||||
behavior: 'smooth',
|
||||
});
|
||||
|
||||
return scrollRoot;
|
||||
}
|
||||
|
||||
private openSettingsSnackbar(
|
||||
message: string,
|
||||
config: MatSnackBarConfig = {}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
CoverSize,
|
||||
StartupBehavior,
|
||||
Theme,
|
||||
VideoPlayer,
|
||||
} from 'shared-interfaces';
|
||||
|
||||
export interface SettingsSection {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
export interface ObservedSettingsSection {
|
||||
id: string;
|
||||
element: HTMLElement;
|
||||
}
|
||||
|
||||
export interface ThemeOption {
|
||||
value: Theme;
|
||||
icon: string;
|
||||
labelKey: string;
|
||||
}
|
||||
|
||||
export interface StartupBehaviorOption {
|
||||
value: StartupBehavior;
|
||||
labelKey: string;
|
||||
}
|
||||
|
||||
export interface CoverSizeOption {
|
||||
value: CoverSize;
|
||||
icon: string;
|
||||
labelKey: string;
|
||||
}
|
||||
|
||||
export interface SettingsPlayerOption {
|
||||
id: VideoPlayer;
|
||||
labelKey: string;
|
||||
}
|
||||
|
||||
export interface SettingsPlaylistDeleteSummary {
|
||||
total: number;
|
||||
m3u: number;
|
||||
xtream: number;
|
||||
stalker: number;
|
||||
}
|
||||
Reference in new issue
Block a user