diff --git a/apps/web/src/app/settings/settings-about-section.component.html b/apps/web/src/app/settings/settings-about-section.component.html new file mode 100644 index 000000000..b0887b15f --- /dev/null +++ b/apps/web/src/app/settings/settings-about-section.component.html @@ -0,0 +1,179 @@ +
+
+
+ info +
+
+

{{ 'SETTINGS.ABOUT' | translate }}

+

{{ 'SETTINGS.ABOUT_SUBTITLE' | translate }}

+
+
+ +
+
+

{{ 'SETTINGS.VERSION' | translate }}

+

{{ 'SETTINGS.VERSION_DESCRIPTION' | translate }}

+
+
+ {{ version() }} + {{ updateMessage() }} +
+
+ + @if (isDesktop()) { +
+ {{ + 'SETTINGS.EMBEDDED_MPV_NOTICE_TITLE' | translate + }} + {{ + 'SETTINGS.EMBEDDED_MPV_NOTICE_DESCRIPTION' | translate + }} +
+ } + +
+
+

{{ 'SETTINGS.COMMUNITY' | translate }}

+

{{ 'SETTINGS.COMMUNITY_DESCRIPTION' | translate }}

+
+ +
+ +
+
+
+
+ favorite +
+
+

{{ 'SETTINGS.SUPPORT_TITLE' | translate }}

+

{{ 'SETTINGS.SUPPORT_DESCRIPTION' | translate }}

+
+
+ +
+
+
diff --git a/apps/web/src/app/settings/settings-about-section.component.ts b/apps/web/src/app/settings/settings-about-section.component.ts new file mode 100644 index 000000000..0c2be99a9 --- /dev/null +++ b/apps/web/src/app/settings/settings-about-section.component.ts @@ -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(); + readonly isDesktop = input(false); + readonly version = input(); + readonly updateMessage = input(); +} diff --git a/apps/web/src/app/settings/settings-backup-section.component.html b/apps/web/src/app/settings/settings-backup-section.component.html new file mode 100644 index 000000000..0a9f7189d --- /dev/null +++ b/apps/web/src/app/settings/settings-backup-section.component.html @@ -0,0 +1,77 @@ +
+
+
+ backup +
+
+

{{ 'SETTINGS.BACKUP' | translate }}

+

{{ 'SETTINGS.BACKUP_DESCRIPTION' | translate }}

+
+
+ +
+
+

{{ 'SETTINGS.IMPORT_EXPORT_DATA' | translate }}

+

+ {{ 'SETTINGS.IMPORT_EXPORT_DATA_DESCRIPTION' | translate }} +

+
+
+ + +
+
+ + @if (isPwa()) { +
+ {{ 'SETTINGS.EPG_NOTE' | translate }} +  {{ 'SETTINGS.EPG_NOTE_URL_TEXT' | translate }} +
+ } +
diff --git a/apps/web/src/app/settings/settings-backup-section.component.ts b/apps/web/src/app/settings/settings-backup-section.component.ts new file mode 100644 index 000000000..9cfb5af6c --- /dev/null +++ b/apps/web/src/app/settings/settings-backup-section.component.ts @@ -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(); + readonly isPwa = input(false); + readonly isRemovingAllPlaylists = input(false); + readonly isExportingData = input(false); + + readonly importData = output(); + readonly exportData = output(); +} diff --git a/apps/web/src/app/settings/settings-epg-section.component.html b/apps/web/src/app/settings/settings-epg-section.component.html new file mode 100644 index 000000000..6e7f01938 --- /dev/null +++ b/apps/web/src/app/settings/settings-epg-section.component.html @@ -0,0 +1,127 @@ +
+
+
+ list_alt +
+
+

{{ 'SETTINGS.EPG_SOURCES' | translate }}

+

{{ 'SETTINGS.EPG_SOURCES_DESCRIPTION' | translate }}

