refactor(web): split settings component into sections

Entire-Checkpoint: c2cea9c530e6
This commit is contained in:
4gray committed 2026-05-01 20:16:32 +02:00
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 }}
&nbsp;<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;
+44 -422
View File
@@ -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;
}