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:
4grayandClaude Fable 5.1 authored and GitHub committed 2026-10-09 20:39:07 +02:00
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 }}
&nbsp;<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 = () =>
+111 -4
View File
@@ -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 {