+
+
+ +
+
+ + @if (epgUrl().length === 0) { +
+ event_busy +

+ {{ 'SETTINGS.EPG_EMPTY_TITLE' | translate }} +

+

+ {{ 'SETTINGS.EPG_EMPTY_HINT' | translate }} +

+
+ } @else { +
+ @for (_ of epgUrl().controls; track _; let i = $index) { +
+ + {{ + 'SETTINGS.EPG_URL_PLACEHOLDER' + | translate + }} + + {{ + 'SETTINGS.EPG_URL_ERROR' | translate + }} + + @if (epgField.value) { + + } + + +
+ } +
+ } +
+
+
+ +
+
+ + + +
+
+
diff --git a/apps/web/src/app/settings/settings-epg-section.component.ts b/apps/web/src/app/settings/settings-epg-section.component.ts new file mode 100644 index 000000000..6d03e14ff --- /dev/null +++ b/apps/web/src/app/settings/settings-epg-section.component.ts @@ -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(); + readonly activeSection = input.required(); + readonly epgUrl = input.required(); + readonly isClearingEpgData = input(false); + + readonly refreshEpg = output(); + readonly removeEpgSource = output(); + readonly addEpgSource = output(); + readonly refreshAllEpg = output(); + readonly clearEpgData = output(); +} diff --git a/apps/web/src/app/settings/settings-form.utils.ts b/apps/web/src/app/settings/settings-form.utils.ts new file mode 100644 index 000000000..f7c91d176 --- /dev/null +++ b/apps/web/src/app/settings/settings-form.utils.ts @@ -0,0 +1,7 @@ +import { FormControl, Validators } from '@angular/forms'; + +export const EPG_URL_PATTERN = /^(http|https|file):\/\/[^ "]+$/; + +export function createEpgUrlControl(value = ''): FormControl { + return new FormControl(value, [Validators.pattern(EPG_URL_PATTERN)]); +} diff --git a/apps/web/src/app/settings/settings-general-section.component.html b/apps/web/src/app/settings/settings-general-section.component.html new file mode 100644 index 000000000..4e831237e --- /dev/null +++ b/apps/web/src/app/settings/settings-general-section.component.html @@ -0,0 +1,174 @@ +
+
+
+ settings +
+
+

{{ 'SETTINGS.GENERAL' | translate }}

+

{{ 'SETTINGS.GENERAL_SUBTITLE' | translate }}

+
+
+ +
+
+

{{ 'SETTINGS.LANGUAGE' | translate }}

+

{{ 'SETTINGS.LANGUAGE_DESCRIPTION' | translate }}

+
+
+ + {{ + 'SETTINGS.VIDEO_PLAYER_PLACEHOLDER' | translate + }} + + @for ( + language of languageEnum() | keyvalue; + track language + ) { + {{ + 'LANGUAGES.' + language.key | translate + }} + } + + +
+
+ +
+
+

{{ 'SETTINGS.THEME' | translate }}

+

{{ 'SETTINGS.THEME_DESCRIPTION' | translate }}

+
+
+
+ @for (themeOption of themeOptions(); track themeOption.value) { + + } +
+
+
+ +
+
+

{{ 'SETTINGS.COVER_SIZE' | translate }}

+

{{ 'SETTINGS.COVER_SIZE_DESCRIPTION' | translate }}

+
+
+
+ @for (option of coverSizeOptions(); track option.value) { + + } +
+
+
+ +
+
+

{{ 'SETTINGS.SHOW_DASHBOARD' | translate }}

+

+ {{ 'SETTINGS.SHOW_DASHBOARD_DESCRIPTION' | translate }} +

+
+
+ +
+
+ +
+
+

{{ 'SETTINGS.STARTUP_BEHAVIOR' | translate }}

+

+ {{ 'SETTINGS.STARTUP_BEHAVIOR_DESCRIPTION' | translate }} +

+
+
+ + {{ + 'SETTINGS.STARTUP_BEHAVIOR' | translate + }} + + @for ( + option of startupBehaviorOptions(); + track option.value + ) { + + {{ option.labelKey | translate }} + + } + + +
+
+ +
+
+

{{ 'SETTINGS.SHOW_CAPTIONS' | translate }}

+

+ {{ 'SETTINGS.SHOW_CAPTIONS_DESCRIPTION' | translate }} +

+
+
+ +
+
+
diff --git a/apps/web/src/app/settings/settings-general-section.component.ts b/apps/web/src/app/settings/settings-general-section.component.ts new file mode 100644 index 000000000..be634dc9d --- /dev/null +++ b/apps/web/src/app/settings/settings-general-section.component.ts @@ -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(); + readonly activeSection = input.required(); + readonly languageEnum = input.required(); + readonly themeOptions = input.required(); + readonly coverSizeOptions = input.required(); + readonly startupBehaviorOptions = input.required(); + + readonly selectTheme = output(); + readonly selectCoverSize = output(); +} diff --git a/apps/web/src/app/settings/settings-options.ts b/apps/web/src/app/settings/settings-options.ts new file mode 100644 index 000000000..0e05271c5 --- /dev/null +++ b/apps/web/src/app/settings/settings-options.ts @@ -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, + }, + ]; +} diff --git a/apps/web/src/app/settings/settings-playback-section.component.html b/apps/web/src/app/settings/settings-playback-section.component.html new file mode 100644 index 000000000..b036e6615 --- /dev/null +++ b/apps/web/src/app/settings/settings-playback-section.component.html @@ -0,0 +1,150 @@ +
+
+
+ play_circle +
+
+

