mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 12:36:15 -08:00
feat(settings): grouped navigation, cards and switches from the settings concept (#1853)
* feat(settings): grouped navigation, cards and switches from the settings concept Implements the Claude Design "Settings concept" handoff. Navigation: the sidebar is a 20px "Settings" title over App, Library, Devices and Data groups, with About pinned to the footer beside the installed version and an update badge. The active item is a soft fill without a border. Esc leaves settings like the header Back. Reset is no longer a page: its one destructive action is the last card of "Backup & data". Pages: a title and one-line subtitle, then rows grouped into titled cards with one right-aligned control column. Selects are compact without floating labels, checkboxes are switches, segmented controls are pills, and descriptions are capped at 56ch. "Show subtitles" moves to Playback, the Embedded MPV note becomes a callout under the player select (only while it is selected), and the TMDB attribution becomes the About footer. EPG: Add and Refresh all sit in the Sources header, the empty state has its own Add, the format examples are one line, Clear EPG data is its own row and the offset is a stepper. About: version, update state and channel share one card; the nightly warning is a callout shown only while Nightly is selected, with a shortcut to the backup page; the support buttons are neutral with coloured icons. Save model: the save bar stays (design option B) and now floats over the content column, counts the staged changes, answers Cmd/Ctrl+S, and marks pages with staged edits in the sidebar. A saved change that waits for a restart shows a dismissible notice. Rows stack under 600px of pane width (the page is a size container, so the persistent sidebar is accounted for). Page-specific styles moved into the About, EPG, Backup and Remote control section stylesheets to stay within the component style budget. New SETTINGS keys are translated in all 19 locales and NAV_RESET is removed; brand names and loanwords that stay English are in the identical-value baseline. E2E: Material slide toggles report aria-checked after the next render, so both suites toggle through a setSwitch helper; the settings nav is a navigation landmark, so the Dashboard rail link is scoped to the rail. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(settings): compare restart notices against the running app, fix CI fallout The restart notice now lists only the restart controls whose saved value differs from what the running app uses: the stored values at first load, or what the embedded engine reports it actually runs for the frame-copy opt-in. Saving the launch value back withdraws the notice instead of leaving it up with no chip to explain it. The refresh reads the current list untracked and writes only a changed one, because the engine probe effect calls it. CI: the zoneless checklist dropped the deleted Reset section component, and the theme-tokens Electron E2E floats the recording folder label on the Playback page now that the settings selects carry no floating label. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(settings): keep a dismissed restart reminder away until the setting changes "Later" now records the saved value it dismissed, for the rest of the app run, so an unrelated save (or reopening Settings) does not bring the same reminder back. It returns once a restart setting is saved with another value. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
4c6540679d
commit
8030ced95a
90 files changed
+5957
-3617
No files matched your search
@@ -1,437 +1,427 @@
|
||||
<section class="settings-group" 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>
|
||||
<h2>{{ 'SETTINGS.ABOUT' | translate }}</h2>
|
||||
<p>{{ 'SETTINGS.ABOUT_SUBTITLE' | translate }}</p>
|
||||
</header>
|
||||
|
||||
<div class="setting-item" data-setting-id="version">
|
||||
<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 data-test-id="app-version">
|
||||
{{ version() }}
|
||||
@if (buildCommitShort(); as commit) {
|
||||
<span
|
||||
class="build-commit"
|
||||
data-test-id="app-build-commit"
|
||||
[title]="buildCommit()"
|
||||
>({{ commit }})</span
|
||||
>
|
||||
}
|
||||
</span>
|
||||
<small>{{ updateMessage() }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (isDesktop() && appUpdateStatus(); as updateStatus) {
|
||||
<div
|
||||
class="setting-item setting-item--full-control app-update-panel"
|
||||
data-setting-id="app-update"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.APP_UPDATE_TITLE' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.APP_UPDATE_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
<div class="setting-item__control app-update-panel__control">
|
||||
<div
|
||||
class="app-update-status"
|
||||
[class.app-update-status--stale]="hasPendingChannelChange()"
|
||||
data-test-id="app-update-status"
|
||||
>
|
||||
@if (appliedChannelLabelKey(); as channelLabelKey) {
|
||||
<span
|
||||
class="app-update-status__channel"
|
||||
data-test-id="app-update-status-channel"
|
||||
>
|
||||
{{ channelLabelKey | translate }}
|
||||
</span>
|
||||
}
|
||||
<strong>
|
||||
{{
|
||||
appUpdateStatusLabelKey()
|
||||
| translate
|
||||
: {
|
||||
version:
|
||||
updateStatus.latestVersion ||
|
||||
updateStatus.currentVersion,
|
||||
}
|
||||
}}
|
||||
</strong>
|
||||
@if (updateStatus.error) {
|
||||
<span>{{ updateStatus.error }}</span>
|
||||
}
|
||||
@if (updateStatus.progress; as progress) {
|
||||
<progress
|
||||
[value]="progress.percent"
|
||||
max="100"
|
||||
data-test-id="app-update-progress"
|
||||
></progress>
|
||||
}
|
||||
@if (hasPendingChannelChange()) {
|
||||
<span data-test-id="app-update-channel-pending-hint">
|
||||
{{
|
||||
'SETTINGS.APP_UPDATE_CHANNEL_PENDING_HINT'
|
||||
| translate
|
||||
: {
|
||||
current:
|
||||
(channelLabelKey(
|
||||
verdictChannel()
|
||||
) | translate),
|
||||
next:
|
||||
(channelLabelKey(
|
||||
draftUpdateChannel()
|
||||
) | translate),
|
||||
}
|
||||
}}
|
||||
</span>
|
||||
} @else if (hasRetainedOtherChannelVerdict()) {
|
||||
<span data-test-id="app-update-channel-retained-hint">
|
||||
{{
|
||||
'SETTINGS.APP_UPDATE_CHANNEL_RETAINED_HINT'
|
||||
| translate
|
||||
: {
|
||||
current:
|
||||
(channelLabelKey(
|
||||
verdictChannel()
|
||||
) | translate),
|
||||
next:
|
||||
(channelLabelKey(
|
||||
updateStatus.channel
|
||||
) | translate),
|
||||
}
|
||||
}}
|
||||
</span>
|
||||
<!-- Version, update state and channel in one block. -->
|
||||
<section class="settings-section">
|
||||
<div class="settings-card">
|
||||
<div class="setting-item settings-hero" data-setting-id="version">
|
||||
<img
|
||||
class="settings-hero__logo"
|
||||
src="assets/icons/android-chrome-192x192.png"
|
||||
alt=""
|
||||
/>
|
||||
<div class="settings-hero__text">
|
||||
<div class="settings-hero__title">
|
||||
<span>IPTVnator</span>
|
||||
@if (updateStatusChip(); as chip) {
|
||||
<span
|
||||
class="settings-chip"
|
||||
[class.settings-chip--success]="
|
||||
chip.tone === 'success'
|
||||
"
|
||||
[class.settings-chip--accent]="
|
||||
chip.tone === 'accent'
|
||||
"
|
||||
[class.settings-chip--warn]="chip.tone === 'warn'"
|
||||
data-test-id="app-update-status-chip"
|
||||
>{{ chip.labelKey | translate }}</span
|
||||
>
|
||||
}
|
||||
</div>
|
||||
<code class="settings-hero__version" data-test-id="app-version">
|
||||
{{ version() }}
|
||||
@if (buildCommitShort(); as commit) {
|
||||
<span
|
||||
class="build-commit"
|
||||
data-test-id="app-build-commit"
|
||||
[title]="buildCommit()"
|
||||
>({{ commit }})</span
|
||||
>
|
||||
}
|
||||
</code>
|
||||
@if (!appUpdateStatus() && updateMessage()) {
|
||||
<small class="settings-hero__note">{{
|
||||
updateMessage()
|
||||
}}</small>
|
||||
}
|
||||
</div>
|
||||
@if (canSelectUpdateChannel()) {
|
||||
<div
|
||||
class="app-update-channel"
|
||||
[formGroup]="form()!"
|
||||
data-test-id="app-update-channel"
|
||||
>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>{{
|
||||
'SETTINGS.APP_UPDATE_CHANNEL' | translate
|
||||
}}</mat-label>
|
||||
<mat-select
|
||||
formControlName="updateChannel"
|
||||
data-test-id="select-update-channel"
|
||||
@if (isDesktop() && appUpdateStatus()) {
|
||||
<div class="settings-hero__actions">
|
||||
@if (canOpenAppUpdateReleaseNotes()) {
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
(click)="openAppUpdateReleaseNotes.emit()"
|
||||
data-test-id="app-update-release-notes"
|
||||
>
|
||||
@for (
|
||||
option of updateChannelOptions();
|
||||
track option.value
|
||||
) {
|
||||
<mat-option
|
||||
[value]="option.value"
|
||||
[attr.data-test-id]="
|
||||
'update-channel-' + option.value
|
||||
"
|
||||
>
|
||||
{{ option.labelKey | translate }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<small class="app-update-channel__note">
|
||||
{{
|
||||
'SETTINGS.APP_UPDATE_CHANNEL_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</small>
|
||||
@if (
|
||||
form()!.get('updateChannel')?.value === 'nightly'
|
||||
) {
|
||||
<small
|
||||
class="app-update-channel__note"
|
||||
data-test-id="app-update-channel-nightly-warning"
|
||||
>
|
||||
{{
|
||||
'SETTINGS.APP_UPDATE_CHANNEL_NIGHTLY_WARNING'
|
||||
| translate
|
||||
}}
|
||||
</small>
|
||||
<mat-icon>article</mat-icon>
|
||||
{{ 'SETTINGS.APP_UPDATE_RELEASE_NOTES' | translate }}
|
||||
</button>
|
||||
}
|
||||
@if (isNightlyBuildOnStableChannel()) {
|
||||
<small
|
||||
class="app-update-channel__note"
|
||||
data-test-id="app-update-nightly-on-stable-hint"
|
||||
@if (canSaveAndCheckForAppUpdate()) {
|
||||
<button
|
||||
mat-flat-button
|
||||
type="button"
|
||||
(click)="saveAndCheckForAppUpdate.emit()"
|
||||
[disabled]="isAppUpdateBusy() || form()!.invalid"
|
||||
data-test-id="app-update-save-and-check"
|
||||
>
|
||||
<mat-icon>save</mat-icon>
|
||||
{{
|
||||
'SETTINGS.APP_UPDATE_NIGHTLY_ON_STABLE_HINT'
|
||||
'SETTINGS.APP_UPDATE_SAVE_AND_CHECK'
|
||||
| translate
|
||||
: {
|
||||
channel:
|
||||
(channelLabelKey(
|
||||
draftUpdateChannel()
|
||||
) | translate),
|
||||
}
|
||||
}}
|
||||
</small>
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
(click)="checkForAppUpdate.emit()"
|
||||
[disabled]="isAppUpdateBusy()"
|
||||
data-test-id="app-update-check"
|
||||
>
|
||||
<mat-icon>sync</mat-icon>
|
||||
{{ 'SETTINGS.APP_UPDATE_CHECK' | translate }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div class="app-update-actions">
|
||||
@if (canSaveAndCheckForAppUpdate()) {
|
||||
<button
|
||||
mat-flat-button
|
||||
type="button"
|
||||
(click)="saveAndCheckForAppUpdate.emit()"
|
||||
[disabled]="isAppUpdateBusy() || form()!.invalid"
|
||||
data-test-id="app-update-save-and-check"
|
||||
>
|
||||
<mat-icon>save</mat-icon>
|
||||
{{
|
||||
'SETTINGS.APP_UPDATE_SAVE_AND_CHECK'
|
||||
| translate
|
||||
: {
|
||||
channel:
|
||||
(channelLabelKey(
|
||||
draftUpdateChannel()
|
||||
) | translate),
|
||||
}
|
||||
}}
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
(click)="checkForAppUpdate.emit()"
|
||||
[disabled]="isAppUpdateBusy()"
|
||||
data-test-id="app-update-check"
|
||||
>
|
||||
<mat-icon>sync</mat-icon>
|
||||
{{ 'SETTINGS.APP_UPDATE_CHECK' | translate }}
|
||||
</button>
|
||||
}
|
||||
@if (canDownloadAppUpdate()) {
|
||||
<button
|
||||
mat-flat-button
|
||||
type="button"
|
||||
(click)="downloadAppUpdate.emit()"
|
||||
data-test-id="app-update-download"
|
||||
>
|
||||
<mat-icon>download</mat-icon>
|
||||
{{ 'SETTINGS.APP_UPDATE_DOWNLOAD' | translate }}
|
||||
</button>
|
||||
}
|
||||
@if (canOpenAppUpdateReleaseNotes()) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
(click)="openAppUpdateReleaseNotes.emit()"
|
||||
data-test-id="app-update-release-notes"
|
||||
>
|
||||
<mat-icon>article</mat-icon>
|
||||
{{
|
||||
'SETTINGS.APP_UPDATE_RELEASE_NOTES' | translate
|
||||
}}
|
||||
</button>
|
||||
}
|
||||
@if (canInstallAppUpdate()) {
|
||||
<button
|
||||
mat-flat-button
|
||||
type="button"
|
||||
(click)="installAppUpdate.emit()"
|
||||
data-test-id="app-update-install"
|
||||
>
|
||||
<mat-icon>restart_alt</mat-icon>
|
||||
{{ 'SETTINGS.APP_UPDATE_INSTALL' | translate }}
|
||||
</button>
|
||||
}
|
||||
@if (canOpenManualAppUpdate()) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
(click)="openManualAppUpdate.emit()"
|
||||
data-test-id="app-update-open-release"
|
||||
>
|
||||
<mat-icon>open_in_new</mat-icon>
|
||||
{{ 'SETTINGS.APP_UPDATE_OPEN_RELEASE' | translate }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</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>
|
||||
}
|
||||
@if (isDesktop() && appUpdateStatus(); as updateStatus) {
|
||||
<div
|
||||
class="setting-item app-update-panel"
|
||||
data-setting-id="app-update"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<div
|
||||
class="app-update-status"
|
||||
[class.app-update-status--stale]="
|
||||
hasPendingChannelChange()
|
||||
"
|
||||
data-test-id="app-update-status"
|
||||
>
|
||||
@if (appliedChannelLabelKey(); as channelLabelKey) {
|
||||
<span
|
||||
class="app-update-status__channel"
|
||||
data-test-id="app-update-status-channel"
|
||||
>
|
||||
{{ channelLabelKey | translate }}
|
||||
</span>
|
||||
}
|
||||
<strong>
|
||||
{{
|
||||
appUpdateStatusLabelKey()
|
||||
| translate
|
||||
: {
|
||||
version:
|
||||
updateStatus.latestVersion ||
|
||||
updateStatus.currentVersion,
|
||||
}
|
||||
}}
|
||||
</strong>
|
||||
@if (updateStatus.error) {
|
||||
<span>{{ updateStatus.error }}</span>
|
||||
}
|
||||
@if (updateStatus.progress; as progress) {
|
||||
<progress
|
||||
[value]="progress.percent"
|
||||
max="100"
|
||||
data-test-id="app-update-progress"
|
||||
></progress>
|
||||
}
|
||||
@if (hasPendingChannelChange()) {
|
||||
<span data-test-id="app-update-channel-pending-hint">
|
||||
{{
|
||||
'SETTINGS.APP_UPDATE_CHANNEL_PENDING_HINT'
|
||||
| translate
|
||||
: {
|
||||
current:
|
||||
(channelLabelKey(
|
||||
verdictChannel()
|
||||
) | translate),
|
||||
next:
|
||||
(channelLabelKey(
|
||||
draftUpdateChannel()
|
||||
) | translate),
|
||||
}
|
||||
}}
|
||||
</span>
|
||||
} @else if (hasRetainedOtherChannelVerdict()) {
|
||||
<span
|
||||
data-test-id="app-update-channel-retained-hint"
|
||||
>
|
||||
{{
|
||||
'SETTINGS.APP_UPDATE_CHANNEL_RETAINED_HINT'
|
||||
| translate
|
||||
: {
|
||||
current:
|
||||
(channelLabelKey(
|
||||
verdictChannel()
|
||||
) | translate),
|
||||
next:
|
||||
(channelLabelKey(
|
||||
updateStatus.channel
|
||||
) | translate),
|
||||
}
|
||||
}}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item__control app-update-actions">
|
||||
@if (canDownloadAppUpdate()) {
|
||||
<button
|
||||
mat-flat-button
|
||||
type="button"
|
||||
(click)="downloadAppUpdate.emit()"
|
||||
data-test-id="app-update-download"
|
||||
>
|
||||
<mat-icon>download</mat-icon>
|
||||
{{ 'SETTINGS.APP_UPDATE_DOWNLOAD' | translate }}
|
||||
</button>
|
||||
}
|
||||
@if (canInstallAppUpdate()) {
|
||||
<button
|
||||
mat-flat-button
|
||||
type="button"
|
||||
(click)="installAppUpdate.emit()"
|
||||
data-test-id="app-update-install"
|
||||
>
|
||||
<mat-icon>restart_alt</mat-icon>
|
||||
{{ 'SETTINGS.APP_UPDATE_INSTALL' | translate }}
|
||||
</button>
|
||||
}
|
||||
@if (canOpenManualAppUpdate()) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
(click)="openManualAppUpdate.emit()"
|
||||
data-test-id="app-update-open-release"
|
||||
>
|
||||
<mat-icon>open_in_new</mat-icon>
|
||||
{{ 'SETTINGS.APP_UPDATE_OPEN_RELEASE' | translate }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="setting-item setting-item--full-control"
|
||||
data-setting-id="tmdb-credits"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.TMDB_CREDITS' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.TMDB_ATTRIBUTION' | translate }}</p>
|
||||
@if (canSelectUpdateChannel()) {
|
||||
<div
|
||||
class="setting-item"
|
||||
data-setting-id="app-update-channel"
|
||||
data-test-id="app-update-channel"
|
||||
[formGroup]="form()!"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<h4 id="app-update-channel-label">
|
||||
{{ 'SETTINGS.APP_UPDATE_CHANNEL' | translate }}
|
||||
</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.APP_UPDATE_CHANNEL_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<mat-form-field
|
||||
class="settings-select"
|
||||
appearance="outline"
|
||||
subscriptSizing="dynamic"
|
||||
>
|
||||
<mat-select
|
||||
formControlName="updateChannel"
|
||||
aria-labelledby="app-update-channel-label"
|
||||
data-test-id="select-update-channel"
|
||||
>
|
||||
@for (
|
||||
option of updateChannelOptions();
|
||||
track option.value
|
||||
) {
|
||||
<mat-option
|
||||
[value]="option.value"
|
||||
[attr.data-test-id]="
|
||||
'update-channel-' + option.value
|
||||
"
|
||||
>
|
||||
{{ option.labelKey | translate }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
<!-- The warning belongs to the risky choice, and only
|
||||
while it is selected. -->
|
||||
@if (draftUpdateChannel() === 'nightly') {
|
||||
<div class="setting-item__sub">
|
||||
<div
|
||||
class="settings-callout settings-callout--warn"
|
||||
data-test-id="app-update-channel-nightly-warning"
|
||||
>
|
||||
<mat-icon>warning</mat-icon>
|
||||
<div>
|
||||
{{
|
||||
'SETTINGS.APP_UPDATE_CHANNEL_NIGHTLY_WARNING'
|
||||
| translate
|
||||
}}
|
||||
<div class="settings-callout__actions">
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
(click)="openBackup.emit()"
|
||||
data-test-id="app-update-open-backup"
|
||||
>
|
||||
{{
|
||||
'SETTINGS.APP_UPDATE_CHANNEL_OPEN_BACKUP'
|
||||
| translate
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (isNightlyBuildOnStableChannel()) {
|
||||
<div class="setting-item__sub">
|
||||
<div
|
||||
class="settings-callout settings-callout--info"
|
||||
data-test-id="app-update-nightly-on-stable-hint"
|
||||
>
|
||||
<mat-icon>info</mat-icon>
|
||||
<div>
|
||||
{{
|
||||
'SETTINGS.APP_UPDATE_NIGHTLY_ON_STABLE_HINT'
|
||||
| translate
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
<div class="settings-section__header">
|
||||
<h3>{{ 'SETTINGS.COMMUNITY' | translate }}</h3>
|
||||
</div>
|
||||
<div
|
||||
class="settings-card link-cards"
|
||||
data-setting-id="community"
|
||||
tabindex="-1"
|
||||
>
|
||||
<a
|
||||
href="https://www.themoviedb.org"
|
||||
class="link-card link-card--github"
|
||||
href="https://github.com/4gray/iptvnator"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
data-test-id="link-github"
|
||||
>
|
||||
<img
|
||||
src="assets/images/tmdb-logo.svg"
|
||||
alt="TMDB"
|
||||
style="height: 20px; margin-top: 8px"
|
||||
/>
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div
|
||||
class="setting-item setting-item--full-control"
|
||||
data-setting-id="community"
|
||||
>
|
||||
<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"
|
||||
data-setting-id="support"
|
||||
>
|
||||
<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>
|
||||
<section class="settings-section">
|
||||
<div
|
||||
class="settings-card support-card"
|
||||
data-setting-id="support"
|
||||
data-test-id="support-card"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="support-card__copy">
|
||||
<h4>{{ 'SETTINGS.SUPPORT_TITLE' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.SUPPORT_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
<div class="support-card__actions">
|
||||
<a
|
||||
mat-stroked-button
|
||||
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>
|
||||
<mat-icon class="support-btn__glyph">favorite</mat-icon>
|
||||
GitHub Sponsors
|
||||
</a>
|
||||
<a
|
||||
mat-stroked-button
|
||||
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>
|
||||
<mat-icon class="support-btn__glyph">local_cafe</mat-icon>
|
||||
Ko-fi
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="settings-foot" data-setting-id="tmdb-credits" tabindex="-1">
|
||||
<img src="assets/images/tmdb-logo.svg" alt="TMDB" />
|
||||
<span>{{ 'SETTINGS.TMDB_ATTRIBUTION' | translate }}</span>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,262 @@
|
||||
// About page: version hero, update status, community links and support.
|
||||
app-settings-about-section {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
// ─── About ─────────────────────────────────────────────────────────────────
|
||||
.settings-hero {
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: 16px;
|
||||
padding: 18px 16px;
|
||||
}
|
||||
|
||||
.settings-hero__logo {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.settings-hero__text {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.settings-hero__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: var(--app-heading-color);
|
||||
}
|
||||
|
||||
.settings-hero__version {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
color: var(--app-body-color);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
.build-commit {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-hero__note {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: var(--settings-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.settings-hero__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.app-update-status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
color: var(--settings-muted);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.45;
|
||||
|
||||
strong {
|
||||
color: var(--app-heading-color);
|
||||
font-size: 13.5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
progress {
|
||||
width: min(100%, 320px);
|
||||
height: 8px;
|
||||
accent-color: var(--app-selection-color);
|
||||
}
|
||||
}
|
||||
|
||||
.app-update-status__channel {
|
||||
align-self: flex-start;
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--settings-chip-bg);
|
||||
color: var(--settings-chip-fg);
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.app-update-status--stale strong,
|
||||
.app-update-status--stale .app-update-status__channel {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.app-update-actions {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
button {
|
||||
app-region: no-drag;
|
||||
}
|
||||
}
|
||||
|
||||
// Project & community: three link tiles sharing one card.
|
||||
.link-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.link-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
padding: 14px 16px;
|
||||
color: var(--app-heading-color);
|
||||
text-decoration: none;
|
||||
|
||||
& + & {
|
||||
border-left: 1px solid var(--settings-item-border);
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
border-radius: 12px 0 0 12px;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-radius: 0 12px 12px 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--app-hover-overlay);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--app-selection-color);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
.link-card__glyph {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
background: var(--settings-chip-bg);
|
||||
color: var(--app-body-color);
|
||||
|
||||
svg,
|
||||
mat-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.link-card__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
|
||||
strong {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--settings-muted);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
}
|
||||
|
||||
.support-card {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.support-card__copy {
|
||||
min-width: 0;
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: var(--app-heading-color);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 3px 0 0;
|
||||
max-width: 56ch;
|
||||
color: var(--settings-muted);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.support-card__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
// Neutral buttons whose icon carries the colour.
|
||||
.support-btn--sponsors .support-btn__glyph {
|
||||
color: #ff6b9a;
|
||||
}
|
||||
|
||||
.support-btn--kofi .support-btn__glyph {
|
||||
color: #29abe0;
|
||||
}
|
||||
|
||||
// Pane width, see settings.component.scss.
|
||||
@container (max-width: 600px) {
|
||||
.settings-hero {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.settings-hero__actions {
|
||||
grid-column: 1 / -1;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.link-cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.link-card {
|
||||
& + & {
|
||||
border-left: 0;
|
||||
border-top: 1px solid var(--settings-item-border);
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
border-radius: 12px 12px 0 0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-radius: 0 0 12px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.support-card {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,11 @@ import {
|
||||
import { UpdateChannelOption } from './settings.models';
|
||||
import { markSectionForCheckOnFormEvents } from './settings-section-form-render';
|
||||
|
||||
interface SettingsUpdateStatusChip {
|
||||
readonly labelKey: string;
|
||||
readonly tone: 'accent' | 'success' | 'warn' | 'neutral';
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings-about-section',
|
||||
imports: [
|
||||
@@ -34,15 +39,7 @@ import { markSectionForCheckOnFormEvents } from './settings-section-form-render'
|
||||
templateUrl: './settings-about-section.component.html',
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styles: [
|
||||
':host { display: contents; }',
|
||||
'.version-block .build-commit { opacity: 0.65; font-size: 0.85em; }',
|
||||
'.app-update-channel { margin-top: 12px; }',
|
||||
'.app-update-channel mat-form-field { width: 100%; max-width: 320px; }',
|
||||
'.app-update-channel__note { display: block; margin-top: 4px; opacity: 0.75; font-size: 0.85em; }',
|
||||
'.app-update-status__channel { align-self: flex-start; padding: 2px 9px; border-radius: 999px; font-size: 0.72rem; font-weight: 600; letter-spacing: 0.05em; text-transform: uppercase; color: var(--mat-sys-on-surface-variant); background: color-mix(in srgb, var(--mat-sys-on-surface) 9%, transparent); }',
|
||||
'.app-update-status--stale strong, .app-update-status--stale .app-update-status__channel { opacity: 0.55; }',
|
||||
],
|
||||
styleUrls: ['./settings-about-section.component.scss'],
|
||||
})
|
||||
export class SettingsAboutSectionComponent {
|
||||
readonly isDesktop = input(false);
|
||||
@@ -85,6 +82,8 @@ export class SettingsAboutSectionComponent {
|
||||
readonly installAppUpdate = output<void>();
|
||||
readonly openManualAppUpdate = output<void>();
|
||||
readonly openAppUpdateReleaseNotes = output<void>();
|
||||
/** The nightly warning offers the backup page before switching. */
|
||||
readonly openBackup = output<void>();
|
||||
|
||||
readonly canSelectUpdateChannel = computed(
|
||||
() =>
|
||||
@@ -177,6 +176,38 @@ export class SettingsAboutSectionComponent {
|
||||
return channel ? this.channelLabelKey(channel) : null;
|
||||
});
|
||||
|
||||
/** The one-word verdict beside the app name in the hero. */
|
||||
readonly updateStatusChip = computed<SettingsUpdateStatusChip | null>(
|
||||
() => {
|
||||
switch (this.appUpdateStatus()?.status) {
|
||||
case ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available:
|
||||
case ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloading:
|
||||
case ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded:
|
||||
return {
|
||||
labelKey: 'SETTINGS.UPDATE_AVAILABLE_CHIP',
|
||||
tone: 'accent',
|
||||
};
|
||||
case ELECTRON_BRIDGE_APP_UPDATE_STATUSES.NotAvailable:
|
||||
return {
|
||||
labelKey: 'SETTINGS.UPDATE_UP_TO_DATE',
|
||||
tone: 'success',
|
||||
};
|
||||
case ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Checking:
|
||||
return {
|
||||
labelKey: 'SETTINGS.APP_UPDATE_CHECKING',
|
||||
tone: 'neutral',
|
||||
};
|
||||
case ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Error:
|
||||
return {
|
||||
labelKey: 'SETTINGS.APP_UPDATE_ERROR',
|
||||
tone: 'warn',
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
readonly isAppUpdateBusy = computed(() => {
|
||||
const status = this.appUpdateStatus()?.status;
|
||||
|
||||
|
||||
@@ -1,73 +1,173 @@
|
||||
<section class="settings-group" 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>
|
||||
<h2>{{ 'SETTINGS.NAV_BACKUP' | translate }}</h2>
|
||||
<p>{{ 'SETTINGS.BACKUP_DESCRIPTION' | translate }}</p>
|
||||
</header>
|
||||
|
||||
<div class="setting-item" data-setting-id="import-export-data">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.IMPORT_EXPORT_DATA' | translate }}</h4>
|
||||
<p>
|
||||
{{ 'SETTINGS.IMPORT_EXPORT_DATA_DESCRIPTION' | translate }}
|
||||
</p>
|
||||
<section class="settings-section">
|
||||
<div class="settings-section__header">
|
||||
<h3>{{ 'SETTINGS.BACKUP' | translate }}</h3>
|
||||
</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>
|
||||
<div class="settings-card">
|
||||
<div class="setting-item" data-setting-id="import-export-data">
|
||||
<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-stroked-button
|
||||
class="inline-actions__button"
|
||||
[disabled]="isRemovingAllPlaylists() || isExportingData()"
|
||||
(click)="
|
||||
$event.preventDefault();
|
||||
$event.stopPropagation();
|
||||
importData.emit()
|
||||
"
|
||||
>
|
||||
{{ 'SETTINGS.IMPORT_DATA' | translate }}
|
||||
</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
class="inline-actions__button"
|
||||
[class.inline-actions__button--busy]="isExportingData()"
|
||||
[disabled]="isRemovingAllPlaylists() || isExportingData()"
|
||||
[attr.aria-busy]="isExportingData()"
|
||||
(click)="
|
||||
$event.preventDefault();
|
||||
$event.stopPropagation();
|
||||
exportData.emit()
|
||||
"
|
||||
>
|
||||
@if (isExportingData()) {
|
||||
<mat-progress-spinner
|
||||
class="button-spinner"
|
||||
diameter="16"
|
||||
mode="indeterminate"
|
||||
/>
|
||||
<span class="inline-actions__label">{{
|
||||
'SETTINGS.EXPORTING_DATA' | translate
|
||||
}}</span>
|
||||
} @else {
|
||||
<span class="inline-actions__label">{{
|
||||
'SETTINGS.EXPORT_DATA' | translate
|
||||
}}</span>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (isPwa()) {
|
||||
<div class="settings-note">
|
||||
{{ 'SETTINGS.EPG_NOTE' | translate }}
|
||||
<a
|
||||
href="https://github.com/4gray/iptvnator/releases"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>{{ 'SETTINGS.EPG_NOTE_URL_TEXT' | translate }}</a
|
||||
>
|
||||
@if (isPwa()) {
|
||||
<div class="setting-item__sub">
|
||||
<div class="settings-callout settings-callout--info">
|
||||
<mat-icon>info</mat-icon>
|
||||
<div>
|
||||
{{ 'SETTINGS.EPG_NOTE' | translate }}
|
||||
<a
|
||||
href="https://github.com/4gray/iptvnator/releases"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>{{ 'SETTINGS.EPG_NOTE_URL_TEXT' | translate }}</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<!-- The former Reset page: one destructive action, so it is this
|
||||
page's last card rather than a page of its own. -->
|
||||
<section class="settings-section">
|
||||
<div class="settings-section__header">
|
||||
<h3>{{ 'SETTINGS.RESET' | translate }}</h3>
|
||||
</div>
|
||||
<p class="settings-section__desc">
|
||||
{{ 'SETTINGS.RESET_DESCRIPTION' | translate }}
|
||||
</p>
|
||||
<div class="settings-card settings-card--danger">
|
||||
<div
|
||||
class="setting-item danger-zone"
|
||||
data-setting-id="remove-all-playlists"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.REMOVE_ALL' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.REMOVE_ALL_DESCRIPTION' | translate }}</p>
|
||||
<div class="danger-zone__summary" aria-live="polite">
|
||||
<span class="danger-zone__summary-pill">
|
||||
<mat-icon class="danger-zone__summary-icon">{{
|
||||
sourceIcons.m3u
|
||||
}}</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">{{
|
||||
sourceIcons.xtream
|
||||
}}</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">{{
|
||||
sourceIcons.stalker
|
||||
}}</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>
|
||||
<div class="setting-item__control">
|
||||
<button
|
||||
mat-stroked-button
|
||||
class="danger-zone__button app-destructive-button"
|
||||
[class.danger-zone__button--busy]="
|
||||
isRemovingAllPlaylists()
|
||||
"
|
||||
[disabled]="!canRemoveAllPlaylists()"
|
||||
[attr.aria-busy]="isRemovingAllPlaylists()"
|
||||
(click)="
|
||||
$event.preventDefault();
|
||||
$event.stopPropagation();
|
||||
removeAll.emit()
|
||||
"
|
||||
>
|
||||
@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>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
@@ -0,0 +1,50 @@
|
||||
// Backup & data page: the playlist reset row with its summary pills.
|
||||
app-settings-backup-section {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
// ─── Reset (Backup & data) ────────────────────────────────────────────────
|
||||
.danger-zone__summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin: 10px 0 0;
|
||||
}
|
||||
|
||||
.danger-zone__summary-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 26px;
|
||||
padding: 2px 8px 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--settings-chip-bg);
|
||||
color: var(--app-heading-color);
|
||||
font-size: 11.5px;
|
||||
|
||||
.danger-zone__summary-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
font-size: 14px;
|
||||
color: var(--app-body-color);
|
||||
}
|
||||
|
||||
.danger-zone__summary-count {
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
.danger-zone__progress {
|
||||
margin: 8px 0 0;
|
||||
color: var(--settings-muted);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.danger-zone__button {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.danger-zone__button--busy {
|
||||
background: color-mix(in srgb, var(--mat-sys-error) 12%, transparent);
|
||||
}
|
||||
@@ -9,7 +9,13 @@ 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 { SOURCE_TYPE_ICONS } from '@iptvnator/shared/interfaces';
|
||||
import { SettingsPlaylistDeleteSummary } from './settings.models';
|
||||
|
||||
/**
|
||||
* Backup & data: import/export plus the one destructive reset action, which
|
||||
* used to be a page of its own.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-settings-backup-section',
|
||||
imports: [
|
||||
@@ -21,13 +27,19 @@ import { TranslateModule } from '@ngx-translate/core';
|
||||
templateUrl: './settings-backup-section.component.html',
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styles: [':host { display: contents; }'],
|
||||
styleUrls: ['./settings-backup-section.component.scss'],
|
||||
})
|
||||
export class SettingsBackupSectionComponent {
|
||||
readonly sourceIcons = SOURCE_TYPE_ICONS;
|
||||
readonly isPwa = input(false);
|
||||
readonly isRemovingAllPlaylists = input(false);
|
||||
readonly isExportingData = input(false);
|
||||
readonly canRemoveAllPlaylists = input(false);
|
||||
readonly playlistDeleteSummary =
|
||||
input.required<SettingsPlaylistDeleteSummary>();
|
||||
readonly removeAllProgressLabel = input<string | null>(null);
|
||||
|
||||
readonly importData = output<void>();
|
||||
readonly exportData = output<void>();
|
||||
readonly removeAll = output<void>();
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { createSettingsForm } from './settings-form.utils';
|
||||
import { FormBuilder } from '@angular/forms';
|
||||
import {
|
||||
diffSettingsValues,
|
||||
sectionOfSettingsPath,
|
||||
SETTINGS_CONTROL_SECTIONS,
|
||||
SETTINGS_RESTART_CONTROLS,
|
||||
} from './settings-change-tracking';
|
||||
|
||||
describe('settings change tracking', () => {
|
||||
it('lists the leaf values that differ, nested groups per leaf', () => {
|
||||
const saved = {
|
||||
theme: 'dark',
|
||||
dashboardRails: { hero: true, continueWatching: true },
|
||||
epgUrl: ['a'],
|
||||
tmdb: { enabled: false, apiKey: '' },
|
||||
};
|
||||
const current = {
|
||||
theme: 'light',
|
||||
dashboardRails: { hero: false, continueWatching: false },
|
||||
epgUrl: ['a', 'b'],
|
||||
tmdb: { enabled: false, apiKey: '' },
|
||||
};
|
||||
|
||||
expect(diffSettingsValues(current, saved)).toEqual([
|
||||
'theme',
|
||||
'dashboardRails.hero',
|
||||
'dashboardRails.continueWatching',
|
||||
'epgUrl',
|
||||
]);
|
||||
expect(diffSettingsValues(saved, saved)).toEqual([]);
|
||||
});
|
||||
|
||||
it('treats a missing value and null alike, and arrays as one value', () => {
|
||||
expect(diffSettingsValues({ a: null }, { a: undefined })).toEqual([]);
|
||||
expect(diffSettingsValues({ a: [1, 2] }, { a: [2, 1] })).toEqual([
|
||||
'a',
|
||||
]);
|
||||
expect(diffSettingsValues({ a: 1 }, {})).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('maps a changed path to the page it is edited on', () => {
|
||||
expect(sectionOfSettingsPath('dashboardRails.hero')).toBe('dashboard');
|
||||
expect(sectionOfSettingsPath('tmdb.apiKey')).toBe('tmdb');
|
||||
expect(sectionOfSettingsPath('showCaptions')).toBe('playback');
|
||||
expect(sectionOfSettingsPath('unknownControl')).toBeNull();
|
||||
});
|
||||
|
||||
it('maps every control of the settings form to a page', () => {
|
||||
const form = createSettingsForm(new FormBuilder(), true);
|
||||
const unmapped = Object.keys(form.controls).filter(
|
||||
(control) => !(control in SETTINGS_CONTROL_SECTIONS)
|
||||
);
|
||||
|
||||
expect(unmapped).toEqual([]);
|
||||
});
|
||||
|
||||
it('names the row label for every restart control', () => {
|
||||
for (const control of Object.keys(SETTINGS_RESTART_CONTROLS)) {
|
||||
expect(control in SETTINGS_CONTROL_SECTIONS).toBe(true);
|
||||
expect(SETTINGS_RESTART_CONTROLS[control]).toMatch(/^SETTINGS\./);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Which settings page each top-level form control is edited on. The save
|
||||
* bar counts staged changes and the navigation marks their pages with it.
|
||||
*/
|
||||
export const SETTINGS_CONTROL_SECTIONS: Readonly<Record<string, string>> = {
|
||||
language: 'general',
|
||||
theme: 'general',
|
||||
coverSize: 'general',
|
||||
showCoverTitles: 'general',
|
||||
startupBehavior: 'general',
|
||||
startupWindowMode: 'general',
|
||||
stripCountryPrefix: 'general',
|
||||
portalConnectivityGuard: 'general',
|
||||
player: 'playback',
|
||||
streamFormat: 'playback',
|
||||
showCaptions: 'playback',
|
||||
webPlayerSharedControls: 'playback',
|
||||
playerAmbientMode: 'playback',
|
||||
playerUpNextRail: 'playback',
|
||||
playerUpNextCard: 'playback',
|
||||
detailTrailerBackdrop: 'playback',
|
||||
fullscreenChannelPanel: 'playback',
|
||||
vodAutoFailover: 'playback',
|
||||
openStreamOnDoubleClick: 'playback',
|
||||
showExternalPlaybackBar: 'playback',
|
||||
embeddedMpvFrameCopy: 'playback',
|
||||
embeddedMpvExtraOptions: 'playback',
|
||||
embeddedMpvAutoReconnect: 'playback',
|
||||
recordingFolder: 'playback',
|
||||
mpvPlayerPath: 'playback',
|
||||
mpvPlayerArguments: 'playback',
|
||||
mpvReuseInstance: 'playback',
|
||||
vlcPlayerPath: 'playback',
|
||||
vlcPlayerArguments: 'playback',
|
||||
vlcReuseInstance: 'playback',
|
||||
epgUrl: 'epg',
|
||||
epgViewMode: 'epg',
|
||||
epgOffsetMinutes: 'epg',
|
||||
preferUploadedEpgOverXtream: 'epg',
|
||||
showDashboard: 'dashboard',
|
||||
dashboardRails: 'dashboard',
|
||||
remoteControl: 'remote-control',
|
||||
remoteControlPort: 'remote-control',
|
||||
tmdb: 'tmdb',
|
||||
m3uVodDetails: 'tmdb',
|
||||
updateChannel: 'about',
|
||||
};
|
||||
|
||||
/**
|
||||
* Controls whose saved value only takes effect after the app restarts,
|
||||
* with the label of the row that carries the restart chip.
|
||||
*/
|
||||
export const SETTINGS_RESTART_CONTROLS: Readonly<Record<string, string>> = {
|
||||
startupWindowMode: 'SETTINGS.STARTUP_WINDOW_MODE',
|
||||
embeddedMpvFrameCopy: 'SETTINGS.EMBEDDED_MPV_FRAME_COPY',
|
||||
};
|
||||
|
||||
/**
|
||||
* Dotted paths of the leaf values that differ between the staged form value
|
||||
* and the saved one. Nested groups (dashboard rails, TMDB) count per leaf,
|
||||
* so "3 unsaved changes" means three switches, not one group.
|
||||
*/
|
||||
export function diffSettingsValues(
|
||||
current: unknown,
|
||||
saved: unknown,
|
||||
path = ''
|
||||
): string[] {
|
||||
if (isPlainObject(current) && isPlainObject(saved)) {
|
||||
const keys = new Set([...Object.keys(current), ...Object.keys(saved)]);
|
||||
const changed: string[] = [];
|
||||
for (const key of keys) {
|
||||
changed.push(
|
||||
...diffSettingsValues(
|
||||
current[key],
|
||||
saved[key],
|
||||
path ? `${path}.${key}` : key
|
||||
)
|
||||
);
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
return JSON.stringify(current ?? null) === JSON.stringify(saved ?? null)
|
||||
? []
|
||||
: [path];
|
||||
}
|
||||
|
||||
/** Section page of a changed path, `null` for an unmapped control. */
|
||||
export function sectionOfSettingsPath(path: string): string | null {
|
||||
return SETTINGS_CONTROL_SECTIONS[path.split('.')[0]] ?? null;
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return (
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
);
|
||||
}
|
||||
@@ -1,212 +1,236 @@
|
||||
<section class="settings-group" id="dashboard" [formGroup]="form()">
|
||||
<header class="settings-group__header">
|
||||
<div class="settings-group__header-icon">
|
||||
<mat-icon>dashboard</mat-icon>
|
||||
</div>
|
||||
<div class="settings-group__header-text">
|
||||
<h3>{{ 'SETTINGS.DASHBOARD' | translate }}</h3>
|
||||
<p>{{ 'SETTINGS.DASHBOARD_SUBTITLE' | translate }}</p>
|
||||
</div>
|
||||
<h2>{{ 'SETTINGS.DASHBOARD' | translate }}</h2>
|
||||
<p>{{ 'SETTINGS.DASHBOARD_SUBTITLE' | translate }}</p>
|
||||
</header>
|
||||
|
||||
<div class="setting-item" data-setting-id="show-dashboard">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.SHOW_DASHBOARD' | translate }}</h4>
|
||||
<p>
|
||||
{{ 'SETTINGS.SHOW_DASHBOARD_DESCRIPTION' | translate }}
|
||||
</p>
|
||||
<section class="settings-section">
|
||||
<div class="settings-card">
|
||||
<div class="setting-item" data-setting-id="show-dashboard">
|
||||
<div class="setting-item__meta">
|
||||
<h4 id="show-dashboard-label">
|
||||
{{ 'SETTINGS.SHOW_DASHBOARD' | translate }}
|
||||
</h4>
|
||||
<p>
|
||||
{{ 'SETTINGS.SHOW_DASHBOARD_DESCRIPTION' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-slide-toggle
|
||||
formControlName="showDashboard"
|
||||
aria-labelledby="show-dashboard-label"
|
||||
data-test-id="toggle-show-dashboard"
|
||||
></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox
|
||||
formControlName="showDashboard"
|
||||
data-test-id="toggle-show-dashboard"
|
||||
></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div formGroupName="dashboardRails">
|
||||
<div class="setting-item" data-setting-id="dashboard-hero">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.DASHBOARD_HERO' | translate }}</h4>
|
||||
<p>
|
||||
{{ 'SETTINGS.DASHBOARD_HERO_DESCRIPTION' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox
|
||||
formControlName="hero"
|
||||
data-test-id="toggle-dashboard-hero"
|
||||
></mat-checkbox>
|
||||
</div>
|
||||
<section class="settings-section">
|
||||
<div class="settings-section__header">
|
||||
<h3>{{ 'SETTINGS.CARD_RAILS' | translate }}</h3>
|
||||
</div>
|
||||
<div class="settings-card" formGroupName="dashboardRails">
|
||||
<div class="setting-item" data-setting-id="dashboard-hero">
|
||||
<div class="setting-item__meta">
|
||||
<h4 id="dashboard-hero-label">
|
||||
{{ 'SETTINGS.DASHBOARD_HERO' | translate }}
|
||||
</h4>
|
||||
<p>
|
||||
{{ 'SETTINGS.DASHBOARD_HERO_DESCRIPTION' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-slide-toggle
|
||||
formControlName="hero"
|
||||
aria-labelledby="dashboard-hero-label"
|
||||
data-test-id="toggle-dashboard-hero"
|
||||
></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="setting-item"
|
||||
data-setting-id="dashboard-rail-continue-watching"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.DASHBOARD_RAIL_CONTINUE' | translate }}</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.DASHBOARD_RAIL_CONTINUE_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
<div
|
||||
class="setting-item"
|
||||
data-setting-id="dashboard-rail-continue-watching"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<h4 id="dashboard-rail-continue-watching-label">
|
||||
{{ 'SETTINGS.DASHBOARD_RAIL_CONTINUE' | translate }}
|
||||
</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.DASHBOARD_RAIL_CONTINUE_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-slide-toggle
|
||||
formControlName="continueWatching"
|
||||
aria-labelledby="dashboard-rail-continue-watching-label"
|
||||
data-test-id="toggle-dashboard-rail-continue-watching"
|
||||
></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox
|
||||
formControlName="continueWatching"
|
||||
data-test-id="toggle-dashboard-rail-continue-watching"
|
||||
></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="setting-item"
|
||||
data-setting-id="dashboard-rail-live-favorites"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<h4>
|
||||
{{ 'SETTINGS.DASHBOARD_RAIL_LIVE_FAVORITES' | translate }}
|
||||
</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.DASHBOARD_RAIL_LIVE_FAVORITES_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
<div
|
||||
class="setting-item"
|
||||
data-setting-id="dashboard-rail-live-favorites"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<h4 id="dashboard-rail-live-favorites-label">
|
||||
{{ 'SETTINGS.DASHBOARD_RAIL_LIVE_FAVORITES' | translate }}
|
||||
</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.DASHBOARD_RAIL_LIVE_FAVORITES_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-slide-toggle
|
||||
formControlName="liveFavorites"
|
||||
aria-labelledby="dashboard-rail-live-favorites-label"
|
||||
data-test-id="toggle-dashboard-rail-live-favorites"
|
||||
></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox
|
||||
formControlName="liveFavorites"
|
||||
data-test-id="toggle-dashboard-rail-live-favorites"
|
||||
></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" data-setting-id="dashboard-rail-recent-live">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.DASHBOARD_RAIL_RECENT_LIVE' | translate }}</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.DASHBOARD_RAIL_RECENT_LIVE_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
<div class="setting-item" data-setting-id="dashboard-rail-recent-live">
|
||||
<div class="setting-item__meta">
|
||||
<h4 id="dashboard-rail-recent-live-label">
|
||||
{{ 'SETTINGS.DASHBOARD_RAIL_RECENT_LIVE' | translate }}
|
||||
</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.DASHBOARD_RAIL_RECENT_LIVE_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-slide-toggle
|
||||
formControlName="recentlyWatchedLive"
|
||||
aria-labelledby="dashboard-rail-recent-live-label"
|
||||
data-test-id="toggle-dashboard-rail-recently-watched-live"
|
||||
></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox
|
||||
formControlName="recentlyWatchedLive"
|
||||
data-test-id="toggle-dashboard-rail-recently-watched-live"
|
||||
></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" data-setting-id="dashboard-rail-favorite-vod">
|
||||
<div class="setting-item__meta">
|
||||
<h4>
|
||||
{{ 'SETTINGS.DASHBOARD_RAIL_FAVORITE_VOD' | translate }}
|
||||
</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.DASHBOARD_RAIL_FAVORITE_VOD_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
<div class="setting-item" data-setting-id="dashboard-rail-favorite-vod">
|
||||
<div class="setting-item__meta">
|
||||
<h4 id="dashboard-rail-favorite-vod-label">
|
||||
{{ 'SETTINGS.DASHBOARD_RAIL_FAVORITE_VOD' | translate }}
|
||||
</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.DASHBOARD_RAIL_FAVORITE_VOD_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-slide-toggle
|
||||
formControlName="favoriteMoviesAndSeries"
|
||||
aria-labelledby="dashboard-rail-favorite-vod-label"
|
||||
data-test-id="toggle-dashboard-rail-favorite-movies-and-series"
|
||||
></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox
|
||||
formControlName="favoriteMoviesAndSeries"
|
||||
data-test-id="toggle-dashboard-rail-favorite-movies-and-series"
|
||||
></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" data-setting-id="dashboard-rail-sources">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.DASHBOARD_RAIL_SOURCES' | translate }}</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.DASHBOARD_RAIL_SOURCES_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
<div class="setting-item" data-setting-id="dashboard-rail-sources">
|
||||
<div class="setting-item__meta">
|
||||
<h4 id="dashboard-rail-sources-label">
|
||||
{{ 'SETTINGS.DASHBOARD_RAIL_SOURCES' | translate }}
|
||||
</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.DASHBOARD_RAIL_SOURCES_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-slide-toggle
|
||||
formControlName="recentSources"
|
||||
aria-labelledby="dashboard-rail-sources-label"
|
||||
data-test-id="toggle-dashboard-rail-recent-sources"
|
||||
></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox
|
||||
formControlName="recentSources"
|
||||
data-test-id="toggle-dashboard-rail-recent-sources"
|
||||
></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" data-setting-id="dashboard-rail-xtream-added">
|
||||
<div class="setting-item__meta">
|
||||
<h4>
|
||||
{{ 'SETTINGS.DASHBOARD_RAIL_XTREAM_ADDED' | translate }}
|
||||
</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
<div class="setting-item" data-setting-id="dashboard-rail-xtream-added">
|
||||
<div class="setting-item__meta">
|
||||
<h4 id="dashboard-rail-xtream-added-label">
|
||||
{{ 'SETTINGS.DASHBOARD_RAIL_XTREAM_ADDED' | translate }}
|
||||
</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-slide-toggle
|
||||
formControlName="xtreamRecentlyAdded"
|
||||
aria-labelledby="dashboard-rail-xtream-added-label"
|
||||
data-test-id="toggle-dashboard-rail-xtream-recently-added"
|
||||
></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox
|
||||
formControlName="xtreamRecentlyAdded"
|
||||
data-test-id="toggle-dashboard-rail-xtream-recently-added"
|
||||
></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="setting-item"
|
||||
data-setting-id="dashboard-rail-tmdb-trending"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<h4>
|
||||
{{ 'SETTINGS.DASHBOARD_RAIL_TMDB_TRENDING' | translate }}
|
||||
</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.DASHBOARD_RAIL_TMDB_TRENDING_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
<div
|
||||
class="setting-item"
|
||||
data-setting-id="dashboard-rail-tmdb-trending"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<h4 id="dashboard-rail-tmdb-trending-label">
|
||||
{{ 'SETTINGS.DASHBOARD_RAIL_TMDB_TRENDING' | translate }}
|
||||
</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.DASHBOARD_RAIL_TMDB_TRENDING_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-slide-toggle
|
||||
formControlName="tmdbTrending"
|
||||
aria-labelledby="dashboard-rail-tmdb-trending-label"
|
||||
data-test-id="toggle-dashboard-rail-tmdb-trending"
|
||||
></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox
|
||||
formControlName="tmdbTrending"
|
||||
data-test-id="toggle-dashboard-rail-tmdb-trending"
|
||||
></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="setting-item"
|
||||
data-setting-id="dashboard-rail-tmdb-recommendations"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<h4>
|
||||
{{
|
||||
'SETTINGS.DASHBOARD_RAIL_TMDB_RECOMMENDATIONS'
|
||||
| translate
|
||||
}}
|
||||
</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.DASHBOARD_RAIL_TMDB_RECOMMENDATIONS_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox
|
||||
formControlName="tmdbRecommendations"
|
||||
data-test-id="toggle-dashboard-rail-tmdb-recommendations"
|
||||
></mat-checkbox>
|
||||
<div
|
||||
class="setting-item"
|
||||
data-setting-id="dashboard-rail-tmdb-recommendations"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<h4 id="dashboard-rail-tmdb-recommendations-label">
|
||||
{{
|
||||
'SETTINGS.DASHBOARD_RAIL_TMDB_RECOMMENDATIONS'
|
||||
| translate
|
||||
}}
|
||||
</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.DASHBOARD_RAIL_TMDB_RECOMMENDATIONS_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-slide-toggle
|
||||
formControlName="tmdbRecommendations"
|
||||
aria-labelledby="dashboard-rail-tmdb-recommendations-label"
|
||||
data-test-id="toggle-dashboard-rail-tmdb-recommendations"
|
||||
></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
@@ -6,8 +6,8 @@ import {
|
||||
ChangeDetectionStrategy,
|
||||
} from '@angular/core';
|
||||
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { markSectionForCheckOnFormEvents } from './settings-section-form-render';
|
||||
|
||||
@@ -15,8 +15,8 @@ import { markSectionForCheckOnFormEvents } from './settings-section-form-render'
|
||||
selector: 'app-settings-dashboard-section',
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatCheckboxModule,
|
||||
MatIconModule,
|
||||
MatSlideToggleModule,
|
||||
ReactiveFormsModule,
|
||||
TranslateModule,
|
||||
],
|
||||
|
||||
@@ -1,238 +1,330 @@
|
||||
<section class="settings-group" 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>
|
||||
<h2>{{ 'SETTINGS.NAV_EPG' | translate }}</h2>
|
||||
<p>{{ 'SETTINGS.EPG_SUBTITLE' | translate }}</p>
|
||||
</header>
|
||||
|
||||
<div
|
||||
class="setting-item setting-item--full-control"
|
||||
data-setting-id="epg-view-mode"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.EPG_VIEW_MODE' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.EPG_VIEW_MODE_DESCRIPTION' | translate }}</p>
|
||||
<section class="settings-section">
|
||||
<div class="settings-section__header">
|
||||
<h3>{{ 'SETTINGS.CARD_GUIDE' | translate }}</h3>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<div
|
||||
class="theme-switcher"
|
||||
role="radiogroup"
|
||||
[attr.aria-label]="'SETTINGS.EPG_VIEW_MODE' | translate"
|
||||
data-test-id="select-epg-view-mode"
|
||||
>
|
||||
@for (option of epgViewModeOptions(); track option.value) {
|
||||
<button
|
||||
type="button"
|
||||
class="theme-switcher__option"
|
||||
[class.theme-switcher__option--selected]="
|
||||
form().value.epgViewMode === option.value
|
||||
"
|
||||
[attr.aria-checked]="
|
||||
form().value.epgViewMode === option.value
|
||||
"
|
||||
[attr.data-test-id]="'epg-view-mode-' + option.value"
|
||||
role="radio"
|
||||
(click)="selectEpgViewMode.emit(option.value)"
|
||||
<div class="settings-card">
|
||||
<div class="setting-item" data-setting-id="epg-view-mode">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.EPG_VIEW_MODE' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.EPG_VIEW_MODE_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<div
|
||||
class="theme-switcher"
|
||||
role="radiogroup"
|
||||
[attr.aria-label]="'SETTINGS.EPG_VIEW_MODE' | translate"
|
||||
data-test-id="select-epg-view-mode"
|
||||
>
|
||||
<mat-icon>{{ option.icon }}</mat-icon>
|
||||
<span>{{ option.labelKey | translate }}</span>
|
||||
</button>
|
||||
}
|
||||
@for (option of epgViewModeOptions(); track option.value) {
|
||||
<button
|
||||
type="button"
|
||||
class="theme-switcher__option"
|
||||
[class.theme-switcher__option--selected]="
|
||||
form().value.epgViewMode === option.value
|
||||
"
|
||||
[attr.aria-checked]="
|
||||
form().value.epgViewMode === option.value
|
||||
"
|
||||
[attr.data-test-id]="
|
||||
'epg-view-mode-' + option.value
|
||||
"
|
||||
role="radio"
|
||||
(click)="selectEpgViewMode.emit(option.value)"
|
||||
>
|
||||
<mat-icon>{{ option.icon }}</mat-icon>
|
||||
<span>{{ option.labelKey | translate }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" data-setting-id="epg-offset">
|
||||
<div class="setting-item__meta">
|
||||
<h4 id="epg-offset-label">
|
||||
{{ 'SETTINGS.EPG_OFFSET_TITLE' | translate }}
|
||||
</h4>
|
||||
<p>{{ 'SETTINGS.EPG_OFFSET_HINT' | translate }}</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__control--stack">
|
||||
<div
|
||||
class="settings-stepper"
|
||||
[class.settings-stepper--invalid]="
|
||||
offsetControl?.invalid
|
||||
"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="settings-stepper__button"
|
||||
[attr.aria-label]="
|
||||
'SETTINGS.EPG_OFFSET_STEP_DOWN'
|
||||
| translate: { minutes: offsetStep }
|
||||
"
|
||||
(click)="stepOffset(-offsetStep)"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
min="-720"
|
||||
max="720"
|
||||
[step]="offsetStep"
|
||||
formControlName="epgOffsetMinutes"
|
||||
aria-labelledby="epg-offset-label"
|
||||
data-test-id="epg-offset-minutes"
|
||||
/>
|
||||
<span class="settings-stepper__unit">min</span>
|
||||
<button
|
||||
type="button"
|
||||
class="settings-stepper__button"
|
||||
[attr.aria-label]="
|
||||
'SETTINGS.EPG_OFFSET_STEP_UP'
|
||||
| translate: { minutes: offsetStep }
|
||||
"
|
||||
(click)="stepOffset(offsetStep)"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
@if (offsetControl?.invalid) {
|
||||
<p class="settings-field-error" role="alert">
|
||||
{{ 'SETTINGS.EPG_OFFSET_ERROR' | translate }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div
|
||||
class="setting-item setting-item--full-control"
|
||||
data-setting-id="epg-offset"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.EPG_OFFSET_TITLE' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.EPG_OFFSET_HINT' | translate }}</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>{{
|
||||
'SETTINGS.EPG_OFFSET_LABEL' | translate
|
||||
}}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="number"
|
||||
min="-720"
|
||||
max="720"
|
||||
step="15"
|
||||
formControlName="epgOffsetMinutes"
|
||||
data-test-id="epg-offset-minutes"
|
||||
/>
|
||||
<mat-error>{{
|
||||
'SETTINGS.EPG_OFFSET_ERROR' | translate
|
||||
}}</mat-error>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="setting-item setting-item--full-control"
|
||||
data-setting-id="epg-sources"
|
||||
>
|
||||
<div class="setting-item__meta" data-test-id="epg-source-formats">
|
||||
<h4>{{ 'SETTINGS.EPG_SOURCE_LIST_TITLE' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.EPG_SOURCE_FORMATS_HINT' | translate }}</p>
|
||||
<p class="epg-sources__formats-examples">
|
||||
<span>{{
|
||||
'SETTINGS.EPG_SOURCE_FORMATS_EXAMPLES' | translate
|
||||
<section class="settings-section">
|
||||
<div class="settings-section__header">
|
||||
<h3>{{ 'SETTINGS.CARD_SOURCES' | translate }}</h3>
|
||||
@if (epgUrl().length > 0) {
|
||||
<span class="settings-section__count">{{
|
||||
epgUrl().length
|
||||
}}</span>
|
||||
<code>https://example.org/guide.xml.gz</code>
|
||||
<code>/home/you/epg/guide.xml</code>
|
||||
<code>C:\epg\guide.xml.gz</code>
|
||||
<code>file:///…/guide.xml</code>
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<ng-container formArrayName="epgUrl">
|
||||
@if (epgUrl().length === 0) {
|
||||
<div
|
||||
class="epg-sources-empty"
|
||||
data-test-id="epg-sources-empty"
|
||||
<div class="settings-section__actions">
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
[disabled]="isClearingEpgData()"
|
||||
(click)="refreshAllEpg.emit()"
|
||||
>
|
||||
<mat-icon class="epg-sources-empty__icon"
|
||||
>event_busy</mat-icon
|
||||
<mat-icon>refresh</mat-icon>
|
||||
{{ 'SETTINGS.REFRESH_ALL_EPG' | translate }}
|
||||
</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
(click)="addEpgSource.emit()"
|
||||
>
|
||||
<mat-icon>add</mat-icon>
|
||||
{{ 'SETTINGS.ADD_EPG_SOURCE' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<p class="settings-section__desc">
|
||||
{{ 'SETTINGS.EPG_SOURCE_FORMATS_HINT' | translate }}
|
||||
</p>
|
||||
<div class="settings-card">
|
||||
<div
|
||||
class="setting-item setting-item--list"
|
||||
data-setting-id="epg-sources"
|
||||
>
|
||||
<ng-container formArrayName="epgUrl">
|
||||
@if (epgUrl().length === 0) {
|
||||
<div
|
||||
class="epg-sources-empty"
|
||||
data-test-id="epg-sources-empty"
|
||||
>
|
||||
<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="text"
|
||||
spellcheck="false"
|
||||
[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"
|
||||
/>
|
||||
}
|
||||
@if (canBrowseFiles()) {
|
||||
<mat-icon class="epg-sources-empty__icon"
|
||||
>event_busy</mat-icon
|
||||
>
|
||||
<p class="epg-sources-empty__title">
|
||||
{{ 'SETTINGS.EPG_EMPTY_TITLE' | translate }}
|
||||
</p>
|
||||
<p class="epg-sources-empty__hint">
|
||||
{{ 'SETTINGS.EPG_EMPTY_HINT' | translate }}
|
||||
</p>
|
||||
<button
|
||||
mat-flat-button
|
||||
type="button"
|
||||
(click)="addEpgSource.emit()"
|
||||
>
|
||||
<mat-icon>add</mat-icon>
|
||||
{{ 'SETTINGS.ADD_EPG_SOURCE' | translate }}
|
||||
</button>
|
||||
</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"
|
||||
>
|
||||
<input
|
||||
matInput
|
||||
type="text"
|
||||
spellcheck="false"
|
||||
[formControlName]="i"
|
||||
[placeholder]="
|
||||
'SETTINGS.EPG_URL_PLACEHOLDER'
|
||||
| translate
|
||||
"
|
||||
[attr.aria-label]="
|
||||
'SETTINGS.EPG_URL_LABEL'
|
||||
| translate
|
||||
"
|
||||
#epgField
|
||||
/>
|
||||
<mat-error>{{
|
||||
'SETTINGS.EPG_URL_ERROR' | translate
|
||||
}}</mat-error>
|
||||
</mat-form-field>
|
||||
@if (epgField.value) {
|
||||
<app-epg-source-status
|
||||
[url]="epgField.value"
|
||||
/>
|
||||
}
|
||||
@if (canBrowseFiles()) {
|
||||
<button
|
||||
mat-icon-button
|
||||
[matTooltip]="
|
||||
'SETTINGS.BROWSE_EPG_FILE'
|
||||
| translate
|
||||
"
|
||||
[attr.aria-label]="
|
||||
'SETTINGS.BROWSE_EPG_FILE'
|
||||
| translate
|
||||
"
|
||||
type="button"
|
||||
data-test-id="epg-source-browse"
|
||||
(click)="browseEpgSource.emit(i)"
|
||||
>
|
||||
<mat-icon>folder_open</mat-icon>
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
mat-icon-button
|
||||
[matTooltip]="
|
||||
'SETTINGS.BROWSE_EPG_FILE'
|
||||
'SETTINGS.REFRESH_EPG' | translate
|
||||
"
|
||||
[attr.aria-label]="
|
||||
'SETTINGS.REFRESH_EPG' | translate
|
||||
"
|
||||
type="button"
|
||||
data-test-id="epg-source-refresh"
|
||||
[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
|
||||
"
|
||||
[attr.aria-label]="
|
||||
'SETTINGS.BROWSE_EPG_FILE'
|
||||
'SETTINGS.REMOVE_EPG_SOURCE'
|
||||
| translate
|
||||
"
|
||||
type="button"
|
||||
data-test-id="epg-source-browse"
|
||||
(click)="browseEpgSource.emit(i)"
|
||||
data-test-id="epg-source-remove"
|
||||
(click)="removeEpgSource.emit(i)"
|
||||
>
|
||||
<mat-icon>folder_open</mat-icon>
|
||||
<mat-icon>remove</mat-icon>
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
mat-icon-button
|
||||
[matTooltip]="
|
||||
'SETTINGS.REFRESH_EPG' | translate
|
||||
"
|
||||
type="button"
|
||||
data-test-id="epg-source-refresh"
|
||||
[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"
|
||||
data-test-id="epg-source-remove"
|
||||
(click)="removeEpgSource.emit(i)"
|
||||
>
|
||||
<mat-icon>remove</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</ng-container>
|
||||
<p
|
||||
class="epg-sources__formats-examples"
|
||||
data-test-id="epg-source-formats"
|
||||
>
|
||||
<span>{{
|
||||
'SETTINGS.EPG_SOURCE_FORMATS_EXAMPLES' | translate
|
||||
}}</span>
|
||||
<code>https://example.org/guide.xml.gz</code>
|
||||
<code>/home/you/epg/guide.xml</code>
|
||||
<code>C:\epg\guide.xml.gz</code>
|
||||
<code>file:///…/guide.xml</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
<div class="settings-section__header">
|
||||
<h3>{{ 'SETTINGS.CARD_XTREAM_PLAYLISTS' | translate }}</h3>
|
||||
</div>
|
||||
<div class="settings-card">
|
||||
<div class="setting-item" data-setting-id="prefer-uploaded-epg">
|
||||
<div class="setting-item__meta">
|
||||
<h4 id="prefer-uploaded-epg-label">
|
||||
{{ 'SETTINGS.PREFER_UPLOADED_EPG_TITLE' | translate }}
|
||||
</h4>
|
||||
<p>{{ 'SETTINGS.PREFER_UPLOADED_EPG_HINT' | translate }}</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-slide-toggle
|
||||
formControlName="preferUploadedEpgOverXtream"
|
||||
aria-labelledby="prefer-uploaded-epg-label"
|
||||
data-test-id="toggle-prefer-uploaded-epg"
|
||||
></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
<div class="settings-section__header">
|
||||
<h3>{{ 'SETTINGS.DATA' | translate }}</h3>
|
||||
</div>
|
||||
<div class="settings-card">
|
||||
<div class="setting-item" data-setting-id="epg-clear-data">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.CLEAR_EPG_DATA' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.CLEAR_EPG_DATA_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<button
|
||||
mat-stroked-button
|
||||
class="app-destructive-button"
|
||||
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 }}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" data-setting-id="prefer-uploaded-epg">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.PREFER_UPLOADED_EPG_TITLE' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.PREFER_UPLOADED_EPG_HINT' | translate }}</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox
|
||||
formControlName="preferUploadedEpgOverXtream"
|
||||
data-test-id="toggle-prefer-uploaded-epg"
|
||||
></mat-checkbox>
|
||||
</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
|
||||
class="app-destructive-button"
|
||||
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>
|
||||
</section>
|
||||
@@ -0,0 +1,169 @@
|
||||
// EPG page: the offset stepper and the sources list. Shared row and card
|
||||
// styles come from settings.component.scss (ViewEncapsulation.None).
|
||||
app-settings-epg-section {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
// Number stepper (EPG offset).
|
||||
.settings-stepper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 34px;
|
||||
border: 1px solid var(--settings-control-border);
|
||||
border-radius: 8px;
|
||||
background: var(--settings-control-bg);
|
||||
overflow: hidden;
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--app-selection-color);
|
||||
}
|
||||
|
||||
input {
|
||||
width: 64px;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--app-heading-color);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
appearance: textfield;
|
||||
-moz-appearance: textfield;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&::-webkit-inner-spin-button,
|
||||
&::-webkit-outer-spin-button {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.settings-stepper--invalid {
|
||||
border-color: var(--mat-sys-error);
|
||||
}
|
||||
|
||||
.settings-stepper__button {
|
||||
width: 32px;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--app-body-color);
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--app-hover-overlay);
|
||||
color: var(--app-heading-color);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--app-selection-color);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-stepper__unit {
|
||||
padding-right: 10px;
|
||||
color: var(--settings-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.settings-field-error {
|
||||
margin: 0;
|
||||
color: var(--mat-sys-error);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
// ─── EPG sources ───────────────────────────────────────────────────────────
|
||||
.epg-sources {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.epg-source-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px 8px 16px;
|
||||
|
||||
& + & {
|
||||
border-top: 1px solid var(--settings-item-border);
|
||||
}
|
||||
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
app-epg-source-status {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.epg-sources-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 34px 20px 24px;
|
||||
color: var(--settings-muted);
|
||||
text-align: center;
|
||||
line-height: 1.55;
|
||||
|
||||
.epg-sources-empty__icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
font-size: 28px;
|
||||
margin-bottom: 8px;
|
||||
color: var(--app-muted-color);
|
||||
}
|
||||
|
||||
.epg-sources-empty__title {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--app-heading-color);
|
||||
}
|
||||
|
||||
.epg-sources-empty__hint {
|
||||
margin: 0;
|
||||
max-width: 42ch;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-top: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.epg-sources__formats-examples {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px 8px;
|
||||
margin: 0;
|
||||
padding: 10px 16px 12px;
|
||||
border-top: 1px solid var(--settings-item-border);
|
||||
color: var(--settings-muted);
|
||||
font-size: 11.5px;
|
||||
|
||||
code {
|
||||
padding: 1px 6px;
|
||||
border-radius: 6px;
|
||||
background: var(--settings-chip-bg);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
// Pane width, see settings.component.scss.
|
||||
@container (max-width: 600px) {
|
||||
.settings-field-error {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { FormArray, FormControl, FormGroup, Validators } from '@angular/forms';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { SettingsEpgSectionComponent } from './settings-epg-section.component';
|
||||
import { SETTINGS_EPG_VIEW_MODE_OPTIONS } from './settings-options';
|
||||
|
||||
function createForm(offset = 0): FormGroup {
|
||||
return new FormGroup({
|
||||
epgUrl: new FormArray<FormControl<string | null>>([]),
|
||||
epgViewMode: new FormControl('timeline'),
|
||||
epgOffsetMinutes: new FormControl<number>(offset, [
|
||||
Validators.required,
|
||||
Validators.min(-720),
|
||||
Validators.max(720),
|
||||
]),
|
||||
preferUploadedEpgOverXtream: new FormControl(false),
|
||||
});
|
||||
}
|
||||
|
||||
describe('SettingsEpgSectionComponent offset stepper', () => {
|
||||
let fixture: ComponentFixture<SettingsEpgSectionComponent>;
|
||||
|
||||
function configure(form: FormGroup) {
|
||||
fixture.componentRef.setInput('form', form);
|
||||
fixture.componentRef.setInput('epgUrl', form.get('epgUrl'));
|
||||
fixture.componentRef.setInput(
|
||||
'epgViewModeOptions',
|
||||
SETTINGS_EPG_VIEW_MODE_OPTIONS
|
||||
);
|
||||
fixture.detectChanges();
|
||||
return form;
|
||||
}
|
||||
|
||||
const query = (selector: string) =>
|
||||
(fixture.nativeElement as HTMLElement).querySelector(selector);
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [SettingsEpgSectionComponent, TranslateModule.forRoot()],
|
||||
}).compileComponents();
|
||||
fixture = TestBed.createComponent(SettingsEpgSectionComponent);
|
||||
});
|
||||
|
||||
it('moves the offset by one step and stages the change', () => {
|
||||
const form = configure(createForm(0));
|
||||
|
||||
fixture.componentInstance.stepOffset(30);
|
||||
expect(form.get('epgOffsetMinutes')?.value).toBe(30);
|
||||
expect(form.get('epgOffsetMinutes')?.dirty).toBe(true);
|
||||
expect(form.dirty).toBe(true);
|
||||
|
||||
fixture.componentInstance.stepOffset(-30);
|
||||
fixture.componentInstance.stepOffset(-30);
|
||||
expect(form.get('epgOffsetMinutes')?.value).toBe(-30);
|
||||
});
|
||||
|
||||
it('clamps the buttons to the accepted range and recovers a bad value', () => {
|
||||
const form = configure(createForm(710));
|
||||
|
||||
fixture.componentInstance.stepOffset(30);
|
||||
expect(form.get('epgOffsetMinutes')?.value).toBe(720);
|
||||
|
||||
form.get('epgOffsetMinutes')?.setValue(Number.NaN);
|
||||
fixture.componentInstance.stepOffset(-30);
|
||||
expect(form.get('epgOffsetMinutes')?.value).toBe(-30);
|
||||
});
|
||||
|
||||
it('shows the range error under the stepper for a typed value out of range', () => {
|
||||
const form = configure(createForm(0));
|
||||
|
||||
expect(query('.settings-field-error')).toBeNull();
|
||||
|
||||
form.get('epgOffsetMinutes')?.setValue(900);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(query('.settings-stepper--invalid')).not.toBeNull();
|
||||
expect(query('.settings-field-error')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('offers Add in the empty state and in the header once a source exists', () => {
|
||||
const form = configure(createForm());
|
||||
const addButtons = () =>
|
||||
Array.from(
|
||||
(fixture.nativeElement as HTMLElement).querySelectorAll('button')
|
||||
).filter((button) =>
|
||||
button.textContent?.includes('SETTINGS.ADD_EPG_SOURCE')
|
||||
);
|
||||
|
||||
expect(query('[data-test-id="epg-sources-empty"]')).not.toBeNull();
|
||||
expect(addButtons()).toHaveLength(1);
|
||||
expect(query('.settings-section__actions')).toBeNull();
|
||||
|
||||
(form.get('epgUrl') as FormArray).push(new FormControl('https://x'));
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(query('[data-test-id="epg-sources-empty"]')).toBeNull();
|
||||
expect(addButtons()).toHaveLength(1);
|
||||
expect(
|
||||
query('.settings-section__actions')?.contains(addButtons()[0])
|
||||
).toBe(true);
|
||||
expect(query('.settings-section__count')?.textContent?.trim()).toBe(
|
||||
'1'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -5,13 +5,18 @@ import {
|
||||
ViewEncapsulation,
|
||||
ChangeDetectionStrategy,
|
||||
} from '@angular/core';
|
||||
import { FormArray, FormGroup, ReactiveFormsModule } from '@angular/forms';
|
||||
import {
|
||||
AbstractControl,
|
||||
FormArray,
|
||||
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 { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { EpgViewMode } from '@iptvnator/shared/interfaces';
|
||||
import { EpgSourceStatusComponent } from '@iptvnator/ui/epg';
|
||||
@@ -24,11 +29,11 @@ import { markSectionForCheckOnFormEvents } from './settings-section-form-render'
|
||||
imports: [
|
||||
EpgSourceStatusComponent,
|
||||
MatButtonModule,
|
||||
MatCheckboxModule,
|
||||
MatFormFieldModule,
|
||||
MatIconModule,
|
||||
MatInputModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatSlideToggleModule,
|
||||
MatTooltipModule,
|
||||
ReactiveFormsModule,
|
||||
TranslateModule,
|
||||
@@ -36,7 +41,7 @@ import { markSectionForCheckOnFormEvents } from './settings-section-form-render'
|
||||
templateUrl: './settings-epg-section.component.html',
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styles: [':host { display: contents; }'],
|
||||
styleUrls: ['./settings-epg-section.component.scss'],
|
||||
})
|
||||
export class SettingsEpgSectionComponent {
|
||||
readonly form = input.required<FormGroup>();
|
||||
@@ -59,4 +64,35 @@ export class SettingsEpgSectionComponent {
|
||||
readonly refreshAllEpg = output<void>();
|
||||
readonly clearEpgData = output<void>();
|
||||
readonly selectEpgViewMode = output<EpgViewMode>();
|
||||
|
||||
/** Minutes one stepper click shifts the displayed times by */
|
||||
readonly offsetStep = 30;
|
||||
|
||||
get offsetControl(): AbstractControl | null {
|
||||
return this.form().get('epgOffsetMinutes');
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the offset by one step within the accepted range. Typed values
|
||||
* keep going through the control's own validation; only the buttons
|
||||
* clamp, so an out-of-range typed value snaps back into range.
|
||||
*/
|
||||
stepOffset(delta: number): void {
|
||||
const control = this.offsetControl;
|
||||
if (!control) return;
|
||||
const current = Number(control.value);
|
||||
const next = Math.max(
|
||||
EPG_OFFSET_MIN,
|
||||
Math.min(
|
||||
EPG_OFFSET_MAX,
|
||||
(Number.isFinite(current) ? current : 0) + delta
|
||||
)
|
||||
);
|
||||
control.setValue(next);
|
||||
control.markAsDirty();
|
||||
this.form().markAsDirty();
|
||||
}
|
||||
}
|
||||
|
||||
const EPG_OFFSET_MIN = -720;
|
||||
const EPG_OFFSET_MAX = 720;
|
||||
@@ -1,4 +1,10 @@
|
||||
import { DestroyRef, inject, Injectable } from '@angular/core';
|
||||
import {
|
||||
DestroyRef,
|
||||
inject,
|
||||
Injectable,
|
||||
signal,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { FormArray, FormBuilder } from '@angular/forms';
|
||||
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
|
||||
@@ -16,6 +22,11 @@ import { SettingsSnackbarService } from './settings-snackbar.service';
|
||||
import { SettingsStore } from '../services/settings-store.service';
|
||||
import { SettingsService } from '../services/settings.service';
|
||||
import { AppDateLocaleService } from '../app-date-locales';
|
||||
import {
|
||||
diffSettingsValues,
|
||||
SETTINGS_RESTART_CONTROLS,
|
||||
} from './settings-change-tracking';
|
||||
import { SettingsLaunchValuesService } from './settings-launch-values.service';
|
||||
import {
|
||||
applyEpgUrlsToFormArray,
|
||||
createEpgUrlControl,
|
||||
@@ -36,6 +47,7 @@ export class SettingsFormFacade {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly epgBridge = inject(EpgRuntimeBridgeService);
|
||||
private readonly formBuilder = inject(FormBuilder);
|
||||
private readonly launchValues = inject(SettingsLaunchValuesService);
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
private readonly settingsService = inject(SettingsService);
|
||||
private readonly settingsSnackbar = inject(SettingsSnackbarService);
|
||||
@@ -50,6 +62,24 @@ export class SettingsFormFacade {
|
||||
/** Form array with epg sources — absent when EPG is unsupported */
|
||||
readonly epgUrl = this.form.get('epgUrl') as FormArray;
|
||||
|
||||
/**
|
||||
* Dotted paths of the staged values that differ from the saved ones.
|
||||
* Drives the save bar's change count and the dirty marks in the nav;
|
||||
* the form's own `dirty` flag stays what the save/leave guards read.
|
||||
*/
|
||||
readonly changedPaths = signal<readonly string[]>([]);
|
||||
|
||||
/** Controls whose saved value differs from what the running app uses. */
|
||||
readonly restartPendingControls = signal<readonly string[]>([]);
|
||||
|
||||
private savedSnapshot: unknown = {};
|
||||
|
||||
constructor() {
|
||||
this.form.valueChanges
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe(() => this.refreshChangedPaths());
|
||||
}
|
||||
|
||||
/** Trimmed, non-empty EPG source URLs currently in the form */
|
||||
get epgUrls(): string[] {
|
||||
return ((this.epgUrl?.value as string[] | undefined) ?? [])
|
||||
@@ -82,6 +112,23 @@ export class SettingsFormFacade {
|
||||
this.epgUrl.clear();
|
||||
this.setEpgUrls(currentSettings.epgUrl);
|
||||
}
|
||||
this.takeSavedSnapshot();
|
||||
// The app launched with the stored values; recorded once per app run,
|
||||
// not per settings visit, so a later visit compares against launch.
|
||||
this.launchValues.captureOnce(
|
||||
(control) => this.form.get(control)?.value
|
||||
);
|
||||
this.refreshRestartPending();
|
||||
}
|
||||
|
||||
/**
|
||||
* What the running app actually uses for a restart control when that
|
||||
* differs from the stored value (a frame-copy opt-in the engine could
|
||||
* not honour). Re-evaluates the pending notice.
|
||||
*/
|
||||
setRunningValue(control: string, value: unknown): void {
|
||||
this.launchValues.set(control, value);
|
||||
this.refreshRestartPending();
|
||||
}
|
||||
|
||||
bindDashboardControlsEnabledState(): void {
|
||||
@@ -164,7 +211,6 @@ export class SettingsFormFacade {
|
||||
this.form,
|
||||
this.settingsStore.getSettings()
|
||||
);
|
||||
|
||||
let cleanupError: EpgSourceReconciliationError | undefined;
|
||||
try {
|
||||
await this.settingsStore.updateSettings(settings, {
|
||||
@@ -176,7 +222,10 @@ export class SettingsFormFacade {
|
||||
// committed values, but retain the dirty form for cleanup retry.
|
||||
cleanupError = error;
|
||||
}
|
||||
if (!cleanupError) onSaved();
|
||||
if (!cleanupError) {
|
||||
onSaved();
|
||||
this.refreshRestartPending();
|
||||
}
|
||||
|
||||
if (window.electron) {
|
||||
window.electron.updateSettings(settings);
|
||||
@@ -189,9 +238,19 @@ export class SettingsFormFacade {
|
||||
if (cleanupError) throw cleanupError;
|
||||
}
|
||||
|
||||
/** Later: the reminder stays away until one of those settings changes. */
|
||||
dismissRestartNotice(): void {
|
||||
const saved = (this.savedSnapshot ?? {}) as Record<string, unknown>;
|
||||
for (const control of this.restartPendingControls()) {
|
||||
this.launchValues.dismiss(control, saved[control]);
|
||||
}
|
||||
this.restartPendingControls.set([]);
|
||||
}
|
||||
|
||||
/** Applies the saved language/theme and resets the dirty state */
|
||||
applySavedSettings(): void {
|
||||
this.form.markAsPristine();
|
||||
this.takeSavedSnapshot();
|
||||
// The switch re-renders every date with the new locale; its data is
|
||||
// a lazy chunk that must be registered first, and a newer choice
|
||||
// must win over an older one whose data arrives later.
|
||||
@@ -201,6 +260,43 @@ export class SettingsFormFacade {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart controls whose SAVED value the running app does not use yet.
|
||||
* Compares the last saved snapshot, never the draft form: a staged but
|
||||
* unsaved edit must not raise or clear the reminder. Called from an
|
||||
* effect (the engine probe), so the current list is read untracked and
|
||||
* only a changed list is written: a fresh array on every run would
|
||||
* re-trigger that effect for ever.
|
||||
*/
|
||||
private refreshRestartPending(): void {
|
||||
const running = this.launchValues.get();
|
||||
const saved = (this.savedSnapshot ?? {}) as Record<string, unknown>;
|
||||
const pending = Object.keys(SETTINGS_RESTART_CONTROLS).filter(
|
||||
(control) =>
|
||||
control in running &&
|
||||
saved[control] !== running[control] &&
|
||||
!this.launchValues.isDismissed(control, saved[control])
|
||||
);
|
||||
const current = untracked(this.restartPendingControls);
|
||||
if (
|
||||
current.length !== pending.length ||
|
||||
current.some((control, index) => control !== pending[index])
|
||||
) {
|
||||
this.restartPendingControls.set(pending);
|
||||
}
|
||||
}
|
||||
|
||||
private takeSavedSnapshot(): void {
|
||||
this.savedSnapshot = this.form.getRawValue();
|
||||
this.refreshChangedPaths();
|
||||
}
|
||||
|
||||
private refreshChangedPaths(): void {
|
||||
this.changedPaths.set(
|
||||
diffSettingsValues(this.form.getRawValue(), this.savedSnapshot)
|
||||
);
|
||||
}
|
||||
|
||||
private patchAndMarkDirty(
|
||||
value: SettingsFormPatch,
|
||||
controlName: string
|
||||
|
||||
@@ -1,276 +1,311 @@
|
||||
<section class="settings-group" 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>
|
||||
<h2>{{ 'SETTINGS.GENERAL' | translate }}</h2>
|
||||
<p>{{ 'SETTINGS.GENERAL_SUBTITLE' | translate }}</p>
|
||||
</header>
|
||||
|
||||
<div class="setting-item" data-setting-id="language">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.LANGUAGE' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.LANGUAGE_DESCRIPTION' | translate }}</p>
|
||||
<section class="settings-section">
|
||||
<div class="settings-section__header">
|
||||
<h3>{{ 'SETTINGS.CARD_APPEARANCE' | translate }}</h3>
|
||||
</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"
|
||||
data-setting-id="theme"
|
||||
>
|
||||
<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)"
|
||||
<div class="settings-card">
|
||||
<div class="setting-item" data-setting-id="language">
|
||||
<div class="setting-item__meta">
|
||||
<h4 id="language-label">
|
||||
{{ 'SETTINGS.LANGUAGE' | translate }}
|
||||
</h4>
|
||||
<p>{{ 'SETTINGS.LANGUAGE_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<mat-form-field
|
||||
class="settings-select"
|
||||
appearance="outline"
|
||||
subscriptSizing="dynamic"
|
||||
>
|
||||
<mat-icon>{{ themeOption.icon }}</mat-icon>
|
||||
<span>{{ themeOption.labelKey | translate }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="setting-item setting-item--full-control"
|
||||
data-setting-id="cover-size"
|
||||
>
|
||||
<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" data-setting-id="cover-titles">
|
||||
<div class="setting-item__meta">
|
||||
<h4 id="cover-titles-label">
|
||||
{{ 'SETTINGS.COVER_TITLES' | translate }}
|
||||
</h4>
|
||||
<p id="cover-titles-description">
|
||||
{{ 'SETTINGS.COVER_TITLES_DESCRIPTION' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox
|
||||
formControlName="showCoverTitles"
|
||||
aria-labelledby="cover-titles-label"
|
||||
aria-describedby="cover-titles-description"
|
||||
data-test-id="cover-titles-toggle"
|
||||
></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" data-setting-id="startup-behavior">
|
||||
<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"
|
||||
<mat-select
|
||||
formControlName="language"
|
||||
aria-labelledby="language-label"
|
||||
data-test-id="select-language"
|
||||
>
|
||||
{{ option.labelKey | translate }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (supportsStartupWindowMode()) {
|
||||
<div
|
||||
class="setting-item"
|
||||
data-setting-id="startup-window-mode"
|
||||
data-test-id="startup-window-mode-setting"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.STARTUP_WINDOW_MODE' | translate }}</h4>
|
||||
<p>
|
||||
{{ 'SETTINGS.STARTUP_WINDOW_MODE_DESCRIPTION' | translate }}
|
||||
</p>
|
||||
@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__control">
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>{{
|
||||
'SETTINGS.STARTUP_WINDOW_MODE' | translate
|
||||
}}</mat-label>
|
||||
<mat-select
|
||||
formControlName="startupWindowMode"
|
||||
data-test-id="select-startup-window-mode"
|
||||
|
||||
<div class="setting-item" data-setting-id="theme">
|
||||
<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 (
|
||||
option of startupWindowModeOptions();
|
||||
track option.value
|
||||
themeOption of themeOptions();
|
||||
track themeOption.value
|
||||
) {
|
||||
<mat-option
|
||||
[value]="option.value"
|
||||
[attr.data-test-id]="
|
||||
'startup-window-mode-' + option.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)"
|
||||
>
|
||||
{{ option.labelKey | translate }}
|
||||
</mat-option>
|
||||
<mat-icon>{{ themeOption.icon }}</mat-icon>
|
||||
<span>{{ themeOption.labelKey | translate }}</span>
|
||||
</button>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="setting-item" data-setting-id="show-captions">
|
||||
<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>
|
||||
<div class="setting-item" data-setting-id="cover-size">
|
||||
<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" data-setting-id="strip-country-prefix">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.STRIP_COUNTRY_PREFIX' | translate }}</h4>
|
||||
<p>
|
||||
{{ 'SETTINGS.STRIP_COUNTRY_PREFIX_DESCRIPTION' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox formControlName="stripCountryPrefix"></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@if (supportsPortalConnectivityGuard()) {
|
||||
<section
|
||||
class="settings-group"
|
||||
[formGroup]="form()"
|
||||
data-test-id="portal-connectivity-setting"
|
||||
>
|
||||
<header class="settings-group__header">
|
||||
<div class="settings-group__header-icon">
|
||||
<mat-icon>hub</mat-icon>
|
||||
</div>
|
||||
<div class="settings-group__header-text">
|
||||
<h3>{{ 'SETTINGS.PORTAL_CONNECTIONS' | translate }}</h3>
|
||||
</div>
|
||||
</header>
|
||||
<div class="setting-item" data-setting-id="portal-connectivity-guard">
|
||||
<div class="setting-item__meta">
|
||||
<h4 id="portal-connectivity-label">
|
||||
{{ 'SETTINGS.PORTAL_CONNECTIVITY_GUARD' | translate }}
|
||||
</h4>
|
||||
<p id="portal-connectivity-description">
|
||||
{{
|
||||
'SETTINGS.PORTAL_CONNECTIVITY_GUARD_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
@if (form().get('portalConnectivityGuard')?.value === false) {
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.PORTAL_CONNECTIVITY_GUARD_DISABLED'
|
||||
| translate
|
||||
}}
|
||||
<div class="setting-item" data-setting-id="cover-titles">
|
||||
<div class="setting-item__meta">
|
||||
<h4 id="cover-titles-label">
|
||||
{{ 'SETTINGS.COVER_TITLES' | translate }}
|
||||
</h4>
|
||||
<p id="cover-titles-description">
|
||||
{{ 'SETTINGS.COVER_TITLES_DESCRIPTION' | translate }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox
|
||||
formControlName="portalConnectivityGuard"
|
||||
aria-labelledby="portal-connectivity-label"
|
||||
aria-describedby="portal-connectivity-description"
|
||||
data-test-id="portal-connectivity-toggle"
|
||||
></mat-checkbox>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-slide-toggle
|
||||
formControlName="showCoverTitles"
|
||||
aria-labelledby="cover-titles-label"
|
||||
aria-describedby="cover-titles-description"
|
||||
data-test-id="cover-titles-toggle"
|
||||
></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
<section class="settings-section">
|
||||
<div class="settings-section__header">
|
||||
<h3>{{ 'SETTINGS.CARD_STARTUP' | translate }}</h3>
|
||||
</div>
|
||||
<div class="settings-card">
|
||||
<div class="setting-item" data-setting-id="startup-behavior">
|
||||
<div class="setting-item__meta">
|
||||
<h4 id="startup-behavior-label">
|
||||
{{ 'SETTINGS.STARTUP_BEHAVIOR' | translate }}
|
||||
</h4>
|
||||
<p>
|
||||
{{ 'SETTINGS.STARTUP_BEHAVIOR_DESCRIPTION' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<mat-form-field
|
||||
class="settings-select"
|
||||
appearance="outline"
|
||||
subscriptSizing="dynamic"
|
||||
>
|
||||
<mat-select
|
||||
formControlName="startupBehavior"
|
||||
aria-labelledby="startup-behavior-label"
|
||||
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>
|
||||
|
||||
@if (supportsStartupWindowMode()) {
|
||||
<div
|
||||
class="setting-item"
|
||||
data-setting-id="startup-window-mode"
|
||||
data-test-id="startup-window-mode-setting"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<h4 id="startup-window-mode-label">
|
||||
{{ 'SETTINGS.STARTUP_WINDOW_MODE' | translate }}
|
||||
<span class="settings-chip">{{
|
||||
'SETTINGS.CHIP_RESTART' | translate
|
||||
}}</span>
|
||||
</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.STARTUP_WINDOW_MODE_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<mat-form-field
|
||||
class="settings-select"
|
||||
appearance="outline"
|
||||
subscriptSizing="dynamic"
|
||||
>
|
||||
<mat-select
|
||||
formControlName="startupWindowMode"
|
||||
aria-labelledby="startup-window-mode-label"
|
||||
data-test-id="select-startup-window-mode"
|
||||
>
|
||||
@for (
|
||||
option of startupWindowModeOptions();
|
||||
track option.value
|
||||
) {
|
||||
<mat-option
|
||||
[value]="option.value"
|
||||
[attr.data-test-id]="
|
||||
'startup-window-mode-' +
|
||||
option.value
|
||||
"
|
||||
>
|
||||
{{ option.labelKey | translate }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
<div class="settings-section__header">
|
||||
<h3>{{ 'SETTINGS.CARD_CHANNELS' | translate }}</h3>
|
||||
</div>
|
||||
<div class="settings-card">
|
||||
<div class="setting-item" data-setting-id="strip-country-prefix">
|
||||
<div class="setting-item__meta">
|
||||
<h4 id="strip-country-prefix-label">
|
||||
{{ 'SETTINGS.STRIP_COUNTRY_PREFIX' | translate }}
|
||||
</h4>
|
||||
<p id="strip-country-prefix-description">
|
||||
{{
|
||||
'SETTINGS.STRIP_COUNTRY_PREFIX_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-slide-toggle
|
||||
formControlName="stripCountryPrefix"
|
||||
aria-labelledby="strip-country-prefix-label"
|
||||
aria-describedby="strip-country-prefix-description"
|
||||
data-test-id="strip-country-prefix-toggle"
|
||||
></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (supportsPortalConnectivityGuard()) {
|
||||
<section
|
||||
class="settings-section"
|
||||
data-test-id="portal-connectivity-setting"
|
||||
>
|
||||
<div class="settings-section__header">
|
||||
<h3>{{ 'SETTINGS.PORTAL_CONNECTIONS' | translate }}</h3>
|
||||
</div>
|
||||
<div class="settings-card">
|
||||
<div
|
||||
class="setting-item"
|
||||
data-setting-id="portal-connectivity-guard"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<h4 id="portal-connectivity-label">
|
||||
{{ 'SETTINGS.PORTAL_CONNECTIVITY_GUARD' | translate }}
|
||||
</h4>
|
||||
<p id="portal-connectivity-description">
|
||||
{{
|
||||
'SETTINGS.PORTAL_CONNECTIVITY_GUARD_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
@if (
|
||||
form().get('portalConnectivityGuard')?.value ===
|
||||
false
|
||||
) {
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.PORTAL_CONNECTIVITY_GUARD_DISABLED'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-slide-toggle
|
||||
formControlName="portalConnectivityGuard"
|
||||
aria-labelledby="portal-connectivity-label"
|
||||
aria-describedby="portal-connectivity-description"
|
||||
data-test-id="portal-connectivity-toggle"
|
||||
></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
</section>
|
||||
@@ -7,10 +7,10 @@ import {
|
||||
ChangeDetectionStrategy,
|
||||
} 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 { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { CoverSize, Language, Theme } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
@@ -25,10 +25,10 @@ import { markSectionForCheckOnFormEvents } from './settings-section-form-render'
|
||||
selector: 'app-settings-general-section',
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatCheckboxModule,
|
||||
MatFormFieldModule,
|
||||
MatIconModule,
|
||||
MatSelectModule,
|
||||
MatSlideToggleModule,
|
||||
ReactiveFormsModule,
|
||||
TranslateModule,
|
||||
],
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { SETTINGS_RESTART_CONTROLS } from './settings-change-tracking';
|
||||
|
||||
/**
|
||||
* Values of the restart-only settings the running app started with. Root
|
||||
* provided on purpose: the settings page (and its form facade) is recreated
|
||||
* every time it opens, but what the app launched with does not change until
|
||||
* it restarts, so the restart notice must compare against one app-lifetime
|
||||
* record.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SettingsLaunchValuesService {
|
||||
private values: Record<string, unknown> | null = null;
|
||||
/** Saved values whose restart reminder the user dismissed with Later. */
|
||||
private dismissed: Record<string, unknown> = {};
|
||||
|
||||
/** Records the stored values once, on the first settings open. */
|
||||
captureOnce(read: (control: string) => unknown): void {
|
||||
if (this.values) return;
|
||||
const values: Record<string, unknown> = {};
|
||||
for (const control of Object.keys(SETTINGS_RESTART_CONTROLS)) {
|
||||
values[control] = read(control);
|
||||
}
|
||||
this.values = values;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the running app actually uses when that differs from the stored
|
||||
* value (a frame-copy opt-in the engine could not honour).
|
||||
*/
|
||||
set(control: string, value: unknown): void {
|
||||
if (!(control in SETTINGS_RESTART_CONTROLS)) return;
|
||||
this.values = { ...(this.values ?? {}), [control]: value };
|
||||
}
|
||||
|
||||
get(): Readonly<Record<string, unknown>> {
|
||||
return this.values ?? {};
|
||||
}
|
||||
|
||||
/** Later: this saved value needs no further reminder this app run. */
|
||||
dismiss(control: string, savedValue: unknown): void {
|
||||
this.dismissed = { ...this.dismissed, [control]: savedValue };
|
||||
}
|
||||
|
||||
/** A dismissed reminder returns only once the saved value changes. */
|
||||
isDismissed(control: string, savedValue: unknown): boolean {
|
||||
return (
|
||||
control in this.dismissed && this.dismissed[control] === savedValue
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -92,12 +92,26 @@ describe('buildSettingsSectionNavItems', () => {
|
||||
'playback',
|
||||
'epg',
|
||||
'dashboard',
|
||||
'remote-control',
|
||||
'tmdb',
|
||||
'parental',
|
||||
'remote-control',
|
||||
'backup',
|
||||
'reset',
|
||||
'about',
|
||||
]);
|
||||
});
|
||||
|
||||
it('groups the sections and pins About to the footer', () => {
|
||||
const items = buildSettingsSectionNavItems({
|
||||
supportsEpg: true,
|
||||
supportsRemoteControl: true,
|
||||
});
|
||||
const groupOf = (id: string) =>
|
||||
items.find((item) => item.id === id)?.group;
|
||||
|
||||
expect(groupOf('general')).toBe('app');
|
||||
expect(groupOf('tmdb')).toBe('library');
|
||||
expect(groupOf('remote-control')).toBe('devices');
|
||||
expect(groupOf('backup')).toBe('data');
|
||||
expect(groupOf('about')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -177,5 +177,6 @@ export function buildSettingsSectionNavItems({
|
||||
label: section.navLabelKey,
|
||||
icon: section.icon,
|
||||
visible: meetsSettingsRequirements(section.requires, capabilities),
|
||||
...(section.group ? { group: section.group } : {}),
|
||||
}));
|
||||
}
|
||||
@@ -1,145 +1,151 @@
|
||||
<section class="settings-group" id="parental">
|
||||
<header class="settings-group__header">
|
||||
<div class="settings-group__header-icon">
|
||||
<mat-icon>family_restroom</mat-icon>
|
||||
</div>
|
||||
<div class="settings-group__header-text">
|
||||
<h3>{{ 'SETTINGS.PARENTAL_LOCK.TITLE' | translate }}</h3>
|
||||
<p>{{ 'SETTINGS.PARENTAL_LOCK.DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
<h2>{{ 'SETTINGS.PARENTAL_LOCK.TITLE' | translate }}</h2>
|
||||
<p>{{ 'SETTINGS.PARENTAL_LOCK.DESCRIPTION' | translate }}</p>
|
||||
</header>
|
||||
|
||||
<div class="setting-item" data-setting-id="parental-lock-enable">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.PARENTAL_LOCK.ENABLE' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.PARENTAL_LOCK.ENABLE_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-slide-toggle
|
||||
data-test-id="parental-lock-enabled"
|
||||
[checked]="enabled()"
|
||||
[disabled]="busy()"
|
||||
(change)="onToggleEnabled($event)"
|
||||
[attr.aria-label]="'SETTINGS.PARENTAL_LOCK.ENABLE' | translate"
|
||||
></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (enabled()) {
|
||||
<div class="setting-item" data-setting-id="parental-lock-status">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.PARENTAL_LOCK.STATUS' | translate }}</h4>
|
||||
<p>
|
||||
{{
|
||||
(unlocked()
|
||||
? 'SETTINGS.PARENTAL_LOCK.STATUS_UNLOCKED'
|
||||
: 'SETTINGS.PARENTAL_LOCK.STATUS_LOCKED'
|
||||
) | translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
@if (unlocked()) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
data-test-id="parental-lock-lock-now"
|
||||
<section class="settings-section">
|
||||
<div class="settings-card">
|
||||
<div class="setting-item" data-setting-id="parental-lock-enable">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.PARENTAL_LOCK.ENABLE' | translate }}</h4>
|
||||
<p>
|
||||
{{ 'SETTINGS.PARENTAL_LOCK.ENABLE_DESCRIPTION' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-slide-toggle
|
||||
data-test-id="parental-lock-enabled"
|
||||
[checked]="enabled()"
|
||||
[disabled]="busy()"
|
||||
(click)="lockNow.emit()"
|
||||
>
|
||||
<mat-icon>lock</mat-icon>
|
||||
{{ 'SETTINGS.PARENTAL_LOCK.LOCK_NOW' | translate }}
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
data-test-id="parental-lock-unlock"
|
||||
[disabled]="busy()"
|
||||
(click)="unlock.emit()"
|
||||
>
|
||||
<mat-icon>lock_open</mat-icon>
|
||||
{{ 'SETTINGS.PARENTAL_LOCK.UNLOCK' | translate }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" data-setting-id="parental-lock-relock">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.PARENTAL_LOCK.RELOCK' | translate }}</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.PARENTAL_LOCK.RELOCK_DESCRIPTION' | translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>{{
|
||||
'SETTINGS.PARENTAL_LOCK.RELOCK' | translate
|
||||
}}</mat-label>
|
||||
<mat-select
|
||||
data-test-id="parental-lock-relock"
|
||||
[value]="relockMinutes()"
|
||||
[disabled]="busy() || !unlocked()"
|
||||
(selectionChange)="
|
||||
relockMinutesChanged.emit($event.value)
|
||||
(change)="onToggleEnabled($event)"
|
||||
[attr.aria-label]="
|
||||
'SETTINGS.PARENTAL_LOCK.ENABLE' | translate
|
||||
"
|
||||
>
|
||||
@for (option of relockOptions(); track option) {
|
||||
<mat-option [value]="option">
|
||||
{{
|
||||
relockLabelKey(option)
|
||||
| translate: { minutes: option }
|
||||
}}
|
||||
</mat-option>
|
||||
></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (enabled()) {
|
||||
<div class="setting-item" data-setting-id="parental-lock-status">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.PARENTAL_LOCK.STATUS' | translate }}</h4>
|
||||
<p>
|
||||
{{
|
||||
(unlocked()
|
||||
? 'SETTINGS.PARENTAL_LOCK.STATUS_UNLOCKED'
|
||||
: 'SETTINGS.PARENTAL_LOCK.STATUS_LOCKED'
|
||||
) | translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
@if (unlocked()) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
data-test-id="parental-lock-lock-now"
|
||||
[disabled]="busy()"
|
||||
(click)="lockNow.emit()"
|
||||
>
|
||||
<mat-icon>lock</mat-icon>
|
||||
{{ 'SETTINGS.PARENTAL_LOCK.LOCK_NOW' | translate }}
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
data-test-id="parental-lock-unlock"
|
||||
[disabled]="busy()"
|
||||
(click)="unlock.emit()"
|
||||
>
|
||||
<mat-icon>lock_open</mat-icon>
|
||||
{{ 'SETTINGS.PARENTAL_LOCK.UNLOCK' | translate }}
|
||||
</button>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" data-setting-id="parental-lock-change-pin">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.PARENTAL_LOCK.CHANGE_PIN' | translate }}</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.PARENTAL_LOCK.CHANGE_PIN_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
data-test-id="parental-lock-change-pin"
|
||||
[disabled]="busy() || !hasPin()"
|
||||
(click)="changePin.emit()"
|
||||
>
|
||||
<mat-icon>password</mat-icon>
|
||||
{{ 'SETTINGS.PARENTAL_LOCK.CHANGE_PIN' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item" data-setting-id="parental-lock-relock">
|
||||
<div class="setting-item__meta">
|
||||
<h4 id="parental-lock-relock-label">
|
||||
{{ 'SETTINGS.PARENTAL_LOCK.RELOCK' | translate }}
|
||||
</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.PARENTAL_LOCK.RELOCK_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<mat-form-field
|
||||
class="settings-select"
|
||||
appearance="outline"
|
||||
subscriptSizing="dynamic"
|
||||
>
|
||||
<mat-select
|
||||
data-test-id="parental-lock-relock"
|
||||
aria-labelledby="parental-lock-relock-label"
|
||||
[value]="relockMinutes()"
|
||||
[disabled]="busy() || !unlocked()"
|
||||
(selectionChange)="
|
||||
relockMinutesChanged.emit($event.value)
|
||||
"
|
||||
>
|
||||
@for (option of relockOptions(); track option) {
|
||||
<mat-option [value]="option">
|
||||
{{
|
||||
relockLabelKey(option)
|
||||
| translate: { minutes: option }
|
||||
}}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" data-setting-id="parental-lock-how-to">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.PARENTAL_LOCK.HOW_TO_LOCK' | translate }}</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.PARENTAL_LOCK.HOW_TO_LOCK_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div class="setting-item" data-setting-id="parental-lock-change-pin">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.PARENTAL_LOCK.CHANGE_PIN' | translate }}</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.PARENTAL_LOCK.CHANGE_PIN_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
data-test-id="parental-lock-change-pin"
|
||||
[disabled]="busy() || !hasPin()"
|
||||
(click)="changePin.emit()"
|
||||
>
|
||||
<mat-icon>password</mat-icon>
|
||||
{{ 'SETTINGS.PARENTAL_LOCK.CHANGE_PIN' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-item__meta">
|
||||
<p>{{ 'SETTINGS.PARENTAL_LOCK.DISCLAIMER' | translate }}</p>
|
||||
<div class="setting-item" data-setting-id="parental-lock-how-to">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.PARENTAL_LOCK.HOW_TO_LOCK' | translate }}</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.PARENTAL_LOCK.HOW_TO_LOCK_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="settings-foot">
|
||||
<span>{{ 'SETTINGS.PARENTAL_LOCK.DISCLAIMER' | translate }}</span>
|
||||
</div>
|
||||
</section>
|
||||
@@ -74,6 +74,7 @@ function createForm(player: VideoPlayer): FormGroup {
|
||||
webPlayerSharedControls: new FormControl(false),
|
||||
playerAmbientMode: new FormControl(false),
|
||||
detailTrailerBackdrop: new FormControl(false),
|
||||
showCaptions: new FormControl(false),
|
||||
playerUpNextRail: new FormControl(true),
|
||||
playerUpNextCard: new FormControl(true),
|
||||
fullscreenChannelPanel: new FormControl(true),
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -167,31 +167,31 @@ describe('SettingsPlaybackSectionComponent', () => {
|
||||
}
|
||||
);
|
||||
|
||||
it('binds the shared web controls checkbox to the settings form', () => {
|
||||
it('binds the shared web controls switch to the settings form', () => {
|
||||
const form = createForm();
|
||||
form.controls['webPlayerSharedControls'].setValue(true);
|
||||
fixture.componentRef.setInput('form', form);
|
||||
fixture.detectChanges();
|
||||
|
||||
const checkbox = (
|
||||
const toggle = (
|
||||
fixture.nativeElement as HTMLElement
|
||||
).querySelector<HTMLInputElement>(
|
||||
'[data-test-id="web-player-shared-controls-toggle"] input[type="checkbox"]'
|
||||
).querySelector<HTMLButtonElement>(
|
||||
'[data-test-id="web-player-shared-controls-toggle"] button[role="switch"]'
|
||||
);
|
||||
|
||||
expect(checkbox?.checked).toBe(true);
|
||||
expect(toggle?.getAttribute('aria-checked')).toBe('true');
|
||||
});
|
||||
|
||||
it('labels the rendered native shared web controls checkbox', () => {
|
||||
it('labels the rendered shared web controls switch', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
const checkbox = (
|
||||
const toggle = (
|
||||
fixture.nativeElement as HTMLElement
|
||||
).querySelector<HTMLInputElement>(
|
||||
'[data-test-id="web-player-shared-controls-toggle"] input[type="checkbox"]'
|
||||
).querySelector<HTMLButtonElement>(
|
||||
'[data-test-id="web-player-shared-controls-toggle"] button[role="switch"]'
|
||||
);
|
||||
|
||||
expect(checkbox?.getAttribute('aria-label')).toBe(
|
||||
expect(toggle?.getAttribute('aria-label')).toBe(
|
||||
WEB_PLAYER_SHARED_CONTROLS_LABEL
|
||||
);
|
||||
});
|
||||
@@ -458,6 +458,7 @@ function createForm(player = VideoPlayer.VideoJs): FormGroup {
|
||||
webPlayerSharedControls: new FormControl(false),
|
||||
playerAmbientMode: new FormControl(false),
|
||||
detailTrailerBackdrop: new FormControl(false),
|
||||
showCaptions: new FormControl(false),
|
||||
playerUpNextRail: new FormControl(true),
|
||||
playerUpNextCard: new FormControl(true),
|
||||
fullscreenChannelPanel: new FormControl(true),
|
||||
|
||||
@@ -8,11 +8,12 @@ import {
|
||||
} 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 { MatSelectModule } from '@angular/material/select';
|
||||
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import {
|
||||
StreamFormat,
|
||||
@@ -27,11 +28,12 @@ import { markSectionForCheckOnFormEvents } from './settings-section-form-render'
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatButtonModule,
|
||||
MatCheckboxModule,
|
||||
MatFormFieldModule,
|
||||
MatIconModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
MatSlideToggleModule,
|
||||
MatTooltipModule,
|
||||
ReactiveFormsModule,
|
||||
TranslateModule,
|
||||
],
|
||||
@@ -114,4 +116,8 @@ export class SettingsPlaybackSectionComponent {
|
||||
const player = this.form().value.player;
|
||||
return player === VideoPlayer.MPV || player === VideoPlayer.VLC;
|
||||
}
|
||||
|
||||
isEmbeddedMpvSelected(): boolean {
|
||||
return this.form().value.player === VideoPlayer.EmbeddedMpv;
|
||||
}
|
||||
}
|
||||
@@ -1,134 +1,153 @@
|
||||
<section class="settings-group" 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>
|
||||
<h2>{{ 'SETTINGS.REMOTE_CONTROL' | translate }}</h2>
|
||||
<p>{{ 'SETTINGS.REMOTE_CONTROL_DESCRIPTION' | translate }}</p>
|
||||
</header>
|
||||
|
||||
<div class="setting-item" data-setting-id="remote-control">
|
||||
<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" data-setting-id="remote-control-port">
|
||||
<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" data-setting-id="remote-control-url">
|
||||
<section class="settings-section">
|
||||
<div class="settings-card">
|
||||
<div class="setting-item" data-setting-id="remote-control">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.REMOTE_CONTROL_URL' | translate }}</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.REMOTE_CONTROL_URL_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
<h4 id="remote-control-label">
|
||||
{{ 'SETTINGS.REMOTE_CONTROL' | translate }}
|
||||
</h4>
|
||||
<p>{{ 'SETTINGS.REMOTE_CONTROL_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 class="setting-item__control setting-item__toggle">
|
||||
<mat-slide-toggle
|
||||
formControlName="remoteControl"
|
||||
aria-labelledby="remote-control-label"
|
||||
data-test-id="remote-control-enabled"
|
||||
></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (form().value.remoteControl === true) {
|
||||
<div class="setting-item" data-setting-id="remote-control-port">
|
||||
<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
|
||||
class="settings-select"
|
||||
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" data-setting-id="remote-control-url">
|
||||
<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
|
||||
"
|
||||
[attr.aria-label]="
|
||||
'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>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
@@ -0,0 +1,45 @@
|
||||
// Remote control page: the address list with QR codes.
|
||||
app-settings-remote-control-section {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
// ─── Remote control ────────────────────────────────────────────────────────
|
||||
.remote-control-urls {
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.url-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.remote-control-url {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12.5px;
|
||||
color: var(--app-heading-color);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.qr-code-container {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
// Pane width, see settings.component.scss.
|
||||
@container (max-width: 600px) {
|
||||
.remote-control-urls,
|
||||
.qr-code-container,
|
||||
.url-row {
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,10 @@ import {
|
||||
} 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 { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { QRCodeComponent } from 'angularx-qrcode';
|
||||
@@ -20,10 +20,10 @@ import { markSectionForCheckOnFormEvents } from './settings-section-form-render'
|
||||
selector: 'app-settings-remote-control-section',
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
MatCheckboxModule,
|
||||
MatFormFieldModule,
|
||||
MatIconModule,
|
||||
MatInputModule,
|
||||
MatSlideToggleModule,
|
||||
MatTooltipModule,
|
||||
QRCodeComponent,
|
||||
ReactiveFormsModule,
|
||||
@@ -32,7 +32,7 @@ import { markSectionForCheckOnFormEvents } from './settings-section-form-render'
|
||||
templateUrl: './settings-remote-control-section.component.html',
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styles: [':host { display: contents; }'],
|
||||
styleUrls: ['./settings-remote-control-section.component.scss'],
|
||||
})
|
||||
export class SettingsRemoteControlSectionComponent {
|
||||
readonly form = input.required<FormGroup>();
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
<section class="settings-group" 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"
|
||||
data-setting-id="remove-all-playlists"
|
||||
>
|
||||
<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 app-destructive-button"
|
||||
[class.danger-zone__button--busy]="isRemovingAllPlaylists()"
|
||||
[disabled]="!canRemoveAllPlaylists()"
|
||||
[attr.aria-busy]="isRemovingAllPlaylists()"
|
||||
(click)="
|
||||
$event.preventDefault();
|
||||
$event.stopPropagation();
|
||||
removeAll.emit()
|
||||
"
|
||||
>
|
||||
@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">{{
|
||||
sourceIcons.m3u
|
||||
}}</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">{{
|
||||
sourceIcons.xtream
|
||||
}}</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">{{
|
||||
sourceIcons.stalker
|
||||
}}</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>
|
||||
@@ -1,37 +0,0 @@
|
||||
import {
|
||||
Component,
|
||||
input,
|
||||
output,
|
||||
ViewEncapsulation,
|
||||
ChangeDetectionStrategy,
|
||||
} 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 { SOURCE_TYPE_ICONS } from '@iptvnator/shared/interfaces';
|
||||
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,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styles: [':host { display: contents; }'],
|
||||
})
|
||||
export class SettingsResetSectionComponent {
|
||||
readonly sourceIcons = SOURCE_TYPE_ICONS;
|
||||
readonly isRemovingAllPlaylists = input(false);
|
||||
readonly canRemoveAllPlaylists = input(false);
|
||||
readonly playlistDeleteSummary =
|
||||
input.required<SettingsPlaylistDeleteSummary>();
|
||||
readonly removeAllProgressLabel = input<string | null>(null);
|
||||
|
||||
readonly removeAll = output<void>();
|
||||
}
|
||||
@@ -21,6 +21,11 @@ const EN = JSON.parse(
|
||||
readFileSync(join(SETTINGS_DIR, '../../assets/i18n/en.json'), 'utf8')
|
||||
);
|
||||
|
||||
/**
|
||||
* Reveal anchors: any element carrying `data-setting-id` (rows, but also
|
||||
* the About cards and footer), plus every `setting-item` row so a titled
|
||||
* row without an anchor is caught too.
|
||||
*/
|
||||
function readTemplateRows(): TemplateRow[] {
|
||||
const rows: TemplateRow[] = [];
|
||||
const files = readdirSync(SETTINGS_DIR).filter((file) =>
|
||||
@@ -31,7 +36,9 @@ function readTemplateRows(): TemplateRow[] {
|
||||
const html = readFileSync(join(SETTINGS_DIR, file), 'utf8');
|
||||
// Row containers only: `setting-item` itself, not `__meta` etc.
|
||||
const starts = [
|
||||
...html.matchAll(/<div\b[^>]*class="setting-item(?:\s[^"]*)?"/g),
|
||||
...html.matchAll(
|
||||
/<\w+\b(?:[^>]*class="setting-item(?:\s[^"]*)?"|[^>]*data-setting-id=")/g
|
||||
),
|
||||
];
|
||||
starts.forEach((match, index) => {
|
||||
const end = starts[index + 1]?.index ?? html.length;
|
||||
@@ -43,11 +50,11 @@ function readTemplateRows(): TemplateRow[] {
|
||||
id: tag.match(/data-setting-id="([^"]+)"/)?.[1] ?? null,
|
||||
labelKey:
|
||||
chunk.match(
|
||||
/<h4[^>]*>\s*\{\{\s*'(SETTINGS\.[A-Z0-9_]+)'/
|
||||
/<h4[^>]*>\s*\{\{\s*'(SETTINGS\.[A-Z0-9_.]+)'/
|
||||
)?.[1] ?? null,
|
||||
descriptionKey:
|
||||
meta.match(
|
||||
/<p[^>]*>\s*\{\{\s*'(SETTINGS\.[A-Z0-9_]+)'/
|
||||
/<p[^>]*>\s*\{\{\s*'(SETTINGS\.[A-Z0-9_.]+)'/
|
||||
)?.[1] ?? null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,21 +4,19 @@
|
||||
aria-labelledby="settings-search-results-title"
|
||||
>
|
||||
<header class="settings-group__header">
|
||||
<div class="settings-group__header-text">
|
||||
<h3 id="settings-search-results-title">
|
||||
{{ 'SETTINGS.SEARCH_RESULTS_TITLE' | translate }}
|
||||
</h3>
|
||||
<p aria-live="polite" data-test-id="settings-search-summary">
|
||||
{{
|
||||
'SETTINGS.SEARCH_RESULTS_COUNT'
|
||||
| translate: { count: results().length, query: query() }
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<h2 id="settings-search-results-title">
|
||||
{{ 'SETTINGS.SEARCH_RESULTS_TITLE' | translate }}
|
||||
</h2>
|
||||
<p aria-live="polite" data-test-id="settings-search-summary">
|
||||
{{
|
||||
'SETTINGS.SEARCH_RESULTS_COUNT'
|
||||
| translate: { count: results().length, query: query() }
|
||||
}}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
@if (results().length > 0) {
|
||||
<ul class="settings-search-results__list">
|
||||
<ul class="settings-search-results__list settings-card">
|
||||
@for (result of results(); track result.entry.id) {
|
||||
<li>
|
||||
<button
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
gap: 14px;
|
||||
width: 100%;
|
||||
min-height: 56px;
|
||||
padding: 12px 10px;
|
||||
padding: 12px 16px;
|
||||
border: 0;
|
||||
border-top: 1px solid var(--settings-item-border);
|
||||
border-radius: 0;
|
||||
@@ -37,6 +37,11 @@
|
||||
|
||||
li:first-child .settings-search-result {
|
||||
border-top: 0;
|
||||
border-radius: 12px 12px 0 0;
|
||||
}
|
||||
|
||||
li:last-child .settings-search-result {
|
||||
border-radius: 0 0 12px 12px;
|
||||
}
|
||||
|
||||
.settings-search-result__icon,
|
||||
@@ -54,16 +59,15 @@ li:first-child .settings-search-result {
|
||||
}
|
||||
|
||||
.settings-search-result__label {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
font-size: 13.5px;
|
||||
font-weight: 500;
|
||||
color: var(--app-heading-color);
|
||||
}
|
||||
|
||||
.settings-search-result__description {
|
||||
overflow: hidden;
|
||||
color: var(--settings-muted);
|
||||
font-size: 0.84rem;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.45;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -71,11 +75,12 @@ li:first-child .settings-search-result {
|
||||
|
||||
.settings-search-result__section {
|
||||
flex-shrink: 0;
|
||||
padding: 2px 10px;
|
||||
border: 1px solid var(--settings-item-border);
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
color: var(--settings-muted);
|
||||
font-size: 0.78rem;
|
||||
background: var(--settings-chip-bg);
|
||||
color: var(--settings-chip-fg);
|
||||
font-size: 10.5px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,176 +1,177 @@
|
||||
<section class="settings-group" id="tmdb" [formGroup]="form()">
|
||||
<header class="settings-group__header">
|
||||
<div class="settings-group__header-icon">
|
||||
<mat-icon>movie</mat-icon>
|
||||
</div>
|
||||
<div class="settings-group__header-text">
|
||||
<h3>{{ 'SETTINGS.TMDB' | translate }}</h3>
|
||||
<p>{{ 'SETTINGS.TMDB_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
<h2>{{ 'SETTINGS.TMDB' | translate }}</h2>
|
||||
<p>{{ 'SETTINGS.TMDB_DESCRIPTION' | translate }}</p>
|
||||
</header>
|
||||
|
||||
<div formGroupName="tmdb">
|
||||
<div class="setting-item" data-setting-id="tmdb-enable">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.TMDB_ENABLE' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.TMDB_ENABLE_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox
|
||||
formControlName="enabled"
|
||||
data-test-id="tmdb-enabled"
|
||||
></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (form().value.tmdb?.enabled === true) {
|
||||
<div class="setting-item" data-setting-id="tmdb-api-key">
|
||||
<section class="settings-section">
|
||||
<div class="settings-card" formGroupName="tmdb">
|
||||
<div class="setting-item" data-setting-id="tmdb-enable">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.TMDB_API_KEY' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.TMDB_API_KEY_DESCRIPTION' | translate }}</p>
|
||||
<p>
|
||||
<a
|
||||
href="https://www.themoviedb.org/settings/api"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
data-test-id="tmdb-get-key-link"
|
||||
<h4 id="tmdb-enable-label">
|
||||
{{ 'SETTINGS.TMDB_ENABLE' | translate }}
|
||||
</h4>
|
||||
<p>{{ 'SETTINGS.TMDB_ENABLE_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-slide-toggle
|
||||
formControlName="enabled"
|
||||
aria-labelledby="tmdb-enable-label"
|
||||
data-test-id="tmdb-enabled"
|
||||
></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (form().value.tmdb?.enabled === true) {
|
||||
<div class="setting-item" data-setting-id="tmdb-api-key">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.TMDB_API_KEY' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.TMDB_API_KEY_DESCRIPTION' | translate }}</p>
|
||||
<p>
|
||||
<a
|
||||
href="https://www.themoviedb.org/settings/api"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
data-test-id="tmdb-get-key-link"
|
||||
>
|
||||
{{ 'SETTINGS.TMDB_GET_KEY_HINT' | translate }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__control--stack">
|
||||
<mat-form-field
|
||||
class="settings-text-field"
|
||||
appearance="outline"
|
||||
subscriptSizing="dynamic"
|
||||
>
|
||||
{{ 'SETTINGS.TMDB_GET_KEY_HINT' | translate }}
|
||||
</a>
|
||||
<mat-label>{{
|
||||
'SETTINGS.TMDB_API_KEY' | translate
|
||||
}}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
formControlName="apiKey"
|
||||
data-test-id="tmdb-api-key"
|
||||
/>
|
||||
</mat-form-field>
|
||||
<div class="tmdb-key-test">
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
[disabled]="
|
||||
!enteredApiKey || keyTestState() === 'testing'
|
||||
"
|
||||
(click)="testApiKey()"
|
||||
data-test-id="tmdb-test-key"
|
||||
>
|
||||
@if (keyTestState() === 'testing') {
|
||||
<mat-spinner diameter="16" />
|
||||
} @else {
|
||||
<mat-icon>network_check</mat-icon>
|
||||
}
|
||||
{{ 'SETTINGS.TMDB_TEST_BUTTON' | translate }}
|
||||
</button>
|
||||
@if (keyTestState() === 'success') {
|
||||
<span
|
||||
class="tmdb-key-test__result tmdb-key-test__result--success"
|
||||
data-test-id="tmdb-test-success"
|
||||
>
|
||||
<mat-icon>check_circle</mat-icon>
|
||||
{{ 'SETTINGS.TMDB_TEST_SUCCESS' | translate }}
|
||||
</span>
|
||||
} @else if (keyTestState() === 'error') {
|
||||
<span
|
||||
class="tmdb-key-test__result tmdb-key-test__result--error"
|
||||
data-test-id="tmdb-test-error"
|
||||
>
|
||||
<mat-icon>error</mat-icon>
|
||||
{{ 'SETTINGS.TMDB_TEST_FAILED' | translate }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (m3uVodDetailsControl; as m3uVodDetails) {
|
||||
<div
|
||||
class="setting-item"
|
||||
data-setting-id="tmdb-m3u-vod-details"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<h4 id="tmdb-m3u-vod-details-label">
|
||||
{{ 'SETTINGS.TMDB_M3U_VOD_DETAILS' | translate }}
|
||||
</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.TMDB_M3U_VOD_DETAILS_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-slide-toggle
|
||||
[formControl]="m3uVodDetails"
|
||||
aria-labelledby="tmdb-m3u-vod-details-label"
|
||||
data-test-id="tmdb-m3u-vod-details"
|
||||
></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
<div class="settings-section__header">
|
||||
<h3>{{ 'SETTINGS.CARD_CACHE' | translate }}</h3>
|
||||
</div>
|
||||
<div class="settings-card">
|
||||
<div class="setting-item" data-setting-id="tmdb-cache">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.TMDB_CACHE' | translate }}</h4>
|
||||
<p>
|
||||
{{ 'SETTINGS.TMDB_CACHE_DESCRIPTION' | translate }}
|
||||
</p>
|
||||
<p class="tmdb-cache__size" data-test-id="tmdb-cache-size">
|
||||
@if (cacheError()) {
|
||||
<span class="tmdb-cache__error">
|
||||
{{ 'SETTINGS.TMDB_CACHE_ERROR' | translate }}
|
||||
</span>
|
||||
} @else if (cacheStats(); as stats) {
|
||||
{{
|
||||
'SETTINGS.TMDB_CACHE_SIZE'
|
||||
| translate
|
||||
: {
|
||||
entries: stats.entries,
|
||||
size: formatBytes(stats.bytes),
|
||||
}
|
||||
}}
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<mat-form-field
|
||||
appearance="outline"
|
||||
subscriptSizing="dynamic"
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
[disabled]="isClearing() || cacheStats()?.entries === 0"
|
||||
(click)="clearCache()"
|
||||
data-test-id="tmdb-clear-cache"
|
||||
>
|
||||
<mat-label>{{
|
||||
'SETTINGS.TMDB_API_KEY' | translate
|
||||
}}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
formControlName="apiKey"
|
||||
data-test-id="tmdb-api-key"
|
||||
/>
|
||||
</mat-form-field>
|
||||
<div class="tmdb-key-test">
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
[disabled]="
|
||||
!enteredApiKey || keyTestState() === 'testing'
|
||||
"
|
||||
(click)="testApiKey()"
|
||||
data-test-id="tmdb-test-key"
|
||||
>
|
||||
@if (keyTestState() === 'testing') {
|
||||
<mat-spinner diameter="16" />
|
||||
} @else {
|
||||
<mat-icon>network_check</mat-icon>
|
||||
}
|
||||
{{ 'SETTINGS.TMDB_TEST_BUTTON' | translate }}
|
||||
</button>
|
||||
@if (keyTestState() === 'success') {
|
||||
<span
|
||||
class="tmdb-key-test__result tmdb-key-test__result--success"
|
||||
data-test-id="tmdb-test-success"
|
||||
>
|
||||
<mat-icon>check_circle</mat-icon>
|
||||
{{ 'SETTINGS.TMDB_TEST_SUCCESS' | translate }}
|
||||
</span>
|
||||
} @else if (keyTestState() === 'error') {
|
||||
<span
|
||||
class="tmdb-key-test__result tmdb-key-test__result--error"
|
||||
data-test-id="tmdb-test-error"
|
||||
>
|
||||
<mat-icon>error</mat-icon>
|
||||
{{ 'SETTINGS.TMDB_TEST_FAILED' | translate }}
|
||||
</span>
|
||||
@if (isClearing()) {
|
||||
<mat-spinner diameter="16" />
|
||||
} @else {
|
||||
<mat-icon>delete_sweep</mat-icon>
|
||||
}
|
||||
</div>
|
||||
{{ 'SETTINGS.TMDB_CACHE_CLEAR' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (form().value.tmdb?.enabled === true) {
|
||||
@if (m3uVodDetailsControl; as m3uVodDetails) {
|
||||
<div
|
||||
class="setting-item"
|
||||
data-setting-id="tmdb-m3u-vod-details"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<h4>
|
||||
{{ 'SETTINGS.TMDB_M3U_VOD_DETAILS' | translate }}
|
||||
</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.TMDB_M3U_VOD_DETAILS_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox
|
||||
[formControl]="m3uVodDetails"
|
||||
data-test-id="tmdb-m3u-vod-details"
|
||||
></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<div class="setting-item" data-setting-id="tmdb-cache">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.TMDB_CACHE' | translate }}</h4>
|
||||
<p>
|
||||
{{ 'SETTINGS.TMDB_CACHE_DESCRIPTION' | translate }}
|
||||
</p>
|
||||
<p class="tmdb-cache__size" data-test-id="tmdb-cache-size">
|
||||
@if (cacheError()) {
|
||||
<span class="tmdb-cache__error">
|
||||
{{ 'SETTINGS.TMDB_CACHE_ERROR' | translate }}
|
||||
</span>
|
||||
} @else if (cacheStats(); as stats) {
|
||||
{{
|
||||
'SETTINGS.TMDB_CACHE_SIZE'
|
||||
| translate
|
||||
: {
|
||||
entries: stats.entries,
|
||||
size: formatBytes(stats.bytes),
|
||||
}
|
||||
}}
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
[disabled]="isClearing() || cacheStats()?.entries === 0"
|
||||
(click)="clearCache()"
|
||||
data-test-id="tmdb-clear-cache"
|
||||
>
|
||||
@if (isClearing()) {
|
||||
<mat-spinner diameter="16" />
|
||||
} @else {
|
||||
<mat-icon>delete_sweep</mat-icon>
|
||||
}
|
||||
{{ 'SETTINGS.TMDB_CACHE_CLEAR' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="setting-item setting-item--full-control">
|
||||
<div class="setting-item__meta">
|
||||
<img
|
||||
src="assets/images/tmdb-logo.svg"
|
||||
alt="TMDB"
|
||||
style="height: 20px; margin-bottom: 8px"
|
||||
/>
|
||||
<p>{{ 'SETTINGS.TMDB_ATTRIBUTION' | translate }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-foot">
|
||||
<img src="assets/images/tmdb-logo.svg" alt="TMDB" />
|
||||
<span>{{ 'SETTINGS.TMDB_ATTRIBUTION' | translate }}</span>
|
||||
</div>
|
||||
</section>
|
||||
@@ -93,7 +93,9 @@ describe('SettingsTmdbSectionComponent', () => {
|
||||
const checkbox = queryByTestId('tmdb-m3u-vod-details');
|
||||
expect(checkbox).not.toBeNull();
|
||||
|
||||
(checkbox?.querySelector('input') as HTMLInputElement).click();
|
||||
(
|
||||
checkbox?.querySelector('button[role="switch"]') as HTMLButtonElement
|
||||
).click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.m3uVodDetailsControl?.value).toBe(
|
||||
|
||||
@@ -8,11 +8,11 @@ import {
|
||||
} from '@angular/core';
|
||||
import { FormControl, 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 { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { TmdbApiService, TmdbCacheService } from '@iptvnator/services';
|
||||
import type { TmdbCacheStats } from '@iptvnator/shared/interfaces';
|
||||
@@ -24,11 +24,11 @@ type TmdbKeyTestState = 'idle' | 'testing' | 'success' | 'error';
|
||||
selector: 'app-settings-tmdb-section',
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
MatCheckboxModule,
|
||||
MatFormFieldModule,
|
||||
MatIconModule,
|
||||
MatInputModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatSlideToggleModule,
|
||||
ReactiveFormsModule,
|
||||
TranslateModule,
|
||||
],
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
VideoPlayer,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { SettingsContextService } from '@iptvnator/workspace/shell/util/settings-context';
|
||||
import { SettingsStore } from '../services/settings-store.service';
|
||||
import { SettingsComponent } from './settings.component';
|
||||
import {
|
||||
@@ -62,10 +63,10 @@ describe('SettingsComponent form', () => {
|
||||
|
||||
it('stages the desktop portal pause setting until Save', async () => {
|
||||
const checkbox = (fixture.nativeElement as HTMLElement).querySelector(
|
||||
'[data-test-id="portal-connectivity-toggle"] input'
|
||||
) as HTMLInputElement;
|
||||
'[data-test-id="portal-connectivity-toggle"] button[role="switch"]'
|
||||
) as HTMLButtonElement;
|
||||
expect(checkbox).not.toBeNull();
|
||||
expect(checkbox.checked).toBe(true);
|
||||
expect(checkbox.getAttribute('aria-checked')).toBe('true');
|
||||
checkbox.click();
|
||||
fixture.detectChanges();
|
||||
expect(
|
||||
@@ -74,7 +75,7 @@ describe('SettingsComponent form', () => {
|
||||
expect(window.electron.updateSettings).not.toHaveBeenCalled();
|
||||
component.form.hydrateFromStore();
|
||||
fixture.detectChanges();
|
||||
expect(checkbox.checked).toBe(true);
|
||||
expect(checkbox.getAttribute('aria-checked')).toBe('true');
|
||||
checkbox.click();
|
||||
await component.form.save(() => undefined);
|
||||
expect(window.electron.updateSettings).toHaveBeenCalledWith(
|
||||
@@ -84,10 +85,10 @@ describe('SettingsComponent form', () => {
|
||||
|
||||
it('stages the posters-only cover wall until Save', async () => {
|
||||
const checkbox = (fixture.nativeElement as HTMLElement).querySelector(
|
||||
'[data-test-id="cover-titles-toggle"] input'
|
||||
) as HTMLInputElement;
|
||||
'[data-test-id="cover-titles-toggle"] button[role="switch"]'
|
||||
) as HTMLButtonElement;
|
||||
expect(checkbox).not.toBeNull();
|
||||
expect(checkbox.checked).toBe(true);
|
||||
expect(checkbox.getAttribute('aria-checked')).toBe('true');
|
||||
|
||||
checkbox.click();
|
||||
fixture.detectChanges();
|
||||
@@ -377,6 +378,244 @@ describe('SettingsComponent form', () => {
|
||||
expect(unsavedBar()).toBeNull();
|
||||
});
|
||||
|
||||
it('counts the staged changes and marks their pages in the navigation', () => {
|
||||
const settingsCtx = TestBed.inject(SettingsContextService);
|
||||
const message = () =>
|
||||
(fixture.nativeElement as HTMLElement).querySelector(
|
||||
'[data-test-id="settings-unsaved-message"]'
|
||||
)?.textContent;
|
||||
|
||||
component.settingsForm.get('theme')?.setValue(Theme.DarkTheme);
|
||||
component.settingsForm.markAsDirty();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.changeCount()).toBe(1);
|
||||
expect(component.unsavedMessageKey()).toBe(
|
||||
'SETTINGS.UNSAVED_CHANGES_ONE'
|
||||
);
|
||||
expect(settingsCtx.dirtySections()).toEqual(new Set(['general']));
|
||||
|
||||
component.settingsForm
|
||||
.get('dashboardRails.hero')
|
||||
?.setValue(!DEFAULT_DASHBOARD_RAILS.hero);
|
||||
component.settingsForm.get('showCaptions')?.setValue(true);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.changeCount()).toBe(3);
|
||||
expect(component.unsavedMessageKey()).toBe(
|
||||
'SETTINGS.UNSAVED_CHANGES_COUNT'
|
||||
);
|
||||
expect(message()).toContain('SETTINGS.UNSAVED_CHANGES_COUNT');
|
||||
expect(settingsCtx.dirtySections()).toEqual(
|
||||
new Set(['general', 'dashboard', 'playback'])
|
||||
);
|
||||
|
||||
// Reverting by hand leaves the form dirty but counts nothing.
|
||||
component.settingsForm.get('theme')?.setValue(Theme.SystemTheme);
|
||||
component.settingsForm
|
||||
.get('dashboardRails.hero')
|
||||
?.setValue(DEFAULT_DASHBOARD_RAILS.hero);
|
||||
component.settingsForm.get('showCaptions')?.setValue(false);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.changeCount()).toBe(0);
|
||||
expect(component.unsavedMessageKey()).toBe(
|
||||
'SETTINGS.UNSAVED_CHANGES'
|
||||
);
|
||||
expect(settingsCtx.dirtySections().size).toBe(0);
|
||||
expect(unsavedBar()).not.toBeNull();
|
||||
});
|
||||
|
||||
it('saves on Ctrl/Cmd+S while valid and dirty, and swallows the browser save otherwise', async () => {
|
||||
settingsStore.updateSettings.mockResolvedValue(undefined);
|
||||
const press = (init: KeyboardEventInit) => {
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key: 's',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
...init,
|
||||
});
|
||||
document.dispatchEvent(event);
|
||||
return event;
|
||||
};
|
||||
|
||||
// Nothing staged: the page still keeps the browser dialog away.
|
||||
expect(press({ ctrlKey: true }).defaultPrevented).toBe(true);
|
||||
expect(settingsStore.updateSettings).not.toHaveBeenCalled();
|
||||
|
||||
component.settingsForm.get('theme')?.setValue(Theme.DarkTheme);
|
||||
component.settingsForm.markAsDirty();
|
||||
fixture.detectChanges();
|
||||
|
||||
// A plain "s" or a chord with Shift is not the shortcut.
|
||||
expect(press({}).defaultPrevented).toBe(false);
|
||||
expect(
|
||||
press({ metaKey: true, shiftKey: true }).defaultPrevented
|
||||
).toBe(false);
|
||||
expect(settingsStore.updateSettings).not.toHaveBeenCalled();
|
||||
|
||||
// The phone drawer makes the page inert: the chord is not ours.
|
||||
const host = fixture.nativeElement as HTMLElement;
|
||||
host.setAttribute('inert', '');
|
||||
expect(press({ metaKey: true }).defaultPrevented).toBe(false);
|
||||
expect(settingsStore.updateSettings).not.toHaveBeenCalled();
|
||||
host.removeAttribute('inert');
|
||||
|
||||
// Nor while a dialog (the leave dialog) or a select panel is open.
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className = 'cdk-overlay-backdrop';
|
||||
document.body.appendChild(backdrop);
|
||||
expect(press({ metaKey: true }).defaultPrevented).toBe(false);
|
||||
expect(settingsStore.updateSettings).not.toHaveBeenCalled();
|
||||
backdrop.remove();
|
||||
|
||||
expect(press({ metaKey: true }).defaultPrevented).toBe(true);
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(settingsStore.updateSettings).toHaveBeenCalledTimes(1);
|
||||
expect(component.settingsForm.pristine).toBe(true);
|
||||
});
|
||||
|
||||
it('notices a saved change that waits for a restart until dismissed', async () => {
|
||||
settingsStore.updateSettings.mockResolvedValue(undefined);
|
||||
const notice = () =>
|
||||
(fixture.nativeElement as HTMLElement).querySelector(
|
||||
'[data-test-id="settings-restart-notice"]'
|
||||
);
|
||||
|
||||
component.settingsForm.get('theme')?.setValue(Theme.DarkTheme);
|
||||
component.settingsForm.markAsDirty();
|
||||
component.onSubmit();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
// A live change says nothing about restarting.
|
||||
expect(notice()).toBeNull();
|
||||
|
||||
component.settingsForm
|
||||
.get('startupWindowMode')
|
||||
?.setValue('maximized');
|
||||
component.settingsForm.markAsDirty();
|
||||
component.onSubmit();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.form.restartPendingControls()).toEqual([
|
||||
'startupWindowMode',
|
||||
]);
|
||||
expect(notice()).not.toBeNull();
|
||||
|
||||
// Saving the launch value back withdraws the notice: nothing
|
||||
// waits for a restart any more.
|
||||
component.settingsForm.get('startupWindowMode')?.setValue('normal');
|
||||
component.settingsForm.markAsDirty();
|
||||
component.onSubmit();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.form.restartPendingControls()).toEqual([]);
|
||||
expect(notice()).toBeNull();
|
||||
|
||||
component.settingsForm
|
||||
.get('startupWindowMode')
|
||||
?.setValue('fullscreen');
|
||||
component.settingsForm.markAsDirty();
|
||||
component.onSubmit();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
expect(notice()).not.toBeNull();
|
||||
|
||||
(
|
||||
notice()?.querySelector(
|
||||
'[data-test-id="settings-restart-later"]'
|
||||
) as HTMLButtonElement
|
||||
).click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(notice()).toBeNull();
|
||||
|
||||
// Later holds: an unrelated save does not bring the reminder back.
|
||||
component.settingsForm.get('theme')?.setValue(Theme.LightTheme);
|
||||
component.settingsForm.markAsDirty();
|
||||
component.onSubmit();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
expect(notice()).toBeNull();
|
||||
|
||||
// A different restart value is news again.
|
||||
component.settingsForm
|
||||
.get('startupWindowMode')
|
||||
?.setValue('maximized');
|
||||
component.settingsForm.markAsDirty();
|
||||
component.onSubmit();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
expect(notice()).not.toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the launch values across settings visits', async () => {
|
||||
settingsStore.updateSettings.mockResolvedValue(undefined);
|
||||
settingsStore.updateSettings.mockImplementation((settings) => {
|
||||
settingsStore._setSettings(settings);
|
||||
return Promise.resolve(undefined);
|
||||
});
|
||||
component.settingsForm
|
||||
.get('startupWindowMode')
|
||||
?.setValue('maximized');
|
||||
component.settingsForm.markAsDirty();
|
||||
component.onSubmit();
|
||||
await fixture.whenStable();
|
||||
expect(component.form.restartPendingControls()).toEqual([
|
||||
'startupWindowMode',
|
||||
]);
|
||||
|
||||
// Leave settings and come back: the page and its form are new,
|
||||
// but the app still runs with the launch value.
|
||||
fixture.destroy();
|
||||
fixture = TestBed.createComponent(SettingsComponent);
|
||||
component = fixture.componentInstance;
|
||||
stubSettingsSideEffects(component);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
expect(component.form.restartPendingControls()).toEqual([
|
||||
'startupWindowMode',
|
||||
]);
|
||||
|
||||
// Saving the launch value back withdraws the notice here too.
|
||||
component.settingsForm.get('startupWindowMode')?.setValue('normal');
|
||||
component.settingsForm.markAsDirty();
|
||||
component.onSubmit();
|
||||
await fixture.whenStable();
|
||||
expect(component.form.restartPendingControls()).toEqual([]);
|
||||
});
|
||||
|
||||
it('compares the frame-copy opt-in against the engine the app runs', async () => {
|
||||
settingsStore.updateSettings.mockResolvedValue(undefined);
|
||||
// Stored true, but the engine could not honour it at launch.
|
||||
component.form.setRunningValue('embeddedMpvFrameCopy', false);
|
||||
component.settingsForm.get('embeddedMpvFrameCopy')?.setValue(true);
|
||||
component.settingsForm.markAsDirty();
|
||||
component.onSubmit();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(component.form.restartPendingControls()).toEqual([
|
||||
'embeddedMpvFrameCopy',
|
||||
]);
|
||||
|
||||
// A staged, unsaved edit back to the launch value changes nothing:
|
||||
// the saved setting still needs the restart.
|
||||
component.settingsForm.get('embeddedMpvFrameCopy')?.setValue(false);
|
||||
component.form.setRunningValue('embeddedMpvFrameCopy', false);
|
||||
expect(component.form.restartPendingControls()).toEqual([
|
||||
'embeddedMpvFrameCopy',
|
||||
]);
|
||||
|
||||
// The engine catches up (a later probe reports frame copy): the
|
||||
// notice goes without another save.
|
||||
component.form.setRunningValue('embeddedMpvFrameCopy', true);
|
||||
expect(component.form.restartPendingControls()).toEqual([]);
|
||||
});
|
||||
|
||||
it('discard reverts a staged cover size (regression: eager persist made it stick)', () => {
|
||||
const largeCoverButton = (
|
||||
fixture.nativeElement as HTMLElement
|
||||
|
||||
@@ -12,6 +12,30 @@
|
||||
{{ 'SETTINGS.TITLE' | translate }}
|
||||
</span>
|
||||
|
||||
<!-- A saved change that only applies after a restart: said once,
|
||||
at the top, until dismissed. -->
|
||||
@if (form.restartPendingControls().length > 0) {
|
||||
<div
|
||||
class="settings-restart-bar"
|
||||
role="status"
|
||||
data-test-id="settings-restart-notice"
|
||||
>
|
||||
<mat-icon>restart_alt</mat-icon>
|
||||
<span>{{
|
||||
'SETTINGS.RESTART_NOTICE'
|
||||
| translate: { settings: restartPendingLabels() }
|
||||
}}</span>
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
(click)="form.dismissRestartNotice()"
|
||||
data-test-id="settings-restart-later"
|
||||
>
|
||||
{{ 'SETTINGS.RESTART_LATER' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="settings-content">
|
||||
<!-- Only the routed section renders. The parent keeps the shared
|
||||
form alive across section switches, so edits staged on one
|
||||
@@ -96,21 +120,14 @@
|
||||
/>
|
||||
}
|
||||
@case ('backup') {
|
||||
<!-- Backup & data: the former Reset page is this
|
||||
page's last card, since it is one action. -->
|
||||
<app-settings-backup-section
|
||||
[isPwa]="isPwa"
|
||||
[isRemovingAllPlaylists]="
|
||||
playlistReset.isRemovingAllPlaylists()
|
||||
"
|
||||
[isExportingData]="backup.isExportingData()"
|
||||
(importData)="importData()"
|
||||
(exportData)="exportData()"
|
||||
/>
|
||||
}
|
||||
@case ('reset') {
|
||||
<app-settings-reset-section
|
||||
[isRemovingAllPlaylists]="
|
||||
playlistReset.isRemovingAllPlaylists()
|
||||
"
|
||||
[canRemoveAllPlaylists]="
|
||||
playlistReset.canRemoveAll()
|
||||
"
|
||||
@@ -120,6 +137,8 @@
|
||||
[removeAllProgressLabel]="
|
||||
playlistReset.removeAllProgressLabel()
|
||||
"
|
||||
(importData)="importData()"
|
||||
(exportData)="exportData()"
|
||||
(removeAll)="removeAll()"
|
||||
/>
|
||||
}
|
||||
@@ -142,6 +161,7 @@
|
||||
(openAppUpdateReleaseNotes)="
|
||||
appUpdate.openReleaseNotes()
|
||||
"
|
||||
(openBackup)="openSection('backup')"
|
||||
/>
|
||||
}
|
||||
@default {
|
||||
@@ -168,16 +188,19 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Discord-style unsaved-changes bar: appears the moment any edit
|
||||
makes the form dirty — on whichever section page — and vanishes
|
||||
once saved or discarded. It replaces the always-visible footer
|
||||
Save button of the single-scroll layout. -->
|
||||
<!-- Save bar: appears the moment any edit makes the form dirty — on
|
||||
whichever section page — and vanishes once saved or discarded.
|
||||
It floats over the content column and counts the staged
|
||||
changes; the navigation marks the pages they are on. -->
|
||||
@if (!settingsForm.pristine) {
|
||||
<footer class="unsaved-bar" data-test-id="settings-unsaved-bar">
|
||||
<div class="unsaved-bar__message">
|
||||
<mat-icon>edit</mat-icon>
|
||||
<span>{{ 'SETTINGS.UNSAVED_CHANGES' | translate }}</span>
|
||||
</div>
|
||||
<span class="unsaved-bar__dot" aria-hidden="true"></span>
|
||||
<span
|
||||
class="unsaved-bar__message"
|
||||
data-test-id="settings-unsaved-message"
|
||||
>
|
||||
{{ unsavedMessageKey() | translate: { count: changeCount() } }}
|
||||
</span>
|
||||
<div class="unsaved-bar__actions">
|
||||
<button
|
||||
mat-button
|
||||
@@ -194,8 +217,10 @@
|
||||
[disabled]="!settingsForm.valid"
|
||||
data-test-id="save-settings"
|
||||
>
|
||||
<mat-icon>save</mat-icon>
|
||||
{{ 'SETTINGS.SAVE_CHANGES' | translate }}
|
||||
<kbd class="settings-kbd" aria-hidden="true">{{
|
||||
saveShortcutLabel
|
||||
}}</kbd>
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -151,10 +151,10 @@ describe('SettingsComponent search', () => {
|
||||
it('scrolls to, focuses and briefly highlights a revealed row', () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
settingsSearch.reveal(entry('show-captions'));
|
||||
settingsSearch.reveal(entry('cover-titles'));
|
||||
fixture.detectChanges();
|
||||
|
||||
const row = query('[data-setting-id="show-captions"]') as HTMLElement;
|
||||
const row = query('[data-setting-id="cover-titles"]') as HTMLElement;
|
||||
expect(scrollIntoView).toHaveBeenCalled();
|
||||
expect(scrollIntoView.mock.contexts[0]).toBe(row);
|
||||
expect(row.classList).toContain(SETTINGS_REVEALED_CLASS);
|
||||
|
||||
@@ -239,7 +239,7 @@ describe('SettingsComponent', () => {
|
||||
});
|
||||
|
||||
it('enables the global wipe action only once a playlist exists', () => {
|
||||
setSettingsSection('reset');
|
||||
setSettingsSection('backup');
|
||||
fixture.detectChanges();
|
||||
|
||||
const deleteButton = () =>
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
computed,
|
||||
effect,
|
||||
ElementRef,
|
||||
HostListener,
|
||||
inject,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
@@ -23,12 +24,20 @@ import {
|
||||
RuntimeCapabilitiesService,
|
||||
} from '@iptvnator/services';
|
||||
import { VodSourceDiscoveryService } from '@iptvnator/portal/shared/data-access';
|
||||
import { Language, StreamFormat } from '@iptvnator/shared/interfaces';
|
||||
import { firstValueFrom, map } from 'rxjs';
|
||||
import {
|
||||
ELECTRON_BRIDGE_APP_UPDATE_STATUSES,
|
||||
Language,
|
||||
StreamFormat,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { firstValueFrom, map, startWith } from 'rxjs';
|
||||
import { BUILD_COMMIT } from '../../environments/build-commit';
|
||||
import { SettingsAboutSectionComponent } from './settings-about-section.component';
|
||||
import { SettingsAppUpdateFacade } from './settings-app-update.facade';
|
||||
import { SettingsBackupSectionComponent } from './settings-backup-section.component';
|
||||
import {
|
||||
sectionOfSettingsPath,
|
||||
SETTINGS_RESTART_CONTROLS,
|
||||
} from './settings-change-tracking';
|
||||
import { SettingsDashboardSectionComponent } from './settings-dashboard-section.component';
|
||||
import { SettingsEmbeddedMpvFacade } from './settings-embedded-mpv.facade';
|
||||
import { SettingsEpgFacade } from './settings-epg.facade';
|
||||
@@ -51,7 +60,6 @@ import { SettingsParentalLockSectionComponent } from './settings-parental-sectio
|
||||
import { SettingsPlaybackSectionComponent } from './settings-playback-section.component';
|
||||
import { SettingsRemoteControlFacade } from './settings-remote-control.facade';
|
||||
import { SettingsRemoteControlSectionComponent } from './settings-remote-control-section.component';
|
||||
import { SettingsResetSectionComponent } from './settings-reset-section.component';
|
||||
import { SettingsSearchFacade } from './settings-search.facade';
|
||||
import { SettingsSearchResultsComponent } from './settings-search-results.component';
|
||||
import { SettingsTmdbSectionComponent } from './settings-tmdb-section.component';
|
||||
@@ -98,7 +106,6 @@ export const SETTINGS_DEFAULT_SECTION = 'general';
|
||||
SettingsParentalLockSectionComponent,
|
||||
SettingsPlaybackSectionComponent,
|
||||
SettingsRemoteControlSectionComponent,
|
||||
SettingsResetSectionComponent,
|
||||
SettingsSearchResultsComponent,
|
||||
SettingsTmdbSectionComponent,
|
||||
],
|
||||
@@ -179,6 +186,42 @@ export class SettingsComponent
|
||||
/** Git commit the app was built from (CI builds only) */
|
||||
readonly buildCommit = BUILD_COMMIT;
|
||||
|
||||
/** Staged leaf values that differ from the saved settings */
|
||||
readonly changeCount = computed(() => this.form.changedPaths().length);
|
||||
|
||||
/** Save bar text: a count once something actually differs */
|
||||
readonly unsavedMessageKey = computed(() => {
|
||||
const count = this.changeCount();
|
||||
if (count === 1) return 'SETTINGS.UNSAVED_CHANGES_ONE';
|
||||
return count > 1
|
||||
? 'SETTINGS.UNSAVED_CHANGES_COUNT'
|
||||
: 'SETTINGS.UNSAVED_CHANGES';
|
||||
});
|
||||
|
||||
/** Re-reads translated text after a language switch */
|
||||
private readonly languageTick = toSignal(
|
||||
this.translate.onLangChange.pipe(startWith(null)),
|
||||
{ initialValue: null }
|
||||
);
|
||||
|
||||
/** Rows whose saved change waits for a restart, as one readable list */
|
||||
readonly restartPendingLabels = computed(() => {
|
||||
this.languageTick();
|
||||
return this.form
|
||||
.restartPendingControls()
|
||||
.map((control) =>
|
||||
this.translate.instant(SETTINGS_RESTART_CONTROLS[control])
|
||||
)
|
||||
.join(', ');
|
||||
});
|
||||
|
||||
/** The Save button advertises the platform's save chord */
|
||||
readonly saveShortcutLabel = /mac|iphone|ipad/i.test(
|
||||
navigator.platform || navigator.userAgent
|
||||
)
|
||||
? '⌘S'
|
||||
: 'Ctrl+S';
|
||||
|
||||
readonly themeOptions = SETTINGS_THEME_OPTIONS;
|
||||
readonly coverSizeOptions = SETTINGS_COVER_SIZE_OPTIONS;
|
||||
readonly startupBehaviorOptions = SETTINGS_STARTUP_BEHAVIOR_OPTIONS;
|
||||
@@ -242,6 +285,63 @@ export class SettingsComponent
|
||||
activeSection: this.activeSection,
|
||||
ready: this.formReady.asReadonly(),
|
||||
});
|
||||
|
||||
// The navigation marks pages with staged edits and shows the
|
||||
// installed version (with an update badge) beside About.
|
||||
effect(() => {
|
||||
const sections = new Set<string>();
|
||||
for (const path of this.form.changedPaths()) {
|
||||
const section = sectionOfSettingsPath(path);
|
||||
if (section) sections.add(section);
|
||||
}
|
||||
this.settingsCtx.setDirtySections(sections);
|
||||
});
|
||||
// The frame-copy opt-in may not be what the engine runs (it was not
|
||||
// available at launch): the restart notice compares against that.
|
||||
effect(() => {
|
||||
if (this.embeddedMpv.support()) {
|
||||
this.form.setRunningValue(
|
||||
'embeddedMpvFrameCopy',
|
||||
this.embeddedMpv.frameCopyActive()
|
||||
);
|
||||
}
|
||||
});
|
||||
effect(() => {
|
||||
const status = this.appUpdate.status()?.status;
|
||||
this.settingsCtx.setVersion(this.appUpdate.version() || null);
|
||||
this.settingsCtx.setUpdateAvailable(
|
||||
status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available ||
|
||||
status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cmd/Ctrl+S saves the staged edits. The browser's own save-page dialog
|
||||
* is suppressed on every settings page, also while nothing is staged.
|
||||
* While the page is inert (the phone drawer is open over it) or an
|
||||
* overlay with a backdrop is up (the unsaved-changes dialog, a select
|
||||
* panel), the chord belongs to that surface, not to the obscured form:
|
||||
* saving under the leave dialog would make its Discard keep the edits.
|
||||
*/
|
||||
@HostListener('document:keydown', ['$event'])
|
||||
onSaveShortcut(event: KeyboardEvent): void {
|
||||
const host = this.hostElement.nativeElement;
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
!(event.metaKey || event.ctrlKey) ||
|
||||
event.altKey ||
|
||||
event.shiftKey ||
|
||||
event.key.toLowerCase() !== 's' ||
|
||||
host.closest('[inert]') ||
|
||||
host.ownerDocument.querySelector('.cdk-overlay-backdrop')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
if (!this.settingsForm.pristine && this.settingsForm.valid) {
|
||||
this.onSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -274,6 +374,13 @@ export class SettingsComponent
|
||||
this.settingsCtx.reset();
|
||||
}
|
||||
|
||||
/** Switches section page in place, like the sidebar links do */
|
||||
openSection(sectionId: string): void {
|
||||
void this.router.navigate(['/workspace/settings', sectionId], {
|
||||
replaceUrl: true,
|
||||
});
|
||||
}
|
||||
|
||||
/** Picks a recording folder in the desktop shell and stages it in the form */
|
||||
async selectRecordingFolder(): Promise<void> {
|
||||
const folder = await this.embeddedMpv.selectRecordingFolder();
|
||||
|
||||
@@ -13,6 +13,8 @@ export interface SettingsSection {
|
||||
label: string;
|
||||
icon: string;
|
||||
visible: boolean;
|
||||
/** Navigation group; absent for sections pinned to the nav footer. */
|
||||
group?: string;
|
||||
}
|
||||
|
||||
export interface ThemeOption {
|
||||
|
||||
Reference in new issue
Block a user