{{ 'SETTINGS.PLAYBACK' | translate }}

+

{{ 'SETTINGS.VIDEO_PLAYER_DESCRIPTION' | translate }}

+
+
+ +
+
+

{{ 'SETTINGS.VIDEO_PLAYER_LABEL' | translate }}

+

{{ 'SETTINGS.VIDEO_PLAYER_DESCRIPTION' | translate }}

+
+
+ + {{ + 'SETTINGS.VIDEO_PLAYER_PLACEHOLDER' | translate + }} + + @for (player of players(); track player.id) { + {{ player.labelKey | translate }} + + } + + +
+
+ +
+
+

{{ 'SETTINGS.STREAM_FORMAT' | translate }}

+

{{ 'SETTINGS.STREAM_FORMAT_DESCRIPTION' | translate }}

+
+
+ + {{ + 'SETTINGS.VIDEO_PLAYER_PLACEHOLDER' | translate + }} + + @for ( + streamFormat of streamFormatEnum() | keyvalue; + track streamFormat + ) { + {{ streamFormat.value }} + + } + + +
+
+ + @if (isDesktop()) { +
+
+

{{ 'SETTINGS.SHOW_EXTERNAL_PLAYBACK_BAR' | translate }}

+

+ {{ + 'SETTINGS.SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION' + | translate + }} +

+
+
+ +
+
+ } + + @if (form().value.player === 'mpv') { +
+
+

{{ 'SETTINGS.MPV_PLAYER_PATH_LABEL' | translate }}

+

+ {{ 'SETTINGS.MPV_PLAYER_PATH_DESCRIPTION' | translate }} +

+
+
+ + {{ + 'SETTINGS.MPV_PLAYER_PATH' | translate + }} + + +
+
+ +
+
+

{{ 'SETTINGS.MPV_REUSE_INSTANCE_LABEL' | translate }}

+

+ {{ 'SETTINGS.MPV_REUSE_INSTANCE_DESCRIPTION' | translate }} +

+
+
+ +
+
+ } + + @if (form().value.player === 'vlc') { +
+
+

{{ 'SETTINGS.VLC_PLAYER_PATH_LABEL' | translate }}

+

+ {{ 'SETTINGS.VLC_PLAYER_PATH_DESCRIPTION' | translate }} +

+
+
+ + {{ + 'SETTINGS.VLC_PLAYER_PATH' | translate + }} + + +
+
+ } +
diff --git a/apps/web/src/app/settings/settings-playback-section.component.ts b/apps/web/src/app/settings/settings-playback-section.component.ts new file mode 100644 index 000000000..c5b1519ad --- /dev/null +++ b/apps/web/src/app/settings/settings-playback-section.component.ts @@ -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(); + readonly activeSection = input.required(); + readonly players = input.required(); + readonly streamFormatEnum = input.required(); + readonly isDesktop = input(false); +} diff --git a/apps/web/src/app/settings/settings-remote-control-section.component.html b/apps/web/src/app/settings/settings-remote-control-section.component.html new file mode 100644 index 000000000..c49c44954 --- /dev/null +++ b/apps/web/src/app/settings/settings-remote-control-section.component.html @@ -0,0 +1,139 @@ +
+
+
+ settings_remote +
+
+

{{ 'SETTINGS.REMOTE_CONTROL' | translate }}

+

{{ 'SETTINGS.REMOTE_CONTROL_DESCRIPTION' | translate }}

+
+
+ +
+
+

{{ 'SETTINGS.REMOTE_CONTROL' | translate }}

+

{{ 'SETTINGS.REMOTE_CONTROL_DESCRIPTION' | translate }}

+
+
+ +
+
+ + @if (form().value.remoteControl === true) { +
+
+

{{ 'SETTINGS.REMOTE_CONTROL_PORT' | translate }}

+

+ {{ 'SETTINGS.REMOTE_CONTROL_PORT_DESCRIPTION' | translate }} +

+
+
+ + {{ + 'SETTINGS.PORT_RANGE_LABEL' | translate + }} + + @if ( + form().get('remoteControlPort')?.hasError('required') + ) { + {{ + 'SETTINGS.PORT_REQUIRED' | translate + }} + } + @if (form().get('remoteControlPort')?.hasError('min')) { + {{ + 'SETTINGS.PORT_MIN' | translate + }} + } + @if (form().get('remoteControlPort')?.hasError('max')) { + {{ + 'SETTINGS.PORT_MAX' | translate + }} + } + @if (form().get('remoteControlPort')?.hasError('pattern')) { + {{ + 'SETTINGS.PORT_PATTERN' | translate + }} + } + +
+
+ + @if (localIpAddresses().length > 0) { +
+
+

{{ 'SETTINGS.REMOTE_CONTROL_URL' | translate }}

+

+ {{ + 'SETTINGS.REMOTE_CONTROL_URL_DESCRIPTION' + | translate + }} +

+
+
+ @for (ip of localIpAddresses(); track ip) { +
+ + @if (visibleQrCodeIp() === ip) { +
+ +
+ } +
+ } +
+
+ } + } +
diff --git a/apps/web/src/app/settings/settings-remote-control-section.component.ts b/apps/web/src/app/settings/settings-remote-control-section.component.ts new file mode 100644 index 000000000..5d243e36a --- /dev/null +++ b/apps/web/src/app/settings/settings-remote-control-section.component.ts @@ -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(); + readonly activeSection = input.required(); + readonly localIpAddresses = input.required(); + readonly visibleQrCodeIp = input(null); + + readonly toggleQrCode = output(); +} diff --git a/apps/web/src/app/settings/settings-reset-section.component.html b/apps/web/src/app/settings/settings-reset-section.component.html new file mode 100644 index 000000000..608dee007 --- /dev/null +++ b/apps/web/src/app/settings/settings-reset-section.component.html @@ -0,0 +1,88 @@ +
+
+
+ delete_sweep +
+
+

{{ 'SETTINGS.RESET' | translate }}

+

{{ 'SETTINGS.RESET_DESCRIPTION' | translate }}

+
+
+ +
+
+
+

{{ 'SETTINGS.REMOVE_ALL' | translate }}

+

{{ 'SETTINGS.REMOVE_ALL_DESCRIPTION' | translate }}

+
+ +
+
+ + folder_open + {{ + 'HOME.PLAYLIST_TYPES.M3U' | translate + }} + {{ + playlistDeleteSummary().m3u + }} + + + cloud + {{ + 'HOME.PLAYLIST_TYPES.XTREAM' | translate + }} + {{ + playlistDeleteSummary().xtream + }} + + + cast + {{ + 'HOME.PLAYLIST_TYPES.STALKER' | translate + }} + {{ + playlistDeleteSummary().stalker + }} + +
+ @if (removeAllProgressLabel(); as progressLabel) { +

+ {{ progressLabel }} +

+ } +
+
diff --git a/apps/web/src/app/settings/settings-reset-section.component.ts b/apps/web/src/app/settings/settings-reset-section.component.ts new file mode 100644 index 000000000..7e42e0f40 --- /dev/null +++ b/apps/web/src/app/settings/settings-reset-section.component.ts @@ -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(); + readonly isRemovingAllPlaylists = input(false); + readonly canRemoveAllPlaylists = input(false); + readonly playlistDeleteSummary = + input.required(); + readonly removeAllProgressLabel = input(null); + + readonly removeAll = output(); +} diff --git a/apps/web/src/app/settings/settings-section-scroll.directive.ts b/apps/web/src/app/settings/settings-section-scroll.directive.ts new file mode 100644 index 000000000..1550d6d41 --- /dev/null +++ b/apps/web/src/app/settings/settings-section-scroll.directive.ts @@ -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); + 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; + } +} diff --git a/apps/web/src/app/settings/settings.component.html b/apps/web/src/app/settings/settings.component.html index efcbeed16..a18a88f17 100644 --- a/apps/web/src/app/settings/settings.component.html +++ b/apps/web/src/app/settings/settings.component.html @@ -6,6 +6,7 @@ class="settings-container" data-test-id="settings-container" [class.dialog-mode]="isDialog" + appSettingsSectionScroll >
-
-
-
- settings -
-
-

{{ 'SETTINGS.GENERAL' | translate }}

-

{{ 'SETTINGS.GENERAL_SUBTITLE' | translate }}

-
-
+ -
-
-

{{ 'SETTINGS.LANGUAGE' | translate }}

-

{{ 'SETTINGS.LANGUAGE_DESCRIPTION' | translate }}

-
-
- - {{ - 'SETTINGS.VIDEO_PLAYER_PLACEHOLDER' | translate - }} - - @for ( - language of languageEnum | keyvalue; - track language - ) { - {{ - 'LANGUAGES.' + language.key - | translate - }} - } - - -
-
- -
-
-

{{ 'SETTINGS.THEME' | translate }}

-

{{ 'SETTINGS.THEME_DESCRIPTION' | translate }}

-
-
-
- @for ( - themeOption of themeOptions; - track themeOption.value - ) { - - } -
-
-
- -
-
-

{{ 'SETTINGS.COVER_SIZE' | translate }}

-

{{ 'SETTINGS.COVER_SIZE_DESCRIPTION' | translate }}

-
-
-
- @for ( - option of coverSizeOptions; - track option.value - ) { - - } -
-
-
- -
-
-

{{ 'SETTINGS.SHOW_DASHBOARD' | translate }}

-

- {{ - 'SETTINGS.SHOW_DASHBOARD_DESCRIPTION' - | translate - }} -

-
-
- -
-
- -
-
-

{{ 'SETTINGS.STARTUP_BEHAVIOR' | translate }}

-

- {{ - 'SETTINGS.STARTUP_BEHAVIOR_DESCRIPTION' - | translate - }} -

-
-
- - {{ - 'SETTINGS.STARTUP_BEHAVIOR' | translate - }} - - @for ( - option of startupBehaviorOptions; - track option.value - ) { - - {{ option.labelKey | translate }} - - } - - -
-
- -
-
-

{{ 'SETTINGS.SHOW_CAPTIONS' | translate }}

-

- {{ - 'SETTINGS.SHOW_CAPTIONS_DESCRIPTION' | translate - }} -

-
-
- -
-
-
- -
-
-
- play_circle -
-
-

{{ 'SETTINGS.PLAYBACK' | translate }}

-

- {{ - 'SETTINGS.VIDEO_PLAYER_DESCRIPTION' | translate - }} -

-
-
- -
-
-

{{ 'SETTINGS.VIDEO_PLAYER_LABEL' | translate }}

-

- {{ - 'SETTINGS.VIDEO_PLAYER_DESCRIPTION' | translate - }} -

-
-
- - {{ - 'SETTINGS.VIDEO_PLAYER_PLACEHOLDER' | translate - }} - - @for (player of players(); track player.id) { - {{ player.labelKey | translate }} - - } - - -
-
- -
-
-

{{ 'SETTINGS.STREAM_FORMAT' | translate }}

-

- {{ - 'SETTINGS.STREAM_FORMAT_DESCRIPTION' | translate - }} -

-
-
- - {{ - 'SETTINGS.VIDEO_PLAYER_PLACEHOLDER' | translate - }} - - @for ( - streamFormat of streamFormatEnum | keyvalue; - track streamFormat - ) { - {{ streamFormat.value }} - - } - - -
-
- - @if (isDesktop) { -
-
-

- {{ - 'SETTINGS.SHOW_EXTERNAL_PLAYBACK_BAR' - | translate - }} -

-

- {{ - 'SETTINGS.SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION' - | translate - }} -

-
-
- -
-
- } - - @if (settingsForm.value.player === 'mpv') { -
-
-

- {{ - 'SETTINGS.MPV_PLAYER_PATH_LABEL' | translate - }} -

-

- {{ - 'SETTINGS.MPV_PLAYER_PATH_DESCRIPTION' - | translate - }} -

-
-
- - {{ - 'SETTINGS.MPV_PLAYER_PATH' | translate - }} - - -
-
- -
-
-

- {{ - 'SETTINGS.MPV_REUSE_INSTANCE_LABEL' - | translate - }} -

-

- {{ - 'SETTINGS.MPV_REUSE_INSTANCE_DESCRIPTION' - | translate - }} -

-
-
- -
-
- } - - @if (settingsForm.value.player === 'vlc') { -
-
-

- {{ - 'SETTINGS.VLC_PLAYER_PATH_LABEL' | translate - }} -

-

- {{ - 'SETTINGS.VLC_PLAYER_PATH_DESCRIPTION' - | translate - }} -

-
-
- - {{ - 'SETTINGS.VLC_PLAYER_PATH' | translate - }} - - -
-
- } -
+ @if (isDesktop) { -
-
-
- list_alt -
-
-

{{ 'SETTINGS.EPG_SOURCES' | translate }}

-

- {{ - 'SETTINGS.EPG_SOURCES_DESCRIPTION' - | translate - }} -

-
-
+ -
-
- - @if (epgUrl.length === 0) { -
- event_busy -

- {{ - 'SETTINGS.EPG_EMPTY_TITLE' - | translate - }} -

-

- {{ - 'SETTINGS.EPG_EMPTY_HINT' - | translate - }} -

-
- } @else { -
- @for ( - _ of epgUrl.controls; - track _; - let i = $index - ) { -
- - {{ - 'SETTINGS.EPG_URL_PLACEHOLDER' - | translate - }} - - {{ - 'SETTINGS.EPG_URL_ERROR' - | translate - }} - - @if (epgField.value) { - - } - - -
- } -
- } -
-
-
- -
-
- - - -
-
-
+ } - @if (isDesktop) { -
-
-
- settings_remote -
-
-

{{ 'SETTINGS.REMOTE_CONTROL' | translate }}

-

- {{ - 'SETTINGS.REMOTE_CONTROL_DESCRIPTION' - | translate - }} -

-
-
+ -
-
-

{{ 'SETTINGS.REMOTE_CONTROL' | translate }}

-

- {{ - 'SETTINGS.REMOTE_CONTROL_DESCRIPTION' - | translate - }} -

-
-
- -
-
+ - @if (settingsForm.value.remoteControl === true) { -
-
-

- {{ - 'SETTINGS.REMOTE_CONTROL_PORT' - | translate - }} -

-

- {{ - 'SETTINGS.REMOTE_CONTROL_PORT_DESCRIPTION' - | translate - }} -

-
-
- - {{ - 'SETTINGS.PORT_RANGE_LABEL' | translate - }} - - @if ( - settingsForm - .get('remoteControlPort') - ?.hasError('required') - ) { - {{ - 'SETTINGS.PORT_REQUIRED' | translate - }} - } - @if ( - settingsForm - .get('remoteControlPort') - ?.hasError('min') - ) { - {{ - 'SETTINGS.PORT_MIN' | translate - }} - } - @if ( - settingsForm - .get('remoteControlPort') - ?.hasError('max') - ) { - {{ - 'SETTINGS.PORT_MAX' | translate - }} - } - @if ( - settingsForm - .get('remoteControlPort') - ?.hasError('pattern') - ) { - {{ - 'SETTINGS.PORT_PATTERN' | translate - }} - } - -
-
- - @if (localIpAddresses().length > 0) { -
-
-

- {{ - 'SETTINGS.REMOTE_CONTROL_URL' - | translate - }} -

-

- {{ - 'SETTINGS.REMOTE_CONTROL_URL_DESCRIPTION' - | translate - }} -

-
-
- @for (ip of localIpAddresses(); track ip) { -
- - @if (visibleQrCodeIp() === ip) { -
- -
- } -
- } -
-
- } - } -
- } - -
-
-
- backup -
-
-

{{ 'SETTINGS.BACKUP' | translate }}

-

- {{ 'SETTINGS.BACKUP_DESCRIPTION' | translate }} -

-
-
- -
-
-

{{ 'SETTINGS.IMPORT_EXPORT_DATA' | translate }}

-

- {{ - 'SETTINGS.IMPORT_EXPORT_DATA_DESCRIPTION' - | translate - }} -

-
-
- - -
-
- - @if (isPwa) { -
- {{ 'SETTINGS.EPG_NOTE' | translate }} -  {{ 'SETTINGS.EPG_NOTE_URL_TEXT' | translate }} -
- } -
- -
-
-
- delete_sweep -
-
-

{{ 'SETTINGS.RESET' | translate }}

-

{{ 'SETTINGS.RESET_DESCRIPTION' | translate }}

-
-
- -
-
-
-

{{ 'SETTINGS.REMOVE_ALL' | translate }}

-

- {{ - 'SETTINGS.REMOVE_ALL_DESCRIPTION' - | translate - }} -

-
- -
-
- - folder_open - {{ - 'HOME.PLAYLIST_TYPES.M3U' | translate - }} - {{ - playlistDeleteSummary().m3u - }} - - - cloud - {{ - 'HOME.PLAYLIST_TYPES.XTREAM' | translate - }} - {{ - playlistDeleteSummary().xtream - }} - - - cast - {{ - 'HOME.PLAYLIST_TYPES.STALKER' | translate - }} - {{ - playlistDeleteSummary().stalker - }} - -
- @if (removeAllProgressLabel(); as progressLabel) { -

- {{ progressLabel }} -

- } -
-
- -
-
-
- info -
-
-

{{ 'SETTINGS.ABOUT' | translate }}

-

{{ 'SETTINGS.ABOUT_SUBTITLE' | translate }}

-
-
- -
-
-

{{ 'SETTINGS.VERSION' | translate }}

-

{{ 'SETTINGS.VERSION_DESCRIPTION' | translate }}

-
-
- {{ version }} - {{ updateMessage }} -
-
- - @if (isDesktop) { -
- {{ - 'SETTINGS.EMBEDDED_MPV_NOTICE_TITLE' | translate - }} - {{ - 'SETTINGS.EMBEDDED_MPV_NOTICE_DESCRIPTION' - | translate - }} -
- } - - - -
-
-
-
- favorite -
-
-

- {{ 'SETTINGS.SUPPORT_TITLE' | translate }} -

-

- {{ - 'SETTINGS.SUPPORT_DESCRIPTION' - | translate - }} -

-
-
- -
-
-
+