mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36:15 -08:00
feat(settings): grouped navigation, cards and switches from the settings concept (#1853)
* feat(settings): grouped navigation, cards and switches from the settings concept Implements the Claude Design "Settings concept" handoff. Navigation: the sidebar is a 20px "Settings" title over App, Library, Devices and Data groups, with About pinned to the footer beside the installed version and an update badge. The active item is a soft fill without a border. Esc leaves settings like the header Back. Reset is no longer a page: its one destructive action is the last card of "Backup & data". Pages: a title and one-line subtitle, then rows grouped into titled cards with one right-aligned control column. Selects are compact without floating labels, checkboxes are switches, segmented controls are pills, and descriptions are capped at 56ch. "Show subtitles" moves to Playback, the Embedded MPV note becomes a callout under the player select (only while it is selected), and the TMDB attribution becomes the About footer. EPG: Add and Refresh all sit in the Sources header, the empty state has its own Add, the format examples are one line, Clear EPG data is its own row and the offset is a stepper. About: version, update state and channel share one card; the nightly warning is a callout shown only while Nightly is selected, with a shortcut to the backup page; the support buttons are neutral with coloured icons. Save model: the save bar stays (design option B) and now floats over the content column, counts the staged changes, answers Cmd/Ctrl+S, and marks pages with staged edits in the sidebar. A saved change that waits for a restart shows a dismissible notice. Rows stack under 600px of pane width (the page is a size container, so the persistent sidebar is accounted for). Page-specific styles moved into the About, EPG, Backup and Remote control section stylesheets to stay within the component style budget. New SETTINGS keys are translated in all 19 locales and NAV_RESET is removed; brand names and loanwords that stay English are in the identical-value baseline. E2E: Material slide toggles report aria-checked after the next render, so both suites toggle through a setSwitch helper; the settings nav is a navigation landmark, so the Dashboard rail link is scoped to the rail. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(settings): compare restart notices against the running app, fix CI fallout The restart notice now lists only the restart controls whose saved value differs from what the running app uses: the stored values at first load, or what the embedded engine reports it actually runs for the frame-copy opt-in. Saving the launch value back withdraws the notice instead of leaving it up with no chip to explain it. The refresh reads the current list untracked and writes only a changed one, because the engine probe effect calls it. CI: the zoneless checklist dropped the deleted Reset section component, and the theme-tokens Electron E2E floats the recording folder label on the Playback page now that the settings selects carry no floating label. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(settings): keep a dismissed restart reminder away until the setting changes "Later" now records the saved value it dismissed, for the rest of the app run, so an unrelated save (or reopening Settings) does not bring the same reminder back. It returns once a restart setting is saved with another value. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
4c6540679d
commit
8030ced95a
90 files changed
+5957
-3617
No files matched your search
@@ -1,83 +1,84 @@
|
||||
@use '@angular/material' as mat;
|
||||
|
||||
$settings-select-density: mat.define-theme(
|
||||
(
|
||||
density: (
|
||||
scale: -5,
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
// Settings surfaces: rows in titled cards on the page background. Light
|
||||
// keeps white cards with hairline borders; dark uses the widget surface with
|
||||
// a faint light border, per the UI guidelines' theme split.
|
||||
.settings-container {
|
||||
// Compact switch, nearer the 22px toggle of the concept than Material's
|
||||
// default 32px track; the 40px state layer keeps the touch target.
|
||||
@include mat.slide-toggle-overrides(
|
||||
(
|
||||
track-width: 40px,
|
||||
track-height: 24px,
|
||||
selected-handle-size: 18px,
|
||||
unselected-handle-size: 14px,
|
||||
pressed-handle-size: 20px,
|
||||
with-icon-handle-size: 18px,
|
||||
)
|
||||
);
|
||||
|
||||
// Compact select without a floating label (the row title names it).
|
||||
.settings-select.mat-mdc-form-field {
|
||||
@include mat.form-field-density($settings-select-density);
|
||||
}
|
||||
|
||||
// Save is the page's one solid call to action: the app CTA pair (light
|
||||
// on dark, dark on light), not the theme primary. Set through the button
|
||||
// tokens, which a plain background rule cannot beat.
|
||||
.settings-save-button {
|
||||
@include mat.button-overrides(
|
||||
(
|
||||
filled-container-color: var(--app-cta-bg),
|
||||
filled-label-text-color: var(--app-cta-fg),
|
||||
filled-state-layer-color: var(--app-cta-fg),
|
||||
filled-container-shape: 8px,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
--settings-safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||
// Action bar is a full-width sticky footer, so it sits flush against
|
||||
// the bottom edge of the scroll container — no hover offset. The
|
||||
// previous 24px gap was tuned for the old floating-chip layout where
|
||||
// the bar visually hovered above the bottom.
|
||||
--settings-action-bar-offset: 0px;
|
||||
// 0 by default — the action bar is now the last child of the form
|
||||
// and acts as the natural bottom edge, so no padding-bottom is
|
||||
// needed below it. The previous 112px/80px reservation existed to
|
||||
// push content above the OLD floating-chip layout, but in the new
|
||||
// sticky-footer model that just added dead scrollable space below
|
||||
// the last setting row. The bar's own padding (12px) and the
|
||||
// section title bottom-borders already provide visual breathing
|
||||
// room when scrolled to the end.
|
||||
--settings-action-bar-space: 0px;
|
||||
--settings-page-header-bg: color-mix(
|
||||
--settings-card-bg: var(--app-widget-bg, #ffffff);
|
||||
--settings-card-border: rgba(0, 0, 0, 0.08);
|
||||
--settings-item-border: rgba(0, 0, 0, 0.07);
|
||||
--settings-control-bg: #ffffff;
|
||||
--settings-control-border: rgba(0, 0, 0, 0.14);
|
||||
--settings-control-hover-border: rgba(0, 0, 0, 0.26);
|
||||
--settings-chip-bg: rgba(0, 0, 0, 0.07);
|
||||
--settings-chip-fg: var(--app-body-color);
|
||||
--settings-callout-info-bg: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-surface-container-low) 94%,
|
||||
rgba(255, 255, 255, 0.98)
|
||||
var(--app-selection-color) 6%,
|
||||
var(--settings-card-bg)
|
||||
);
|
||||
--settings-page-header-border: color-mix(
|
||||
--settings-callout-info-border: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-outline-variant) 72%,
|
||||
var(--app-selection-color) 24%,
|
||||
transparent
|
||||
);
|
||||
--settings-page-header-shadow:
|
||||
0 10px 20px -28px rgba(15, 23, 42, 0.16),
|
||||
0 1px 0 rgba(255, 255, 255, 0.68) inset;
|
||||
--settings-group-bg: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-surface) 96%,
|
||||
rgba(255, 255, 255, 0.96)
|
||||
);
|
||||
--settings-group-border: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-outline-variant) 78%,
|
||||
transparent
|
||||
);
|
||||
--settings-group-shadow: 0 14px 28px -32px rgba(15, 23, 42, 0.14);
|
||||
--settings-group-active-bg: color-mix(
|
||||
in srgb,
|
||||
var(--app-selection-color) 2%,
|
||||
rgba(255, 255, 255, 0.98)
|
||||
);
|
||||
--settings-group-active-ring: color-mix(
|
||||
in srgb,
|
||||
var(--app-selection-border) 68%,
|
||||
var(--settings-group-border)
|
||||
);
|
||||
--settings-group-active-glow: color-mix(
|
||||
in srgb,
|
||||
var(--app-selection-glow) 28%,
|
||||
transparent
|
||||
);
|
||||
--settings-group-active-shadow:
|
||||
inset 0 0 0 1px var(--settings-group-active-ring),
|
||||
0 12px 24px -28px var(--settings-group-active-glow);
|
||||
--settings-item-border: var(--app-separator);
|
||||
--settings-item-danger-bg: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-error) 4%,
|
||||
rgba(255, 255, 255, 0.96)
|
||||
);
|
||||
--settings-callout-warn-bg: color-mix(in srgb, #d97706 9%, transparent);
|
||||
--settings-callout-warn-border: color-mix(in srgb, #d97706 32%, transparent);
|
||||
--settings-callout-warn-icon: #8a5300;
|
||||
--settings-item-danger-border: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-error) 14%,
|
||||
var(--mat-sys-error) 22%,
|
||||
transparent
|
||||
);
|
||||
--settings-action-bar-bg: color-mix(
|
||||
--settings-action-bar-bg: #ffffff;
|
||||
--settings-action-bar-border: rgba(0, 0, 0, 0.1);
|
||||
--settings-action-bar-shadow: 0 16px 40px rgba(15, 23, 42, 0.18);
|
||||
--settings-restart-bar-bg: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-surface-container-low) 94%,
|
||||
rgba(255, 255, 255, 0.9)
|
||||
var(--app-selection-color) 8%,
|
||||
var(--app-content-bg)
|
||||
);
|
||||
--settings-action-bar-border: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-outline-variant) 62%,
|
||||
transparent
|
||||
);
|
||||
--settings-action-bar-shadow: 0 10px 26px -26px rgba(15, 23, 42, 0.22);
|
||||
--settings-muted: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-on-surface) 64%,
|
||||
@@ -87,250 +88,33 @@
|
||||
|
||||
:where(.dark-theme, [data-theme='dark'], .mat-app-background.dark)
|
||||
.settings-container {
|
||||
--settings-page-header-bg: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-surface-container) 92%,
|
||||
rgba(12, 16, 24, 0.94)
|
||||
);
|
||||
--settings-page-header-border: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-outline-variant) 38%,
|
||||
transparent
|
||||
);
|
||||
--settings-page-header-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.05),
|
||||
0 16px 26px -34px rgba(0, 0, 0, 0.4);
|
||||
--settings-group-bg: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-surface-container) 94%,
|
||||
rgba(10, 14, 22, 0.82)
|
||||
);
|
||||
--settings-group-border: rgba(255, 255, 255, 0.08);
|
||||
--settings-group-shadow: 0 16px 28px -32px rgba(0, 0, 0, 0.42);
|
||||
--settings-group-active-bg: color-mix(
|
||||
in srgb,
|
||||
var(--app-selection-surface) 24%,
|
||||
rgba(255, 255, 255, 0.015)
|
||||
);
|
||||
--settings-group-active-ring: color-mix(
|
||||
in srgb,
|
||||
var(--app-selection-border) 54%,
|
||||
rgba(255, 255, 255, 0.08)
|
||||
);
|
||||
--settings-group-active-glow: color-mix(
|
||||
in srgb,
|
||||
var(--app-selection-glow) 22%,
|
||||
transparent
|
||||
);
|
||||
--settings-group-active-shadow:
|
||||
inset 0 0 0 1px var(--settings-group-active-ring),
|
||||
0 10px 22px -28px var(--settings-group-active-glow);
|
||||
--settings-item-danger-bg: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-error) 8%,
|
||||
rgba(24, 10, 12, 0.86)
|
||||
);
|
||||
--settings-card-bg: var(--app-widget-bg, #1e2330);
|
||||
--settings-card-border: rgba(255, 255, 255, 0.07);
|
||||
--settings-item-border: rgba(255, 255, 255, 0.06);
|
||||
--settings-control-bg: color-mix(in srgb, var(--app-widget-bg) 90%, white 6%);
|
||||
--settings-control-border: rgba(255, 255, 255, 0.12);
|
||||
--settings-control-hover-border: rgba(255, 255, 255, 0.22);
|
||||
--settings-chip-bg: rgba(255, 255, 255, 0.1);
|
||||
--settings-chip-fg: var(--app-heading-color);
|
||||
--settings-callout-warn-bg: color-mix(in srgb, #fbbf24 10%, transparent);
|
||||
--settings-callout-warn-border: color-mix(in srgb, #fbbf24 30%, transparent);
|
||||
--settings-callout-warn-icon: #fbbf24;
|
||||
--settings-item-danger-border: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-error) 24%,
|
||||
var(--mat-sys-error) 30%,
|
||||
transparent
|
||||
);
|
||||
--settings-action-bar-bg: color-mix(
|
||||
--settings-action-bar-bg: var(--app-card-hover-bg, #252b3a);
|
||||
--settings-action-bar-border: rgba(255, 255, 255, 0.1);
|
||||
--settings-action-bar-shadow: 0 16px 40px rgba(0, 0, 0, 0.55);
|
||||
--settings-restart-bar-bg: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-surface-container) 92%,
|
||||
transparent
|
||||
var(--app-selection-color) 12%,
|
||||
var(--app-content-bg)
|
||||
);
|
||||
--settings-action-bar-border: rgba(255, 255, 255, 0.08);
|
||||
--settings-action-bar-shadow: 0 8px 30px rgba(0, 0, 0, 0.18);
|
||||
--settings-muted: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-on-surface) 68%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.settings-container {
|
||||
.support-card {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding: 18px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid color-mix(in srgb, #ff4d6d 26%, transparent);
|
||||
background:
|
||||
radial-gradient(
|
||||
120% 140% at 0% 0%,
|
||||
color-mix(in srgb, #ff4d6d 16%, transparent) 0%,
|
||||
transparent 55%
|
||||
),
|
||||
radial-gradient(
|
||||
120% 140% at 100% 100%,
|
||||
color-mix(in srgb, #ffb347 14%, transparent) 0%,
|
||||
transparent 60%
|
||||
),
|
||||
color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-surface-container-low) 92%,
|
||||
transparent
|
||||
);
|
||||
overflow: hidden;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(
|
||||
80% 100% at 50% -10%,
|
||||
color-mix(in srgb, #ff4d6d 12%, transparent) 0%,
|
||||
transparent 70%
|
||||
);
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.support-card__intro {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.support-card__emblem {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(135deg, #ff4d6d, #ff8b5e);
|
||||
color: #fff;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.35),
|
||||
0 10px 18px -10px rgba(255, 77, 109, 0.55);
|
||||
|
||||
mat-icon {
|
||||
font-size: 22px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
.support-card__copy {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 4px 0 0;
|
||||
color: var(--settings-muted);
|
||||
font-size: 0.86rem;
|
||||
line-height: 1.5;
|
||||
max-width: 64ch;
|
||||
}
|
||||
}
|
||||
|
||||
.support-card__actions {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.support-btn {
|
||||
flex: 1 1 220px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid transparent;
|
||||
text-decoration: none;
|
||||
color: #fff;
|
||||
transition:
|
||||
transform 0.18s ease,
|
||||
box-shadow 0.18s ease,
|
||||
filter 0.18s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
filter: brightness(1.04);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
box-shadow:
|
||||
0 0 0 2px var(--mat-sys-surface),
|
||||
0 0 0 4px
|
||||
color-mix(
|
||||
in srgb,
|
||||
var(--app-selection-color) 40%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.support-btn__glyph {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
color: #fff;
|
||||
flex: 0 0 auto;
|
||||
|
||||
svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.support-btn__text {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
|
||||
strong {
|
||||
font-size: 0.92rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
small {
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
}
|
||||
|
||||
.support-btn--sponsors {
|
||||
background: linear-gradient(135deg, #d4337a, #ff4d6d);
|
||||
box-shadow: 0 12px 22px -18px rgba(212, 51, 122, 0.7);
|
||||
}
|
||||
|
||||
.support-btn--kofi {
|
||||
background: linear-gradient(135deg, #13c3ff, #29abe0);
|
||||
box-shadow: 0 12px 22px -18px rgba(19, 195, 255, 0.7);
|
||||
}
|
||||
}
|
||||
@@ -1,437 +1,427 @@
|
||||
<section class="settings-group" id="about">
|
||||
<header class="settings-group__header">
|
||||
<div class="settings-group__header-icon">
|
||||
<mat-icon>info</mat-icon>
|
||||
</div>
|
||||
<div class="settings-group__header-text">
|
||||
<h3>{{ 'SETTINGS.ABOUT' | translate }}</h3>
|
||||
<p>{{ 'SETTINGS.ABOUT_SUBTITLE' | translate }}</p>
|
||||
</div>
|
||||
<h2>{{ 'SETTINGS.ABOUT' | translate }}</h2>
|
||||
<p>{{ 'SETTINGS.ABOUT_SUBTITLE' | translate }}</p>
|
||||
</header>
|
||||
|
||||
<div class="setting-item" data-setting-id="version">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.VERSION' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.VERSION_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
<div class="setting-item__control version-block">
|
||||
<span data-test-id="app-version">
|
||||
{{ version() }}
|
||||
@if (buildCommitShort(); as commit) {
|
||||
<span
|
||||
class="build-commit"
|
||||
data-test-id="app-build-commit"
|
||||
[title]="buildCommit()"
|
||||
>({{ commit }})</span
|
||||
>
|
||||
}
|
||||
</span>
|
||||
<small>{{ updateMessage() }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (isDesktop() && appUpdateStatus(); as updateStatus) {
|
||||
<div
|
||||
class="setting-item setting-item--full-control app-update-panel"
|
||||
data-setting-id="app-update"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.APP_UPDATE_TITLE' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.APP_UPDATE_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
<div class="setting-item__control app-update-panel__control">
|
||||
<div
|
||||
class="app-update-status"
|
||||
[class.app-update-status--stale]="hasPendingChannelChange()"
|
||||
data-test-id="app-update-status"
|
||||
>
|
||||
@if (appliedChannelLabelKey(); as channelLabelKey) {
|
||||
<span
|
||||
class="app-update-status__channel"
|
||||
data-test-id="app-update-status-channel"
|
||||
>
|
||||
{{ channelLabelKey | translate }}
|
||||
</span>
|
||||
}
|
||||
<strong>
|
||||
{{
|
||||
appUpdateStatusLabelKey()
|
||||
| translate
|
||||
: {
|
||||
version:
|
||||
updateStatus.latestVersion ||
|
||||
updateStatus.currentVersion,
|
||||
}
|
||||
}}
|
||||
</strong>
|
||||
@if (updateStatus.error) {
|
||||
<span>{{ updateStatus.error }}</span>
|
||||
}
|
||||
@if (updateStatus.progress; as progress) {
|
||||
<progress
|
||||
[value]="progress.percent"
|
||||
max="100"
|
||||
data-test-id="app-update-progress"
|
||||
></progress>
|
||||
}
|
||||
@if (hasPendingChannelChange()) {
|
||||
<span data-test-id="app-update-channel-pending-hint">
|
||||
{{
|
||||
'SETTINGS.APP_UPDATE_CHANNEL_PENDING_HINT'
|
||||
| translate
|
||||
: {
|
||||
current:
|
||||
(channelLabelKey(
|
||||
verdictChannel()
|
||||
) | translate),
|
||||
next:
|
||||
(channelLabelKey(
|
||||
draftUpdateChannel()
|
||||
) | translate),
|
||||
}
|
||||
}}
|
||||
</span>
|
||||
} @else if (hasRetainedOtherChannelVerdict()) {
|
||||
<span data-test-id="app-update-channel-retained-hint">
|
||||
{{
|
||||
'SETTINGS.APP_UPDATE_CHANNEL_RETAINED_HINT'
|
||||
| translate
|
||||
: {
|
||||
current:
|
||||
(channelLabelKey(
|
||||
verdictChannel()
|
||||
) | translate),
|
||||
next:
|
||||
(channelLabelKey(
|
||||
updateStatus.channel
|
||||
) | translate),
|
||||
}
|
||||
}}
|
||||
</span>
|
||||
<!-- Version, update state and channel in one block. -->
|
||||
<section class="settings-section">
|
||||
<div class="settings-card">
|
||||
<div class="setting-item settings-hero" data-setting-id="version">
|
||||
<img
|
||||
class="settings-hero__logo"
|
||||
src="assets/icons/android-chrome-192x192.png"
|
||||
alt=""
|
||||
/>
|
||||
<div class="settings-hero__text">
|
||||
<div class="settings-hero__title">
|
||||
<span>IPTVnator</span>
|
||||
@if (updateStatusChip(); as chip) {
|
||||
<span
|
||||
class="settings-chip"
|
||||
[class.settings-chip--success]="
|
||||
chip.tone === 'success'
|
||||
"
|
||||
[class.settings-chip--accent]="
|
||||
chip.tone === 'accent'
|
||||
"
|
||||
[class.settings-chip--warn]="chip.tone === 'warn'"
|
||||
data-test-id="app-update-status-chip"
|
||||
>{{ chip.labelKey | translate }}</span
|
||||
>
|
||||
}
|
||||
</div>
|
||||
<code class="settings-hero__version" data-test-id="app-version">
|
||||
{{ version() }}
|
||||
@if (buildCommitShort(); as commit) {
|
||||
<span
|
||||
class="build-commit"
|
||||
data-test-id="app-build-commit"
|
||||
[title]="buildCommit()"
|
||||
>({{ commit }})</span
|
||||
>
|
||||
}
|
||||
</code>
|
||||
@if (!appUpdateStatus() && updateMessage()) {
|
||||
<small class="settings-hero__note">{{
|
||||
updateMessage()
|
||||
}}</small>
|
||||
}
|
||||
</div>
|
||||
@if (canSelectUpdateChannel()) {
|
||||
<div
|
||||
class="app-update-channel"
|
||||
[formGroup]="form()!"
|
||||
data-test-id="app-update-channel"
|
||||
>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>{{
|
||||
'SETTINGS.APP_UPDATE_CHANNEL' | translate
|
||||
}}</mat-label>
|
||||
<mat-select
|
||||
formControlName="updateChannel"
|
||||
data-test-id="select-update-channel"
|
||||
@if (isDesktop() && appUpdateStatus()) {
|
||||
<div class="settings-hero__actions">
|
||||
@if (canOpenAppUpdateReleaseNotes()) {
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
(click)="openAppUpdateReleaseNotes.emit()"
|
||||
data-test-id="app-update-release-notes"
|
||||
>
|
||||
@for (
|
||||
option of updateChannelOptions();
|
||||
track option.value
|
||||
) {
|
||||
<mat-option
|
||||
[value]="option.value"
|
||||
[attr.data-test-id]="
|
||||
'update-channel-' + option.value
|
||||
"
|
||||
>
|
||||
{{ option.labelKey | translate }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<small class="app-update-channel__note">
|
||||
{{
|
||||
'SETTINGS.APP_UPDATE_CHANNEL_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</small>
|
||||
@if (
|
||||
form()!.get('updateChannel')?.value === 'nightly'
|
||||
) {
|
||||
<small
|
||||
class="app-update-channel__note"
|
||||
data-test-id="app-update-channel-nightly-warning"
|
||||
>
|
||||
{{
|
||||
'SETTINGS.APP_UPDATE_CHANNEL_NIGHTLY_WARNING'
|
||||
| translate
|
||||
}}
|
||||
</small>
|
||||
<mat-icon>article</mat-icon>
|
||||
{{ 'SETTINGS.APP_UPDATE_RELEASE_NOTES' | translate }}
|
||||
</button>
|
||||
}
|
||||
@if (isNightlyBuildOnStableChannel()) {
|
||||
<small
|
||||
class="app-update-channel__note"
|
||||
data-test-id="app-update-nightly-on-stable-hint"
|
||||
@if (canSaveAndCheckForAppUpdate()) {
|
||||
<button
|
||||
mat-flat-button
|
||||
type="button"
|
||||
(click)="saveAndCheckForAppUpdate.emit()"
|
||||
[disabled]="isAppUpdateBusy() || form()!.invalid"
|
||||
data-test-id="app-update-save-and-check"
|
||||
>
|
||||
<mat-icon>save</mat-icon>
|
||||
{{
|
||||
'SETTINGS.APP_UPDATE_NIGHTLY_ON_STABLE_HINT'
|
||||
'SETTINGS.APP_UPDATE_SAVE_AND_CHECK'
|
||||
| translate
|
||||
: {
|
||||
channel:
|
||||
(channelLabelKey(
|
||||
draftUpdateChannel()
|
||||
) | translate),
|
||||
}
|
||||
}}
|
||||
</small>
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
(click)="checkForAppUpdate.emit()"
|
||||
[disabled]="isAppUpdateBusy()"
|
||||
data-test-id="app-update-check"
|
||||
>
|
||||
<mat-icon>sync</mat-icon>
|
||||
{{ 'SETTINGS.APP_UPDATE_CHECK' | translate }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div class="app-update-actions">
|
||||
@if (canSaveAndCheckForAppUpdate()) {
|
||||
<button
|
||||
mat-flat-button
|
||||
type="button"
|
||||
(click)="saveAndCheckForAppUpdate.emit()"
|
||||
[disabled]="isAppUpdateBusy() || form()!.invalid"
|
||||
data-test-id="app-update-save-and-check"
|
||||
>
|
||||
<mat-icon>save</mat-icon>
|
||||
{{
|
||||
'SETTINGS.APP_UPDATE_SAVE_AND_CHECK'
|
||||
| translate
|
||||
: {
|
||||
channel:
|
||||
(channelLabelKey(
|
||||
draftUpdateChannel()
|
||||
) | translate),
|
||||
}
|
||||
}}
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
(click)="checkForAppUpdate.emit()"
|
||||
[disabled]="isAppUpdateBusy()"
|
||||
data-test-id="app-update-check"
|
||||
>
|
||||
<mat-icon>sync</mat-icon>
|
||||
{{ 'SETTINGS.APP_UPDATE_CHECK' | translate }}
|
||||
</button>
|
||||
}
|
||||
@if (canDownloadAppUpdate()) {
|
||||
<button
|
||||
mat-flat-button
|
||||
type="button"
|
||||
(click)="downloadAppUpdate.emit()"
|
||||
data-test-id="app-update-download"
|
||||
>
|
||||
<mat-icon>download</mat-icon>
|
||||
{{ 'SETTINGS.APP_UPDATE_DOWNLOAD' | translate }}
|
||||
</button>
|
||||
}
|
||||
@if (canOpenAppUpdateReleaseNotes()) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
(click)="openAppUpdateReleaseNotes.emit()"
|
||||
data-test-id="app-update-release-notes"
|
||||
>
|
||||
<mat-icon>article</mat-icon>
|
||||
{{
|
||||
'SETTINGS.APP_UPDATE_RELEASE_NOTES' | translate
|
||||
}}
|
||||
</button>
|
||||
}
|
||||
@if (canInstallAppUpdate()) {
|
||||
<button
|
||||
mat-flat-button
|
||||
type="button"
|
||||
(click)="installAppUpdate.emit()"
|
||||
data-test-id="app-update-install"
|
||||
>
|
||||
<mat-icon>restart_alt</mat-icon>
|
||||
{{ 'SETTINGS.APP_UPDATE_INSTALL' | translate }}
|
||||
</button>
|
||||
}
|
||||
@if (canOpenManualAppUpdate()) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
(click)="openManualAppUpdate.emit()"
|
||||
data-test-id="app-update-open-release"
|
||||
>
|
||||
<mat-icon>open_in_new</mat-icon>
|
||||
{{ 'SETTINGS.APP_UPDATE_OPEN_RELEASE' | translate }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (isDesktop()) {
|
||||
<div class="settings-note">
|
||||
<strong>{{
|
||||
'SETTINGS.EMBEDDED_MPV_NOTICE_TITLE' | translate
|
||||
}}</strong>
|
||||
<span>{{
|
||||
'SETTINGS.EMBEDDED_MPV_NOTICE_DESCRIPTION' | translate
|
||||
}}</span>
|
||||
</div>
|
||||
}
|
||||
@if (isDesktop() && appUpdateStatus(); as updateStatus) {
|
||||
<div
|
||||
class="setting-item app-update-panel"
|
||||
data-setting-id="app-update"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<div
|
||||
class="app-update-status"
|
||||
[class.app-update-status--stale]="
|
||||
hasPendingChannelChange()
|
||||
"
|
||||
data-test-id="app-update-status"
|
||||
>
|
||||
@if (appliedChannelLabelKey(); as channelLabelKey) {
|
||||
<span
|
||||
class="app-update-status__channel"
|
||||
data-test-id="app-update-status-channel"
|
||||
>
|
||||
{{ channelLabelKey | translate }}
|
||||
</span>
|
||||
}
|
||||
<strong>
|
||||
{{
|
||||
appUpdateStatusLabelKey()
|
||||
| translate
|
||||
: {
|
||||
version:
|
||||
updateStatus.latestVersion ||
|
||||
updateStatus.currentVersion,
|
||||
}
|
||||
}}
|
||||
</strong>
|
||||
@if (updateStatus.error) {
|
||||
<span>{{ updateStatus.error }}</span>
|
||||
}
|
||||
@if (updateStatus.progress; as progress) {
|
||||
<progress
|
||||
[value]="progress.percent"
|
||||
max="100"
|
||||
data-test-id="app-update-progress"
|
||||
></progress>
|
||||
}
|
||||
@if (hasPendingChannelChange()) {
|
||||
<span data-test-id="app-update-channel-pending-hint">
|
||||
{{
|
||||
'SETTINGS.APP_UPDATE_CHANNEL_PENDING_HINT'
|
||||
| translate
|
||||
: {
|
||||
current:
|
||||
(channelLabelKey(
|
||||
verdictChannel()
|
||||
) | translate),
|
||||
next:
|
||||
(channelLabelKey(
|
||||
draftUpdateChannel()
|
||||
) | translate),
|
||||
}
|
||||
}}
|
||||
</span>
|
||||
} @else if (hasRetainedOtherChannelVerdict()) {
|
||||
<span
|
||||
data-test-id="app-update-channel-retained-hint"
|
||||
>
|
||||
{{
|
||||
'SETTINGS.APP_UPDATE_CHANNEL_RETAINED_HINT'
|
||||
| translate
|
||||
: {
|
||||
current:
|
||||
(channelLabelKey(
|
||||
verdictChannel()
|
||||
) | translate),
|
||||
next:
|
||||
(channelLabelKey(
|
||||
updateStatus.channel
|
||||
) | translate),
|
||||
}
|
||||
}}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item__control app-update-actions">
|
||||
@if (canDownloadAppUpdate()) {
|
||||
<button
|
||||
mat-flat-button
|
||||
type="button"
|
||||
(click)="downloadAppUpdate.emit()"
|
||||
data-test-id="app-update-download"
|
||||
>
|
||||
<mat-icon>download</mat-icon>
|
||||
{{ 'SETTINGS.APP_UPDATE_DOWNLOAD' | translate }}
|
||||
</button>
|
||||
}
|
||||
@if (canInstallAppUpdate()) {
|
||||
<button
|
||||
mat-flat-button
|
||||
type="button"
|
||||
(click)="installAppUpdate.emit()"
|
||||
data-test-id="app-update-install"
|
||||
>
|
||||
<mat-icon>restart_alt</mat-icon>
|
||||
{{ 'SETTINGS.APP_UPDATE_INSTALL' | translate }}
|
||||
</button>
|
||||
}
|
||||
@if (canOpenManualAppUpdate()) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
(click)="openManualAppUpdate.emit()"
|
||||
data-test-id="app-update-open-release"
|
||||
>
|
||||
<mat-icon>open_in_new</mat-icon>
|
||||
{{ 'SETTINGS.APP_UPDATE_OPEN_RELEASE' | translate }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="setting-item setting-item--full-control"
|
||||
data-setting-id="tmdb-credits"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.TMDB_CREDITS' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.TMDB_ATTRIBUTION' | translate }}</p>
|
||||
@if (canSelectUpdateChannel()) {
|
||||
<div
|
||||
class="setting-item"
|
||||
data-setting-id="app-update-channel"
|
||||
data-test-id="app-update-channel"
|
||||
[formGroup]="form()!"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<h4 id="app-update-channel-label">
|
||||
{{ 'SETTINGS.APP_UPDATE_CHANNEL' | translate }}
|
||||
</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.APP_UPDATE_CHANNEL_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<mat-form-field
|
||||
class="settings-select"
|
||||
appearance="outline"
|
||||
subscriptSizing="dynamic"
|
||||
>
|
||||
<mat-select
|
||||
formControlName="updateChannel"
|
||||
aria-labelledby="app-update-channel-label"
|
||||
data-test-id="select-update-channel"
|
||||
>
|
||||
@for (
|
||||
option of updateChannelOptions();
|
||||
track option.value
|
||||
) {
|
||||
<mat-option
|
||||
[value]="option.value"
|
||||
[attr.data-test-id]="
|
||||
'update-channel-' + option.value
|
||||
"
|
||||
>
|
||||
{{ option.labelKey | translate }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
<!-- The warning belongs to the risky choice, and only
|
||||
while it is selected. -->
|
||||
@if (draftUpdateChannel() === 'nightly') {
|
||||
<div class="setting-item__sub">
|
||||
<div
|
||||
class="settings-callout settings-callout--warn"
|
||||
data-test-id="app-update-channel-nightly-warning"
|
||||
>
|
||||
<mat-icon>warning</mat-icon>
|
||||
<div>
|
||||
{{
|
||||
'SETTINGS.APP_UPDATE_CHANNEL_NIGHTLY_WARNING'
|
||||
| translate
|
||||
}}
|
||||
<div class="settings-callout__actions">
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
(click)="openBackup.emit()"
|
||||
data-test-id="app-update-open-backup"
|
||||
>
|
||||
{{
|
||||
'SETTINGS.APP_UPDATE_CHANNEL_OPEN_BACKUP'
|
||||
| translate
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (isNightlyBuildOnStableChannel()) {
|
||||
<div class="setting-item__sub">
|
||||
<div
|
||||
class="settings-callout settings-callout--info"
|
||||
data-test-id="app-update-nightly-on-stable-hint"
|
||||
>
|
||||
<mat-icon>info</mat-icon>
|
||||
<div>
|
||||
{{
|
||||
'SETTINGS.APP_UPDATE_NIGHTLY_ON_STABLE_HINT'
|
||||
| translate
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
<div class="settings-section__header">
|
||||
<h3>{{ 'SETTINGS.COMMUNITY' | translate }}</h3>
|
||||
</div>
|
||||
<div
|
||||
class="settings-card link-cards"
|
||||
data-setting-id="community"
|
||||
tabindex="-1"
|
||||
>
|
||||
<a
|
||||
href="https://www.themoviedb.org"
|
||||
class="link-card link-card--github"
|
||||
href="https://github.com/4gray/iptvnator"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
data-test-id="link-github"
|
||||
>
|
||||
<img
|
||||
src="assets/images/tmdb-logo.svg"
|
||||
alt="TMDB"
|
||||
style="height: 20px; margin-top: 8px"
|
||||
/>
|
||||
<span class="link-card__glyph">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 0C5.374 0 0 5.373 0 12c0 5.302 3.438 9.8 8.207 11.387.6.112.793-.26.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23A11.52 11.52 0 0 1 12 5.803c1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576C20.565 21.796 24 17.3 24 12c0-6.627-5.373-12-12-12z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="link-card__text">
|
||||
<strong>GitHub</strong>
|
||||
<span>{{ 'SETTINGS.LINK_GITHUB_DESCRIPTION' | translate }}</span>
|
||||
</span>
|
||||
</a>
|
||||
<a
|
||||
class="link-card link-card--blog"
|
||||
href="https://4gray.github.io/iptvnator/"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
data-test-id="link-blog"
|
||||
>
|
||||
<span class="link-card__glyph">
|
||||
<mat-icon>menu_book</mat-icon>
|
||||
</span>
|
||||
<span class="link-card__text">
|
||||
<strong>{{ 'SETTINGS.LINK_BLOG' | translate }}</strong>
|
||||
<span>{{ 'SETTINGS.LINK_BLOG_DESCRIPTION' | translate }}</span>
|
||||
</span>
|
||||
</a>
|
||||
<a
|
||||
class="link-card link-card--telegram"
|
||||
href="https://t.me/iptvnator"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
data-test-id="link-telegram"
|
||||
>
|
||||
<span class="link-card__glyph">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M9.78 18.65l.28-4.23 7.68-6.92c.34-.31-.07-.46-.52-.19L7.74 13.3 3.64 12c-.88-.25-.89-.86.2-1.3l15.97-6.16c.73-.33 1.43.18 1.15 1.3l-2.72 12.81c-.19.91-.74 1.13-1.5.71L12.6 16.3l-1.99 1.93c-.23.23-.42.42-.83.42z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="link-card__text">
|
||||
<strong>Telegram</strong>
|
||||
<span>{{
|
||||
'SETTINGS.LINK_TELEGRAM_DESCRIPTION' | translate
|
||||
}}</span>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div
|
||||
class="setting-item setting-item--full-control"
|
||||
data-setting-id="community"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.COMMUNITY' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.COMMUNITY_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<div class="link-cards">
|
||||
<a
|
||||
class="link-card link-card--github"
|
||||
href="https://github.com/4gray/iptvnator"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
data-test-id="link-github"
|
||||
>
|
||||
<span class="link-card__glyph">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 0C5.374 0 0 5.373 0 12c0 5.302 3.438 9.8 8.207 11.387.6.112.793-.26.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23A11.52 11.52 0 0 1 12 5.803c1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576C20.565 21.796 24 17.3 24 12c0-6.627-5.373-12-12-12z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="link-card__text">
|
||||
<strong>GitHub</strong>
|
||||
<span>{{
|
||||
'SETTINGS.LINK_GITHUB_DESCRIPTION' | translate
|
||||
}}</span>
|
||||
</span>
|
||||
<mat-icon class="link-card__arrow">arrow_outward</mat-icon>
|
||||
</a>
|
||||
<a
|
||||
class="link-card link-card--blog"
|
||||
href="https://4gray.github.io/iptvnator/"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
data-test-id="link-blog"
|
||||
>
|
||||
<span class="link-card__glyph">
|
||||
<mat-icon>menu_book</mat-icon>
|
||||
</span>
|
||||
<span class="link-card__text">
|
||||
<strong>{{ 'SETTINGS.LINK_BLOG' | translate }}</strong>
|
||||
<span>{{
|
||||
'SETTINGS.LINK_BLOG_DESCRIPTION' | translate
|
||||
}}</span>
|
||||
</span>
|
||||
<mat-icon class="link-card__arrow">arrow_outward</mat-icon>
|
||||
</a>
|
||||
<a
|
||||
class="link-card link-card--telegram"
|
||||
href="https://t.me/iptvnator"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
data-test-id="link-telegram"
|
||||
>
|
||||
<span class="link-card__glyph">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M9.78 18.65l.28-4.23 7.68-6.92c.34-.31-.07-.46-.52-.19L7.74 13.3 3.64 12c-.88-.25-.89-.86.2-1.3l15.97-6.16c.73-.33 1.43.18 1.15 1.3l-2.72 12.81c-.19.91-.74 1.13-1.5.71L12.6 16.3l-1.99 1.93c-.23.23-.42.42-.83.42z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="link-card__text">
|
||||
<strong>Telegram</strong>
|
||||
<span>{{
|
||||
'SETTINGS.LINK_TELEGRAM_DESCRIPTION' | translate
|
||||
}}</span>
|
||||
</span>
|
||||
<mat-icon class="link-card__arrow">arrow_outward</mat-icon>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="setting-item setting-item--full-control"
|
||||
data-setting-id="support"
|
||||
>
|
||||
<div class="support-card" data-test-id="support-card">
|
||||
<div class="support-card__intro">
|
||||
<div class="support-card__emblem">
|
||||
<mat-icon>favorite</mat-icon>
|
||||
</div>
|
||||
<div class="support-card__copy">
|
||||
<h4>{{ 'SETTINGS.SUPPORT_TITLE' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.SUPPORT_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
<section class="settings-section">
|
||||
<div
|
||||
class="settings-card support-card"
|
||||
data-setting-id="support"
|
||||
data-test-id="support-card"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="support-card__copy">
|
||||
<h4>{{ 'SETTINGS.SUPPORT_TITLE' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.SUPPORT_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
<div class="support-card__actions">
|
||||
<a
|
||||
mat-stroked-button
|
||||
class="support-btn support-btn--sponsors"
|
||||
href="https://github.com/sponsors/4gray"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
data-test-id="support-sponsors"
|
||||
>
|
||||
<span class="support-btn__glyph">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="support-btn__text">
|
||||
<strong>GitHub Sponsors</strong>
|
||||
<small>{{
|
||||
'SETTINGS.SUPPORT_SPONSORS_HINT' | translate
|
||||
}}</small>
|
||||
</span>
|
||||
<mat-icon class="support-btn__glyph">favorite</mat-icon>
|
||||
GitHub Sponsors
|
||||
</a>
|
||||
<a
|
||||
mat-stroked-button
|
||||
class="support-btn support-btn--kofi"
|
||||
href="https://ko-fi.com/4gray"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
data-test-id="support-kofi"
|
||||
>
|
||||
<span class="support-btn__glyph">
|
||||
<mat-icon>local_cafe</mat-icon>
|
||||
</span>
|
||||
<span class="support-btn__text">
|
||||
<strong>Ko-fi</strong>
|
||||
<small>{{
|
||||
'SETTINGS.SUPPORT_KOFI_HINT' | translate
|
||||
}}</small>
|
||||
</span>
|
||||
<mat-icon class="support-btn__glyph">local_cafe</mat-icon>
|
||||
Ko-fi
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="settings-foot" data-setting-id="tmdb-credits" tabindex="-1">
|
||||
<img src="assets/images/tmdb-logo.svg" alt="TMDB" />
|
||||
<span>{{ 'SETTINGS.TMDB_ATTRIBUTION' | translate }}</span>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,262 @@
|
||||
// About page: version hero, update status, community links and support.
|
||||
app-settings-about-section {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
// ─── About ─────────────────────────────────────────────────────────────────
|
||||
.settings-hero {
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: 16px;
|
||||
padding: 18px 16px;
|
||||
}
|
||||
|
||||
.settings-hero__logo {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.settings-hero__text {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.settings-hero__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: var(--app-heading-color);
|
||||
}
|
||||
|
||||
.settings-hero__version {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
color: var(--app-body-color);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
.build-commit {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-hero__note {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: var(--settings-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.settings-hero__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.app-update-status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
color: var(--settings-muted);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.45;
|
||||
|
||||
strong {
|
||||
color: var(--app-heading-color);
|
||||
font-size: 13.5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
progress {
|
||||
width: min(100%, 320px);
|
||||
height: 8px;
|
||||
accent-color: var(--app-selection-color);
|
||||
}
|
||||
}
|
||||
|
||||
.app-update-status__channel {
|
||||
align-self: flex-start;
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--settings-chip-bg);
|
||||
color: var(--settings-chip-fg);
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.app-update-status--stale strong,
|
||||
.app-update-status--stale .app-update-status__channel {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.app-update-actions {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
button {
|
||||
app-region: no-drag;
|
||||
}
|
||||
}
|
||||
|
||||
// Project & community: three link tiles sharing one card.
|
||||
.link-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.link-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
padding: 14px 16px;
|
||||
color: var(--app-heading-color);
|
||||
text-decoration: none;
|
||||
|
||||
& + & {
|
||||
border-left: 1px solid var(--settings-item-border);
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
border-radius: 12px 0 0 12px;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-radius: 0 12px 12px 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--app-hover-overlay);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--app-selection-color);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
.link-card__glyph {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
background: var(--settings-chip-bg);
|
||||
color: var(--app-body-color);
|
||||
|
||||
svg,
|
||||
mat-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.link-card__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
|
||||
strong {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--settings-muted);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
}
|
||||
|
||||
.support-card {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.support-card__copy {
|
||||
min-width: 0;
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: var(--app-heading-color);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 3px 0 0;
|
||||
max-width: 56ch;
|
||||
color: var(--settings-muted);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.support-card__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
// Neutral buttons whose icon carries the colour.
|
||||
.support-btn--sponsors .support-btn__glyph {
|
||||
color: #ff6b9a;
|
||||
}
|
||||
|
||||
.support-btn--kofi .support-btn__glyph {
|
||||
color: #29abe0;
|
||||
}
|
||||
|
||||
// Pane width, see settings.component.scss.
|
||||
@container (max-width: 600px) {
|
||||
.settings-hero {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.settings-hero__actions {
|
||||
grid-column: 1 / -1;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.link-cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.link-card {
|
||||
& + & {
|
||||
border-left: 0;
|
||||
border-top: 1px solid var(--settings-item-border);
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
border-radius: 12px 12px 0 0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-radius: 0 0 12px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.support-card {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,11 @@ import {
|
||||
import { UpdateChannelOption } from './settings.models';
|
||||
import { markSectionForCheckOnFormEvents } from './settings-section-form-render';
|
||||
|
||||
interface SettingsUpdateStatusChip {
|
||||
readonly labelKey: string;
|
||||
readonly tone: 'accent' | 'success' | 'warn' | 'neutral';
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings-about-section',
|
||||
imports: [
|
||||
@@ -34,15 +39,7 @@ import { markSectionForCheckOnFormEvents } from './settings-section-form-render'
|
||||
templateUrl: './settings-about-section.component.html',
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styles: [
|
||||
':host { display: contents; }',
|
||||
'.version-block .build-commit { opacity: 0.65; font-size: 0.85em; }',
|
||||
'.app-update-channel { margin-top: 12px; }',
|
||||
'.app-update-channel mat-form-field { width: 100%; max-width: 320px; }',
|
||||
'.app-update-channel__note { display: block; margin-top: 4px; opacity: 0.75; font-size: 0.85em; }',
|
||||
'.app-update-status__channel { align-self: flex-start; padding: 2px 9px; border-radius: 999px; font-size: 0.72rem; font-weight: 600; letter-spacing: 0.05em; text-transform: uppercase; color: var(--mat-sys-on-surface-variant); background: color-mix(in srgb, var(--mat-sys-on-surface) 9%, transparent); }',
|
||||
'.app-update-status--stale strong, .app-update-status--stale .app-update-status__channel { opacity: 0.55; }',
|
||||
],
|
||||
styleUrls: ['./settings-about-section.component.scss'],
|
||||
})
|
||||
export class SettingsAboutSectionComponent {
|
||||
readonly isDesktop = input(false);
|
||||
@@ -85,6 +82,8 @@ export class SettingsAboutSectionComponent {
|
||||
readonly installAppUpdate = output<void>();
|
||||
readonly openManualAppUpdate = output<void>();
|
||||
readonly openAppUpdateReleaseNotes = output<void>();
|
||||
/** The nightly warning offers the backup page before switching. */
|
||||
readonly openBackup = output<void>();
|
||||
|
||||
readonly canSelectUpdateChannel = computed(
|
||||
() =>
|
||||
@@ -177,6 +176,38 @@ export class SettingsAboutSectionComponent {
|
||||
return channel ? this.channelLabelKey(channel) : null;
|
||||
});
|
||||
|
||||
/** The one-word verdict beside the app name in the hero. */
|
||||
readonly updateStatusChip = computed<SettingsUpdateStatusChip | null>(
|
||||
() => {
|
||||
switch (this.appUpdateStatus()?.status) {
|
||||
case ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available:
|
||||
case ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloading:
|
||||
case ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded:
|
||||
return {
|
||||
labelKey: 'SETTINGS.UPDATE_AVAILABLE_CHIP',
|
||||
tone: 'accent',
|
||||
};
|
||||
case ELECTRON_BRIDGE_APP_UPDATE_STATUSES.NotAvailable:
|
||||
return {
|
||||
labelKey: 'SETTINGS.UPDATE_UP_TO_DATE',
|
||||
tone: 'success',
|
||||
};
|
||||
case ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Checking:
|
||||
return {
|
||||
labelKey: 'SETTINGS.APP_UPDATE_CHECKING',
|
||||
tone: 'neutral',
|
||||
};
|
||||
case ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Error:
|
||||
return {
|
||||
labelKey: 'SETTINGS.APP_UPDATE_ERROR',
|
||||
tone: 'warn',
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
readonly isAppUpdateBusy = computed(() => {
|
||||
const status = this.appUpdateStatus()?.status;
|
||||
|
||||
|
||||
@@ -1,73 +1,173 @@
|
||||
<section class="settings-group" id="backup">
|
||||
<header class="settings-group__header">
|
||||
<div class="settings-group__header-icon">
|
||||
<mat-icon>backup</mat-icon>
|
||||
</div>
|
||||
<div class="settings-group__header-text">
|
||||
<h3>{{ 'SETTINGS.BACKUP' | translate }}</h3>
|
||||
<p>{{ 'SETTINGS.BACKUP_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
<h2>{{ 'SETTINGS.NAV_BACKUP' | translate }}</h2>
|
||||
<p>{{ 'SETTINGS.BACKUP_DESCRIPTION' | translate }}</p>
|
||||
</header>
|
||||
|
||||
<div class="setting-item" data-setting-id="import-export-data">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.IMPORT_EXPORT_DATA' | translate }}</h4>
|
||||
<p>
|
||||
{{ 'SETTINGS.IMPORT_EXPORT_DATA_DESCRIPTION' | translate }}
|
||||
</p>
|
||||
<section class="settings-section">
|
||||
<div class="settings-section__header">
|
||||
<h3>{{ 'SETTINGS.BACKUP' | translate }}</h3>
|
||||
</div>
|
||||
<div class="setting-item__control inline-actions">
|
||||
<button
|
||||
mat-button
|
||||
class="inline-actions__button"
|
||||
[disabled]="isRemovingAllPlaylists() || isExportingData()"
|
||||
(click)="
|
||||
$event.preventDefault();
|
||||
$event.stopPropagation();
|
||||
importData.emit()
|
||||
"
|
||||
>
|
||||
{{ 'SETTINGS.IMPORT_DATA' | translate }}
|
||||
</button>
|
||||
<button
|
||||
mat-button
|
||||
class="inline-actions__button"
|
||||
[class.inline-actions__button--busy]="isExportingData()"
|
||||
[disabled]="isRemovingAllPlaylists() || isExportingData()"
|
||||
[attr.aria-busy]="isExportingData()"
|
||||
(click)="
|
||||
$event.preventDefault();
|
||||
$event.stopPropagation();
|
||||
exportData.emit()
|
||||
"
|
||||
>
|
||||
@if (isExportingData()) {
|
||||
<mat-progress-spinner
|
||||
class="button-spinner"
|
||||
diameter="16"
|
||||
mode="indeterminate"
|
||||
/>
|
||||
<span class="inline-actions__label">{{
|
||||
'SETTINGS.EXPORTING_DATA' | translate
|
||||
}}</span>
|
||||
} @else {
|
||||
<span class="inline-actions__label">{{
|
||||
'SETTINGS.EXPORT_DATA' | translate
|
||||
}}</span>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-card">
|
||||
<div class="setting-item" data-setting-id="import-export-data">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.IMPORT_EXPORT_DATA' | translate }}</h4>
|
||||
<p>
|
||||
{{ 'SETTINGS.IMPORT_EXPORT_DATA_DESCRIPTION' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control inline-actions">
|
||||
<button
|
||||
mat-stroked-button
|
||||
class="inline-actions__button"
|
||||
[disabled]="isRemovingAllPlaylists() || isExportingData()"
|
||||
(click)="
|
||||
$event.preventDefault();
|
||||
$event.stopPropagation();
|
||||
importData.emit()
|
||||
"
|
||||
>
|
||||
{{ 'SETTINGS.IMPORT_DATA' | translate }}
|
||||
</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
class="inline-actions__button"
|
||||
[class.inline-actions__button--busy]="isExportingData()"
|
||||
[disabled]="isRemovingAllPlaylists() || isExportingData()"
|
||||
[attr.aria-busy]="isExportingData()"
|
||||
(click)="
|
||||
$event.preventDefault();
|
||||
$event.stopPropagation();
|
||||
exportData.emit()
|
||||
"
|
||||
>
|
||||
@if (isExportingData()) {
|
||||
<mat-progress-spinner
|
||||
class="button-spinner"
|
||||
diameter="16"
|
||||
mode="indeterminate"
|
||||
/>
|
||||
<span class="inline-actions__label">{{
|
||||
'SETTINGS.EXPORTING_DATA' | translate
|
||||
}}</span>
|
||||
} @else {
|
||||
<span class="inline-actions__label">{{
|
||||
'SETTINGS.EXPORT_DATA' | translate
|
||||
}}</span>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (isPwa()) {
|
||||
<div class="settings-note">
|
||||
{{ 'SETTINGS.EPG_NOTE' | translate }}
|
||||
<a
|
||||
href="https://github.com/4gray/iptvnator/releases"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>{{ 'SETTINGS.EPG_NOTE_URL_TEXT' | translate }}</a
|
||||
>
|
||||
@if (isPwa()) {
|
||||
<div class="setting-item__sub">
|
||||
<div class="settings-callout settings-callout--info">
|
||||
<mat-icon>info</mat-icon>
|
||||
<div>
|
||||
{{ 'SETTINGS.EPG_NOTE' | translate }}
|
||||
<a
|
||||
href="https://github.com/4gray/iptvnator/releases"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>{{ 'SETTINGS.EPG_NOTE_URL_TEXT' | translate }}</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<!-- The former Reset page: one destructive action, so it is this
|
||||
page's last card rather than a page of its own. -->
|
||||
<section class="settings-section">
|
||||
<div class="settings-section__header">
|
||||
<h3>{{ 'SETTINGS.RESET' | translate }}</h3>
|
||||
</div>
|
||||
<p class="settings-section__desc">
|
||||
{{ 'SETTINGS.RESET_DESCRIPTION' | translate }}
|
||||
</p>
|
||||
<div class="settings-card settings-card--danger">
|
||||
<div
|
||||
class="setting-item danger-zone"
|
||||
data-setting-id="remove-all-playlists"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.REMOVE_ALL' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.REMOVE_ALL_DESCRIPTION' | translate }}</p>
|
||||
<div class="danger-zone__summary" aria-live="polite">
|
||||
<span class="danger-zone__summary-pill">
|
||||
<mat-icon class="danger-zone__summary-icon">{{
|
||||
sourceIcons.m3u
|
||||
}}</mat-icon>
|
||||
<span class="danger-zone__summary-label">{{
|
||||
'HOME.PLAYLIST_TYPES.M3U' | translate
|
||||
}}</span>
|
||||
<span class="danger-zone__summary-count">{{
|
||||
playlistDeleteSummary().m3u
|
||||
}}</span>
|
||||
</span>
|
||||
<span class="danger-zone__summary-pill">
|
||||
<mat-icon class="danger-zone__summary-icon">{{
|
||||
sourceIcons.xtream
|
||||
}}</mat-icon>
|
||||
<span class="danger-zone__summary-label">{{
|
||||
'HOME.PLAYLIST_TYPES.XTREAM' | translate
|
||||
}}</span>
|
||||
<span class="danger-zone__summary-count">{{
|
||||
playlistDeleteSummary().xtream
|
||||
}}</span>
|
||||
</span>
|
||||
<span class="danger-zone__summary-pill">
|
||||
<mat-icon class="danger-zone__summary-icon">{{
|
||||
sourceIcons.stalker
|
||||
}}</mat-icon>
|
||||
<span class="danger-zone__summary-label">{{
|
||||
'HOME.PLAYLIST_TYPES.STALKER' | translate
|
||||
}}</span>
|
||||
<span class="danger-zone__summary-count">{{
|
||||
playlistDeleteSummary().stalker
|
||||
}}</span>
|
||||
</span>
|
||||
</div>
|
||||
@if (removeAllProgressLabel(); as progressLabel) {
|
||||
<p class="danger-zone__progress">
|
||||
{{ progressLabel }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<button
|
||||
mat-stroked-button
|
||||
class="danger-zone__button app-destructive-button"
|
||||
[class.danger-zone__button--busy]="
|
||||
isRemovingAllPlaylists()
|
||||
"
|
||||
[disabled]="!canRemoveAllPlaylists()"
|
||||
[attr.aria-busy]="isRemovingAllPlaylists()"
|
||||
(click)="
|
||||
$event.preventDefault();
|
||||
$event.stopPropagation();
|
||||
removeAll.emit()
|
||||
"
|
||||
>
|
||||
@if (isRemovingAllPlaylists()) {
|
||||
<mat-progress-spinner
|
||||
class="button-spinner"
|
||||
diameter="16"
|
||||
mode="indeterminate"
|
||||
/>
|
||||
<span class="inline-actions__label">{{
|
||||
'SETTINGS.REMOVE_ALL_IN_PROGRESS' | translate
|
||||
}}</span>
|
||||
} @else {
|
||||
<span class="inline-actions__label">{{
|
||||
'SETTINGS.REMOVE_ALL_BUTTON' | translate
|
||||
}}</span>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
@@ -0,0 +1,50 @@
|
||||
// Backup & data page: the playlist reset row with its summary pills.
|
||||
app-settings-backup-section {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
// ─── Reset (Backup & data) ────────────────────────────────────────────────
|
||||
.danger-zone__summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin: 10px 0 0;
|
||||
}
|
||||
|
||||
.danger-zone__summary-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 26px;
|
||||
padding: 2px 8px 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--settings-chip-bg);
|
||||
color: var(--app-heading-color);
|
||||
font-size: 11.5px;
|
||||
|
||||
.danger-zone__summary-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
font-size: 14px;
|
||||
color: var(--app-body-color);
|
||||
}
|
||||
|
||||
.danger-zone__summary-count {
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
.danger-zone__progress {
|
||||
margin: 8px 0 0;
|
||||
color: var(--settings-muted);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.danger-zone__button {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.danger-zone__button--busy {
|
||||
background: color-mix(in srgb, var(--mat-sys-error) 12%, transparent);
|
||||
}
|
||||
@@ -9,7 +9,13 @@ import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { SOURCE_TYPE_ICONS } from '@iptvnator/shared/interfaces';
|
||||
import { SettingsPlaylistDeleteSummary } from './settings.models';
|
||||
|
||||
/**
|
||||
* Backup & data: import/export plus the one destructive reset action, which
|
||||
* used to be a page of its own.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-settings-backup-section',
|
||||
imports: [
|
||||
@@ -21,13 +27,19 @@ import { TranslateModule } from '@ngx-translate/core';
|
||||
templateUrl: './settings-backup-section.component.html',
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styles: [':host { display: contents; }'],
|
||||
styleUrls: ['./settings-backup-section.component.scss'],
|
||||
})
|
||||
export class SettingsBackupSectionComponent {
|
||||
readonly sourceIcons = SOURCE_TYPE_ICONS;
|
||||
readonly isPwa = input(false);
|
||||
readonly isRemovingAllPlaylists = input(false);
|
||||
readonly isExportingData = input(false);
|
||||
readonly canRemoveAllPlaylists = input(false);
|
||||
readonly playlistDeleteSummary =
|
||||
input.required<SettingsPlaylistDeleteSummary>();
|
||||
readonly removeAllProgressLabel = input<string | null>(null);
|
||||
|
||||
readonly importData = output<void>();
|
||||
readonly exportData = output<void>();
|
||||
readonly removeAll = output<void>();
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { createSettingsForm } from './settings-form.utils';
|
||||
import { FormBuilder } from '@angular/forms';
|
||||
import {
|
||||
diffSettingsValues,
|
||||
sectionOfSettingsPath,
|
||||
SETTINGS_CONTROL_SECTIONS,
|
||||
SETTINGS_RESTART_CONTROLS,
|
||||
} from './settings-change-tracking';
|
||||
|
||||
describe('settings change tracking', () => {
|
||||
it('lists the leaf values that differ, nested groups per leaf', () => {
|
||||
const saved = {
|
||||
theme: 'dark',
|
||||
dashboardRails: { hero: true, continueWatching: true },
|
||||
epgUrl: ['a'],
|
||||
tmdb: { enabled: false, apiKey: '' },
|
||||
};
|
||||
const current = {
|
||||
theme: 'light',
|
||||
dashboardRails: { hero: false, continueWatching: false },
|
||||
epgUrl: ['a', 'b'],
|
||||
tmdb: { enabled: false, apiKey: '' },
|
||||
};
|
||||
|
||||
expect(diffSettingsValues(current, saved)).toEqual([
|
||||
'theme',
|
||||
'dashboardRails.hero',
|
||||
'dashboardRails.continueWatching',
|
||||
'epgUrl',
|
||||
]);
|
||||
expect(diffSettingsValues(saved, saved)).toEqual([]);
|
||||
});
|
||||
|
||||
it('treats a missing value and null alike, and arrays as one value', () => {
|
||||
expect(diffSettingsValues({ a: null }, { a: undefined })).toEqual([]);
|
||||
expect(diffSettingsValues({ a: [1, 2] }, { a: [2, 1] })).toEqual([
|
||||
'a',
|
||||
]);
|
||||
expect(diffSettingsValues({ a: 1 }, {})).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('maps a changed path to the page it is edited on', () => {
|
||||
expect(sectionOfSettingsPath('dashboardRails.hero')).toBe('dashboard');
|
||||
expect(sectionOfSettingsPath('tmdb.apiKey')).toBe('tmdb');
|
||||
expect(sectionOfSettingsPath('showCaptions')).toBe('playback');
|
||||
expect(sectionOfSettingsPath('unknownControl')).toBeNull();
|
||||
});
|
||||
|
||||
it('maps every control of the settings form to a page', () => {
|
||||
const form = createSettingsForm(new FormBuilder(), true);
|
||||
const unmapped = Object.keys(form.controls).filter(
|
||||
(control) => !(control in SETTINGS_CONTROL_SECTIONS)
|
||||
);
|
||||
|
||||
expect(unmapped).toEqual([]);
|
||||
});
|
||||
|
||||
it('names the row label for every restart control', () => {
|
||||
for (const control of Object.keys(SETTINGS_RESTART_CONTROLS)) {
|
||||
expect(control in SETTINGS_CONTROL_SECTIONS).toBe(true);
|
||||
expect(SETTINGS_RESTART_CONTROLS[control]).toMatch(/^SETTINGS\./);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Which settings page each top-level form control is edited on. The save
|
||||
* bar counts staged changes and the navigation marks their pages with it.
|
||||
*/
|
||||
export const SETTINGS_CONTROL_SECTIONS: Readonly<Record<string, string>> = {
|
||||
language: 'general',
|
||||
theme: 'general',
|
||||
coverSize: 'general',
|
||||
showCoverTitles: 'general',
|
||||
startupBehavior: 'general',
|
||||
startupWindowMode: 'general',
|
||||
stripCountryPrefix: 'general',
|
||||
portalConnectivityGuard: 'general',
|
||||
player: 'playback',
|
||||
streamFormat: 'playback',
|
||||
showCaptions: 'playback',
|
||||
webPlayerSharedControls: 'playback',
|
||||
playerAmbientMode: 'playback',
|
||||
playerUpNextRail: 'playback',
|
||||
playerUpNextCard: 'playback',
|
||||
detailTrailerBackdrop: 'playback',
|
||||
fullscreenChannelPanel: 'playback',
|
||||
vodAutoFailover: 'playback',
|
||||
openStreamOnDoubleClick: 'playback',
|
||||
showExternalPlaybackBar: 'playback',
|
||||
embeddedMpvFrameCopy: 'playback',
|
||||
embeddedMpvExtraOptions: 'playback',
|
||||
embeddedMpvAutoReconnect: 'playback',
|
||||
recordingFolder: 'playback',
|
||||
mpvPlayerPath: 'playback',
|
||||
mpvPlayerArguments: 'playback',
|
||||
mpvReuseInstance: 'playback',
|
||||
vlcPlayerPath: 'playback',
|
||||
vlcPlayerArguments: 'playback',
|
||||
vlcReuseInstance: 'playback',
|
||||
epgUrl: 'epg',
|
||||
epgViewMode: 'epg',
|
||||
epgOffsetMinutes: 'epg',
|
||||
preferUploadedEpgOverXtream: 'epg',
|
||||
showDashboard: 'dashboard',
|
||||
dashboardRails: 'dashboard',
|
||||
remoteControl: 'remote-control',
|
||||
remoteControlPort: 'remote-control',
|
||||
tmdb: 'tmdb',
|
||||
m3uVodDetails: 'tmdb',
|
||||
updateChannel: 'about',
|
||||
};
|
||||
|
||||
/**
|
||||
* Controls whose saved value only takes effect after the app restarts,
|
||||
* with the label of the row that carries the restart chip.
|
||||
*/
|
||||
export const SETTINGS_RESTART_CONTROLS: Readonly<Record<string, string>> = {
|
||||
startupWindowMode: 'SETTINGS.STARTUP_WINDOW_MODE',
|
||||
embeddedMpvFrameCopy: 'SETTINGS.EMBEDDED_MPV_FRAME_COPY',
|
||||
};
|
||||
|
||||
/**
|
||||
* Dotted paths of the leaf values that differ between the staged form value
|
||||
* and the saved one. Nested groups (dashboard rails, TMDB) count per leaf,
|
||||
* so "3 unsaved changes" means three switches, not one group.
|
||||
*/
|
||||
export function diffSettingsValues(
|
||||
current: unknown,
|
||||
saved: unknown,
|
||||
path = ''
|
||||
): string[] {
|
||||
if (isPlainObject(current) && isPlainObject(saved)) {
|
||||
const keys = new Set([...Object.keys(current), ...Object.keys(saved)]);
|
||||
const changed: string[] = [];
|
||||
for (const key of keys) {
|
||||
changed.push(
|
||||
...diffSettingsValues(
|
||||
current[key],
|
||||
saved[key],
|
||||
path ? `${path}.${key}` : key
|
||||
)
|
||||
);
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
return JSON.stringify(current ?? null) === JSON.stringify(saved ?? null)
|
||||
? []
|
||||
: [path];
|
||||
}
|
||||
|
||||
/** Section page of a changed path, `null` for an unmapped control. */
|
||||
export function sectionOfSettingsPath(path: string): string | null {
|
||||
return SETTINGS_CONTROL_SECTIONS[path.split('.')[0]] ?? null;
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return (
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
);
|
||||
}
|
||||
@@ -1,212 +1,236 @@
|
||||
<section class="settings-group" id="dashboard" [formGroup]="form()">
|
||||
<header class="settings-group__header">
|
||||
<div class="settings-group__header-icon">
|
||||
<mat-icon>dashboard</mat-icon>
|
||||
</div>
|
||||
<div class="settings-group__header-text">
|
||||
<h3>{{ 'SETTINGS.DASHBOARD' | translate }}</h3>
|
||||
<p>{{ 'SETTINGS.DASHBOARD_SUBTITLE' | translate }}</p>
|
||||
</div>
|
||||
<h2>{{ 'SETTINGS.DASHBOARD' | translate }}</h2>
|
||||
<p>{{ 'SETTINGS.DASHBOARD_SUBTITLE' | translate }}</p>
|
||||
</header>
|
||||
|
||||
<div class="setting-item" data-setting-id="show-dashboard">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.SHOW_DASHBOARD' | translate }}</h4>
|
||||
<p>
|
||||
{{ 'SETTINGS.SHOW_DASHBOARD_DESCRIPTION' | translate }}
|
||||
</p>
|
||||
<section class="settings-section">
|
||||
<div class="settings-card">
|
||||
<div class="setting-item" data-setting-id="show-dashboard">
|
||||
<div class="setting-item__meta">
|
||||
<h4 id="show-dashboard-label">
|
||||
{{ 'SETTINGS.SHOW_DASHBOARD' | translate }}
|
||||
</h4>
|
||||
<p>
|
||||
{{ 'SETTINGS.SHOW_DASHBOARD_DESCRIPTION' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-slide-toggle
|
||||
formControlName="showDashboard"
|
||||
aria-labelledby="show-dashboard-label"
|
||||
data-test-id="toggle-show-dashboard"
|
||||
></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox
|
||||
formControlName="showDashboard"
|
||||
data-test-id="toggle-show-dashboard"
|
||||
></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div formGroupName="dashboardRails">
|
||||
<div class="setting-item" data-setting-id="dashboard-hero">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.DASHBOARD_HERO' | translate }}</h4>
|
||||
<p>
|
||||
{{ 'SETTINGS.DASHBOARD_HERO_DESCRIPTION' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox
|
||||
formControlName="hero"
|
||||
data-test-id="toggle-dashboard-hero"
|
||||
></mat-checkbox>
|
||||
</div>
|
||||
<section class="settings-section">
|
||||
<div class="settings-section__header">
|
||||
<h3>{{ 'SETTINGS.CARD_RAILS' | translate }}</h3>
|
||||
</div>
|
||||
<div class="settings-card" formGroupName="dashboardRails">
|
||||
<div class="setting-item" data-setting-id="dashboard-hero">
|
||||
<div class="setting-item__meta">
|
||||
<h4 id="dashboard-hero-label">
|
||||
{{ 'SETTINGS.DASHBOARD_HERO' | translate }}
|
||||
</h4>
|
||||
<p>
|
||||
{{ 'SETTINGS.DASHBOARD_HERO_DESCRIPTION' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-slide-toggle
|
||||
formControlName="hero"
|
||||
aria-labelledby="dashboard-hero-label"
|
||||
data-test-id="toggle-dashboard-hero"
|
||||
></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="setting-item"
|
||||
data-setting-id="dashboard-rail-continue-watching"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.DASHBOARD_RAIL_CONTINUE' | translate }}</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.DASHBOARD_RAIL_CONTINUE_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
<div
|
||||
class="setting-item"
|
||||
data-setting-id="dashboard-rail-continue-watching"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<h4 id="dashboard-rail-continue-watching-label">
|
||||
{{ 'SETTINGS.DASHBOARD_RAIL_CONTINUE' | translate }}
|
||||
</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.DASHBOARD_RAIL_CONTINUE_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-slide-toggle
|
||||
formControlName="continueWatching"
|
||||
aria-labelledby="dashboard-rail-continue-watching-label"
|
||||
data-test-id="toggle-dashboard-rail-continue-watching"
|
||||
></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox
|
||||
formControlName="continueWatching"
|
||||
data-test-id="toggle-dashboard-rail-continue-watching"
|
||||
></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="setting-item"
|
||||
data-setting-id="dashboard-rail-live-favorites"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<h4>
|
||||
{{ 'SETTINGS.DASHBOARD_RAIL_LIVE_FAVORITES' | translate }}
|
||||
</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.DASHBOARD_RAIL_LIVE_FAVORITES_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
<div
|
||||
class="setting-item"
|
||||
data-setting-id="dashboard-rail-live-favorites"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<h4 id="dashboard-rail-live-favorites-label">
|
||||
{{ 'SETTINGS.DASHBOARD_RAIL_LIVE_FAVORITES' | translate }}
|
||||
</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.DASHBOARD_RAIL_LIVE_FAVORITES_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-slide-toggle
|
||||
formControlName="liveFavorites"
|
||||
aria-labelledby="dashboard-rail-live-favorites-label"
|
||||
data-test-id="toggle-dashboard-rail-live-favorites"
|
||||
></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox
|
||||
formControlName="liveFavorites"
|
||||
data-test-id="toggle-dashboard-rail-live-favorites"
|
||||
></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" data-setting-id="dashboard-rail-recent-live">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.DASHBOARD_RAIL_RECENT_LIVE' | translate }}</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.DASHBOARD_RAIL_RECENT_LIVE_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
<div class="setting-item" data-setting-id="dashboard-rail-recent-live">
|
||||
<div class="setting-item__meta">
|
||||
<h4 id="dashboard-rail-recent-live-label">
|
||||
{{ 'SETTINGS.DASHBOARD_RAIL_RECENT_LIVE' | translate }}
|
||||
</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.DASHBOARD_RAIL_RECENT_LIVE_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-slide-toggle
|
||||
formControlName="recentlyWatchedLive"
|
||||
aria-labelledby="dashboard-rail-recent-live-label"
|
||||
data-test-id="toggle-dashboard-rail-recently-watched-live"
|
||||
></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox
|
||||
formControlName="recentlyWatchedLive"
|
||||
data-test-id="toggle-dashboard-rail-recently-watched-live"
|
||||
></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" data-setting-id="dashboard-rail-favorite-vod">
|
||||
<div class="setting-item__meta">
|
||||
<h4>
|
||||
{{ 'SETTINGS.DASHBOARD_RAIL_FAVORITE_VOD' | translate }}
|
||||
</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.DASHBOARD_RAIL_FAVORITE_VOD_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
<div class="setting-item" data-setting-id="dashboard-rail-favorite-vod">
|
||||
<div class="setting-item__meta">
|
||||
<h4 id="dashboard-rail-favorite-vod-label">
|
||||
{{ 'SETTINGS.DASHBOARD_RAIL_FAVORITE_VOD' | translate }}
|
||||
</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.DASHBOARD_RAIL_FAVORITE_VOD_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-slide-toggle
|
||||
formControlName="favoriteMoviesAndSeries"
|
||||
aria-labelledby="dashboard-rail-favorite-vod-label"
|
||||
data-test-id="toggle-dashboard-rail-favorite-movies-and-series"
|
||||
></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox
|
||||
formControlName="favoriteMoviesAndSeries"
|
||||
data-test-id="toggle-dashboard-rail-favorite-movies-and-series"
|
||||
></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" data-setting-id="dashboard-rail-sources">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.DASHBOARD_RAIL_SOURCES' | translate }}</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.DASHBOARD_RAIL_SOURCES_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
<div class="setting-item" data-setting-id="dashboard-rail-sources">
|
||||
<div class="setting-item__meta">
|
||||
<h4 id="dashboard-rail-sources-label">
|
||||
{{ 'SETTINGS.DASHBOARD_RAIL_SOURCES' | translate }}
|
||||
</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.DASHBOARD_RAIL_SOURCES_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-slide-toggle
|
||||
formControlName="recentSources"
|
||||
aria-labelledby="dashboard-rail-sources-label"
|
||||
data-test-id="toggle-dashboard-rail-recent-sources"
|
||||
></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox
|
||||
formControlName="recentSources"
|
||||
data-test-id="toggle-dashboard-rail-recent-sources"
|
||||
></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" data-setting-id="dashboard-rail-xtream-added">
|
||||
<div class="setting-item__meta">
|
||||
<h4>
|
||||
{{ 'SETTINGS.DASHBOARD_RAIL_XTREAM_ADDED' | translate }}
|
||||
</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
<div class="setting-item" data-setting-id="dashboard-rail-xtream-added">
|
||||
<div class="setting-item__meta">
|
||||
<h4 id="dashboard-rail-xtream-added-label">
|
||||
{{ 'SETTINGS.DASHBOARD_RAIL_XTREAM_ADDED' | translate }}
|
||||
</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-slide-toggle
|
||||
formControlName="xtreamRecentlyAdded"
|
||||
aria-labelledby="dashboard-rail-xtream-added-label"
|
||||
data-test-id="toggle-dashboard-rail-xtream-recently-added"
|
||||
></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox
|
||||
formControlName="xtreamRecentlyAdded"
|
||||
data-test-id="toggle-dashboard-rail-xtream-recently-added"
|
||||
></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="setting-item"
|
||||
data-setting-id="dashboard-rail-tmdb-trending"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<h4>
|
||||
{{ 'SETTINGS.DASHBOARD_RAIL_TMDB_TRENDING' | translate }}
|
||||
</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.DASHBOARD_RAIL_TMDB_TRENDING_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
<div
|
||||
class="setting-item"
|
||||
data-setting-id="dashboard-rail-tmdb-trending"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<h4 id="dashboard-rail-tmdb-trending-label">
|
||||
{{ 'SETTINGS.DASHBOARD_RAIL_TMDB_TRENDING' | translate }}
|
||||
</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.DASHBOARD_RAIL_TMDB_TRENDING_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-slide-toggle
|
||||
formControlName="tmdbTrending"
|
||||
aria-labelledby="dashboard-rail-tmdb-trending-label"
|
||||
data-test-id="toggle-dashboard-rail-tmdb-trending"
|
||||
></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox
|
||||
formControlName="tmdbTrending"
|
||||
data-test-id="toggle-dashboard-rail-tmdb-trending"
|
||||
></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="setting-item"
|
||||
data-setting-id="dashboard-rail-tmdb-recommendations"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<h4>
|
||||
{{
|
||||
'SETTINGS.DASHBOARD_RAIL_TMDB_RECOMMENDATIONS'
|
||||
| translate
|
||||
}}
|
||||
</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.DASHBOARD_RAIL_TMDB_RECOMMENDATIONS_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox
|
||||
formControlName="tmdbRecommendations"
|
||||
data-test-id="toggle-dashboard-rail-tmdb-recommendations"
|
||||
></mat-checkbox>
|
||||
<div
|
||||
class="setting-item"
|
||||
data-setting-id="dashboard-rail-tmdb-recommendations"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<h4 id="dashboard-rail-tmdb-recommendations-label">
|
||||
{{
|
||||
'SETTINGS.DASHBOARD_RAIL_TMDB_RECOMMENDATIONS'
|
||||
| translate
|
||||
}}
|
||||
</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.DASHBOARD_RAIL_TMDB_RECOMMENDATIONS_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-slide-toggle
|
||||
formControlName="tmdbRecommendations"
|
||||
aria-labelledby="dashboard-rail-tmdb-recommendations-label"
|
||||
data-test-id="toggle-dashboard-rail-tmdb-recommendations"
|
||||
></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
@@ -6,8 +6,8 @@ import {
|
||||
ChangeDetectionStrategy,
|
||||
} from '@angular/core';
|
||||
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { markSectionForCheckOnFormEvents } from './settings-section-form-render';
|
||||
|
||||
@@ -15,8 +15,8 @@ import { markSectionForCheckOnFormEvents } from './settings-section-form-render'
|
||||
selector: 'app-settings-dashboard-section',
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatCheckboxModule,
|
||||
MatIconModule,
|
||||
MatSlideToggleModule,
|
||||
ReactiveFormsModule,
|
||||
TranslateModule,
|
||||
],
|
||||
|
||||
@@ -1,238 +1,330 @@
|
||||
<section class="settings-group" id="epg" [formGroup]="form()">
|
||||
<header class="settings-group__header">
|
||||
<div class="settings-group__header-icon">
|
||||
<mat-icon>list_alt</mat-icon>
|
||||
</div>
|
||||
<div class="settings-group__header-text">
|
||||
<h3>{{ 'SETTINGS.EPG_SOURCES' | translate }}</h3>
|
||||
<p>{{ 'SETTINGS.EPG_SOURCES_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
<h2>{{ 'SETTINGS.NAV_EPG' | translate }}</h2>
|
||||
<p>{{ 'SETTINGS.EPG_SUBTITLE' | translate }}</p>
|
||||
</header>
|
||||
|
||||
<div
|
||||
class="setting-item setting-item--full-control"
|
||||
data-setting-id="epg-view-mode"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.EPG_VIEW_MODE' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.EPG_VIEW_MODE_DESCRIPTION' | translate }}</p>
|
||||
<section class="settings-section">
|
||||
<div class="settings-section__header">
|
||||
<h3>{{ 'SETTINGS.CARD_GUIDE' | translate }}</h3>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<div
|
||||
class="theme-switcher"
|
||||
role="radiogroup"
|
||||
[attr.aria-label]="'SETTINGS.EPG_VIEW_MODE' | translate"
|
||||
data-test-id="select-epg-view-mode"
|
||||
>
|
||||
@for (option of epgViewModeOptions(); track option.value) {
|
||||
<button
|
||||
type="button"
|
||||
class="theme-switcher__option"
|
||||
[class.theme-switcher__option--selected]="
|
||||
form().value.epgViewMode === option.value
|
||||
"
|
||||
[attr.aria-checked]="
|
||||
form().value.epgViewMode === option.value
|
||||
"
|
||||
[attr.data-test-id]="'epg-view-mode-' + option.value"
|
||||
role="radio"
|
||||
(click)="selectEpgViewMode.emit(option.value)"
|
||||
<div class="settings-card">
|
||||
<div class="setting-item" data-setting-id="epg-view-mode">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.EPG_VIEW_MODE' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.EPG_VIEW_MODE_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<div
|
||||
class="theme-switcher"
|
||||
role="radiogroup"
|
||||
[attr.aria-label]="'SETTINGS.EPG_VIEW_MODE' | translate"
|
||||
data-test-id="select-epg-view-mode"
|
||||
>
|
||||
<mat-icon>{{ option.icon }}</mat-icon>
|
||||
<span>{{ option.labelKey | translate }}</span>
|
||||
</button>
|
||||
}
|
||||
@for (option of epgViewModeOptions(); track option.value) {
|
||||
<button
|
||||
type="button"
|
||||
class="theme-switcher__option"
|
||||
[class.theme-switcher__option--selected]="
|
||||
form().value.epgViewMode === option.value
|
||||
"
|
||||
[attr.aria-checked]="
|
||||
form().value.epgViewMode === option.value
|
||||
"
|
||||
[attr.data-test-id]="
|
||||
'epg-view-mode-' + option.value
|
||||
"
|
||||
role="radio"
|
||||
(click)="selectEpgViewMode.emit(option.value)"
|
||||
>
|
||||
<mat-icon>{{ option.icon }}</mat-icon>
|
||||
<span>{{ option.labelKey | translate }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" data-setting-id="epg-offset">
|
||||
<div class="setting-item__meta">
|
||||
<h4 id="epg-offset-label">
|
||||
{{ 'SETTINGS.EPG_OFFSET_TITLE' | translate }}
|
||||
</h4>
|
||||
<p>{{ 'SETTINGS.EPG_OFFSET_HINT' | translate }}</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__control--stack">
|
||||
<div
|
||||
class="settings-stepper"
|
||||
[class.settings-stepper--invalid]="
|
||||
offsetControl?.invalid
|
||||
"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="settings-stepper__button"
|
||||
[attr.aria-label]="
|
||||
'SETTINGS.EPG_OFFSET_STEP_DOWN'
|
||||
| translate: { minutes: offsetStep }
|
||||
"
|
||||
(click)="stepOffset(-offsetStep)"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
min="-720"
|
||||
max="720"
|
||||
[step]="offsetStep"
|
||||
formControlName="epgOffsetMinutes"
|
||||
aria-labelledby="epg-offset-label"
|
||||
data-test-id="epg-offset-minutes"
|
||||
/>
|
||||
<span class="settings-stepper__unit">min</span>
|
||||
<button
|
||||
type="button"
|
||||
class="settings-stepper__button"
|
||||
[attr.aria-label]="
|
||||
'SETTINGS.EPG_OFFSET_STEP_UP'
|
||||
| translate: { minutes: offsetStep }
|
||||
"
|
||||
(click)="stepOffset(offsetStep)"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
@if (offsetControl?.invalid) {
|
||||
<p class="settings-field-error" role="alert">
|
||||
{{ 'SETTINGS.EPG_OFFSET_ERROR' | translate }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div
|
||||
class="setting-item setting-item--full-control"
|
||||
data-setting-id="epg-offset"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.EPG_OFFSET_TITLE' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.EPG_OFFSET_HINT' | translate }}</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>{{
|
||||
'SETTINGS.EPG_OFFSET_LABEL' | translate
|
||||
}}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="number"
|
||||
min="-720"
|
||||
max="720"
|
||||
step="15"
|
||||
formControlName="epgOffsetMinutes"
|
||||
data-test-id="epg-offset-minutes"
|
||||
/>
|
||||
<mat-error>{{
|
||||
'SETTINGS.EPG_OFFSET_ERROR' | translate
|
||||
}}</mat-error>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="setting-item setting-item--full-control"
|
||||
data-setting-id="epg-sources"
|
||||
>
|
||||
<div class="setting-item__meta" data-test-id="epg-source-formats">
|
||||
<h4>{{ 'SETTINGS.EPG_SOURCE_LIST_TITLE' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.EPG_SOURCE_FORMATS_HINT' | translate }}</p>
|
||||
<p class="epg-sources__formats-examples">
|
||||
<span>{{
|
||||
'SETTINGS.EPG_SOURCE_FORMATS_EXAMPLES' | translate
|
||||
<section class="settings-section">
|
||||
<div class="settings-section__header">
|
||||
<h3>{{ 'SETTINGS.CARD_SOURCES' | translate }}</h3>
|
||||
@if (epgUrl().length > 0) {
|
||||
<span class="settings-section__count">{{
|
||||
epgUrl().length
|
||||
}}</span>
|
||||
<code>https://example.org/guide.xml.gz</code>
|
||||
<code>/home/you/epg/guide.xml</code>
|
||||
<code>C:\epg\guide.xml.gz</code>
|
||||
<code>file:///…/guide.xml</code>
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<ng-container formArrayName="epgUrl">
|
||||
@if (epgUrl().length === 0) {
|
||||
<div
|
||||
class="epg-sources-empty"
|
||||
data-test-id="epg-sources-empty"
|
||||
<div class="settings-section__actions">
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
[disabled]="isClearingEpgData()"
|
||||
(click)="refreshAllEpg.emit()"
|
||||
>
|
||||
<mat-icon class="epg-sources-empty__icon"
|
||||
>event_busy</mat-icon
|
||||
<mat-icon>refresh</mat-icon>
|
||||
{{ 'SETTINGS.REFRESH_ALL_EPG' | translate }}
|
||||
</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
(click)="addEpgSource.emit()"
|
||||
>
|
||||
<mat-icon>add</mat-icon>
|
||||
{{ 'SETTINGS.ADD_EPG_SOURCE' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<p class="settings-section__desc">
|
||||
{{ 'SETTINGS.EPG_SOURCE_FORMATS_HINT' | translate }}
|
||||
</p>
|
||||
<div class="settings-card">
|
||||
<div
|
||||
class="setting-item setting-item--list"
|
||||
data-setting-id="epg-sources"
|
||||
>
|
||||
<ng-container formArrayName="epgUrl">
|
||||
@if (epgUrl().length === 0) {
|
||||
<div
|
||||
class="epg-sources-empty"
|
||||
data-test-id="epg-sources-empty"
|
||||
>
|
||||
<h4 class="epg-sources-empty__title">
|
||||
{{ 'SETTINGS.EPG_EMPTY_TITLE' | translate }}
|
||||
</h4>
|
||||
<p class="epg-sources-empty__hint">
|
||||
{{ 'SETTINGS.EPG_EMPTY_HINT' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="epg-sources">
|
||||
@for (_ of epgUrl().controls; track _; let i = $index) {
|
||||
<div class="epg-source-row">
|
||||
<mat-form-field
|
||||
appearance="outline"
|
||||
subscriptSizing="dynamic"
|
||||
class="w-full"
|
||||
>
|
||||
<mat-label>{{
|
||||
'SETTINGS.EPG_URL_PLACEHOLDER'
|
||||
| translate
|
||||
}}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="text"
|
||||
spellcheck="false"
|
||||
[formControlName]="i"
|
||||
#epgField
|
||||
/>
|
||||
<mat-error>{{
|
||||
'SETTINGS.EPG_URL_ERROR' | translate
|
||||
}}</mat-error>
|
||||
</mat-form-field>
|
||||
@if (epgField.value) {
|
||||
<app-epg-source-status
|
||||
[url]="epgField.value"
|
||||
/>
|
||||
}
|
||||
@if (canBrowseFiles()) {
|
||||
<mat-icon class="epg-sources-empty__icon"
|
||||
>event_busy</mat-icon
|
||||
>
|
||||
<p class="epg-sources-empty__title">
|
||||
{{ 'SETTINGS.EPG_EMPTY_TITLE' | translate }}
|
||||
</p>
|
||||
<p class="epg-sources-empty__hint">
|
||||
{{ 'SETTINGS.EPG_EMPTY_HINT' | translate }}
|
||||
</p>
|
||||
<button
|
||||
mat-flat-button
|
||||
type="button"
|
||||
(click)="addEpgSource.emit()"
|
||||
>
|
||||
<mat-icon>add</mat-icon>
|
||||
{{ 'SETTINGS.ADD_EPG_SOURCE' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="epg-sources">
|
||||
@for (
|
||||
_ of epgUrl().controls;
|
||||
track _;
|
||||
let i = $index
|
||||
) {
|
||||
<div class="epg-source-row">
|
||||
<mat-form-field
|
||||
appearance="outline"
|
||||
subscriptSizing="dynamic"
|
||||
class="w-full"
|
||||
>
|
||||
<input
|
||||
matInput
|
||||
type="text"
|
||||
spellcheck="false"
|
||||
[formControlName]="i"
|
||||
[placeholder]="
|
||||
'SETTINGS.EPG_URL_PLACEHOLDER'
|
||||
| translate
|
||||
"
|
||||
[attr.aria-label]="
|
||||
'SETTINGS.EPG_URL_LABEL'
|
||||
| translate
|
||||
"
|
||||
#epgField
|
||||
/>
|
||||
<mat-error>{{
|
||||
'SETTINGS.EPG_URL_ERROR' | translate
|
||||
}}</mat-error>
|
||||
</mat-form-field>
|
||||
@if (epgField.value) {
|
||||
<app-epg-source-status
|
||||
[url]="epgField.value"
|
||||
/>
|
||||
}
|
||||
@if (canBrowseFiles()) {
|
||||
<button
|
||||
mat-icon-button
|
||||
[matTooltip]="
|
||||
'SETTINGS.BROWSE_EPG_FILE'
|
||||
| translate
|
||||
"
|
||||
[attr.aria-label]="
|
||||
'SETTINGS.BROWSE_EPG_FILE'
|
||||
| translate
|
||||
"
|
||||
type="button"
|
||||
data-test-id="epg-source-browse"
|
||||
(click)="browseEpgSource.emit(i)"
|
||||
>
|
||||
<mat-icon>folder_open</mat-icon>
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
mat-icon-button
|
||||
[matTooltip]="
|
||||
'SETTINGS.BROWSE_EPG_FILE'
|
||||
'SETTINGS.REFRESH_EPG' | translate
|
||||
"
|
||||
[attr.aria-label]="
|
||||
'SETTINGS.REFRESH_EPG' | translate
|
||||
"
|
||||
type="button"
|
||||
data-test-id="epg-source-refresh"
|
||||
[disabled]="epgField.value === ''"
|
||||
(click)="
|
||||
refreshEpg.emit(epgUrl().value[i])
|
||||
"
|
||||
>
|
||||
<mat-icon>refresh</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
[matTooltip]="
|
||||
'SETTINGS.REMOVE_EPG_SOURCE'
|
||||
| translate
|
||||
"
|
||||
[attr.aria-label]="
|
||||
'SETTINGS.BROWSE_EPG_FILE'
|
||||
'SETTINGS.REMOVE_EPG_SOURCE'
|
||||
| translate
|
||||
"
|
||||
type="button"
|
||||
data-test-id="epg-source-browse"
|
||||
(click)="browseEpgSource.emit(i)"
|
||||
data-test-id="epg-source-remove"
|
||||
(click)="removeEpgSource.emit(i)"
|
||||
>
|
||||
<mat-icon>folder_open</mat-icon>
|
||||
<mat-icon>remove</mat-icon>
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
mat-icon-button
|
||||
[matTooltip]="
|
||||
'SETTINGS.REFRESH_EPG' | translate
|
||||
"
|
||||
type="button"
|
||||
data-test-id="epg-source-refresh"
|
||||
[disabled]="epgField.value === ''"
|
||||
(click)="refreshEpg.emit(epgUrl().value[i])"
|
||||
>
|
||||
<mat-icon>refresh</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
[matTooltip]="
|
||||
'SETTINGS.REMOVE_EPG_SOURCE' | translate
|
||||
"
|
||||
type="button"
|
||||
data-test-id="epg-source-remove"
|
||||
(click)="removeEpgSource.emit(i)"
|
||||
>
|
||||
<mat-icon>remove</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</ng-container>
|
||||
<p
|
||||
class="epg-sources__formats-examples"
|
||||
data-test-id="epg-source-formats"
|
||||
>
|
||||
<span>{{
|
||||
'SETTINGS.EPG_SOURCE_FORMATS_EXAMPLES' | translate
|
||||
}}</span>
|
||||
<code>https://example.org/guide.xml.gz</code>
|
||||
<code>/home/you/epg/guide.xml</code>
|
||||
<code>C:\epg\guide.xml.gz</code>
|
||||
<code>file:///…/guide.xml</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
<div class="settings-section__header">
|
||||
<h3>{{ 'SETTINGS.CARD_XTREAM_PLAYLISTS' | translate }}</h3>
|
||||
</div>
|
||||
<div class="settings-card">
|
||||
<div class="setting-item" data-setting-id="prefer-uploaded-epg">
|
||||
<div class="setting-item__meta">
|
||||
<h4 id="prefer-uploaded-epg-label">
|
||||
{{ 'SETTINGS.PREFER_UPLOADED_EPG_TITLE' | translate }}
|
||||
</h4>
|
||||
<p>{{ 'SETTINGS.PREFER_UPLOADED_EPG_HINT' | translate }}</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-slide-toggle
|
||||
formControlName="preferUploadedEpgOverXtream"
|
||||
aria-labelledby="prefer-uploaded-epg-label"
|
||||
data-test-id="toggle-prefer-uploaded-epg"
|
||||
></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
<div class="settings-section__header">
|
||||
<h3>{{ 'SETTINGS.DATA' | translate }}</h3>
|
||||
</div>
|
||||
<div class="settings-card">
|
||||
<div class="setting-item" data-setting-id="epg-clear-data">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.CLEAR_EPG_DATA' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.CLEAR_EPG_DATA_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<button
|
||||
mat-stroked-button
|
||||
class="app-destructive-button"
|
||||
type="button"
|
||||
[disabled]="isClearingEpgData()"
|
||||
(click)="clearEpgData.emit()"
|
||||
>
|
||||
@if (isClearingEpgData()) {
|
||||
<mat-progress-spinner
|
||||
class="button-spinner"
|
||||
diameter="16"
|
||||
mode="indeterminate"
|
||||
/>
|
||||
{{ 'SETTINGS.CLEARING_EPG_DATA' | translate }}
|
||||
} @else {
|
||||
{{ 'SETTINGS.CLEAR_EPG_DATA' | translate }}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" data-setting-id="prefer-uploaded-epg">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.PREFER_UPLOADED_EPG_TITLE' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.PREFER_UPLOADED_EPG_HINT' | translate }}</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox
|
||||
formControlName="preferUploadedEpgOverXtream"
|
||||
data-test-id="toggle-prefer-uploaded-epg"
|
||||
></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="settings-group__footer">
|
||||
<div class="inline-actions">
|
||||
<button mat-button type="button" (click)="addEpgSource.emit()">
|
||||
<mat-icon>add</mat-icon>
|
||||
{{ 'SETTINGS.ADD_EPG_SOURCE' | translate }}
|
||||
</button>
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
[disabled]="epgUrl().length === 0 || isClearingEpgData()"
|
||||
(click)="refreshAllEpg.emit()"
|
||||
>
|
||||
<mat-icon>refresh</mat-icon>
|
||||
{{ 'SETTINGS.REFRESH_ALL_EPG' | translate }}
|
||||
</button>
|
||||
<button
|
||||
mat-button
|
||||
class="app-destructive-button"
|
||||
type="button"
|
||||
[disabled]="isClearingEpgData()"
|
||||
(click)="clearEpgData.emit()"
|
||||
>
|
||||
@if (isClearingEpgData()) {
|
||||
<mat-progress-spinner
|
||||
class="button-spinner"
|
||||
diameter="16"
|
||||
mode="indeterminate"
|
||||
/>
|
||||
{{ 'SETTINGS.CLEARING_EPG_DATA' | translate }}
|
||||
} @else {
|
||||
{{ 'SETTINGS.CLEAR_EPG_DATA' | translate }}
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</section>
|
||||
</section>
|
||||
@@ -0,0 +1,169 @@
|
||||
// EPG page: the offset stepper and the sources list. Shared row and card
|
||||
// styles come from settings.component.scss (ViewEncapsulation.None).
|
||||
app-settings-epg-section {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
// Number stepper (EPG offset).
|
||||
.settings-stepper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 34px;
|
||||
border: 1px solid var(--settings-control-border);
|
||||
border-radius: 8px;
|
||||
background: var(--settings-control-bg);
|
||||
overflow: hidden;
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--app-selection-color);
|
||||
}
|
||||
|
||||
input {
|
||||
width: 64px;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--app-heading-color);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
appearance: textfield;
|
||||
-moz-appearance: textfield;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&::-webkit-inner-spin-button,
|
||||
&::-webkit-outer-spin-button {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.settings-stepper--invalid {
|
||||
border-color: var(--mat-sys-error);
|
||||
}
|
||||
|
||||
.settings-stepper__button {
|
||||
width: 32px;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--app-body-color);
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--app-hover-overlay);
|
||||
color: var(--app-heading-color);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--app-selection-color);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-stepper__unit {
|
||||
padding-right: 10px;
|
||||
color: var(--settings-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.settings-field-error {
|
||||
margin: 0;
|
||||
color: var(--mat-sys-error);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
// ─── EPG sources ───────────────────────────────────────────────────────────
|
||||
.epg-sources {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.epg-source-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px 8px 16px;
|
||||
|
||||
& + & {
|
||||
border-top: 1px solid var(--settings-item-border);
|
||||
}
|
||||
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
app-epg-source-status {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.epg-sources-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 34px 20px 24px;
|
||||
color: var(--settings-muted);
|
||||
text-align: center;
|
||||
line-height: 1.55;
|
||||
|
||||
.epg-sources-empty__icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
font-size: 28px;
|
||||
margin-bottom: 8px;
|
||||
color: var(--app-muted-color);
|
||||
}
|
||||
|
||||
.epg-sources-empty__title {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--app-heading-color);
|
||||
}
|
||||
|
||||
.epg-sources-empty__hint {
|
||||
margin: 0;
|
||||
max-width: 42ch;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-top: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.epg-sources__formats-examples {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px 8px;
|
||||
margin: 0;
|
||||
padding: 10px 16px 12px;
|
||||
border-top: 1px solid var(--settings-item-border);
|
||||
color: var(--settings-muted);
|
||||
font-size: 11.5px;
|
||||
|
||||
code {
|
||||
padding: 1px 6px;
|
||||
border-radius: 6px;
|
||||
background: var(--settings-chip-bg);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
// Pane width, see settings.component.scss.
|
||||
@container (max-width: 600px) {
|
||||
.settings-field-error {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { FormArray, FormControl, FormGroup, Validators } from '@angular/forms';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { SettingsEpgSectionComponent } from './settings-epg-section.component';
|
||||
import { SETTINGS_EPG_VIEW_MODE_OPTIONS } from './settings-options';
|
||||
|
||||
function createForm(offset = 0): FormGroup {
|
||||
return new FormGroup({
|
||||
epgUrl: new FormArray<FormControl<string | null>>([]),
|
||||
epgViewMode: new FormControl('timeline'),
|
||||
epgOffsetMinutes: new FormControl<number>(offset, [
|
||||
Validators.required,
|
||||
Validators.min(-720),
|
||||
Validators.max(720),
|
||||
]),
|
||||
preferUploadedEpgOverXtream: new FormControl(false),
|
||||
});
|
||||
}
|
||||
|
||||
describe('SettingsEpgSectionComponent offset stepper', () => {
|
||||
let fixture: ComponentFixture<SettingsEpgSectionComponent>;
|
||||
|
||||
function configure(form: FormGroup) {
|
||||
fixture.componentRef.setInput('form', form);
|
||||
fixture.componentRef.setInput('epgUrl', form.get('epgUrl'));
|
||||
fixture.componentRef.setInput(
|
||||
'epgViewModeOptions',
|
||||
SETTINGS_EPG_VIEW_MODE_OPTIONS
|
||||
);
|
||||
fixture.detectChanges();
|
||||
return form;
|
||||
}
|
||||
|
||||
const query = (selector: string) =>
|
||||
(fixture.nativeElement as HTMLElement).querySelector(selector);
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [SettingsEpgSectionComponent, TranslateModule.forRoot()],
|
||||
}).compileComponents();
|
||||
fixture = TestBed.createComponent(SettingsEpgSectionComponent);
|
||||
});
|
||||
|
||||
it('moves the offset by one step and stages the change', () => {
|
||||
const form = configure(createForm(0));
|
||||
|
||||
fixture.componentInstance.stepOffset(30);
|
||||
expect(form.get('epgOffsetMinutes')?.value).toBe(30);
|
||||
expect(form.get('epgOffsetMinutes')?.dirty).toBe(true);
|
||||
expect(form.dirty).toBe(true);
|
||||
|
||||
fixture.componentInstance.stepOffset(-30);
|
||||
fixture.componentInstance.stepOffset(-30);
|
||||
expect(form.get('epgOffsetMinutes')?.value).toBe(-30);
|
||||
});
|
||||
|
||||
it('clamps the buttons to the accepted range and recovers a bad value', () => {
|
||||
const form = configure(createForm(710));
|
||||
|
||||
fixture.componentInstance.stepOffset(30);
|
||||
expect(form.get('epgOffsetMinutes')?.value).toBe(720);
|
||||
|
||||
form.get('epgOffsetMinutes')?.setValue(Number.NaN);
|
||||
fixture.componentInstance.stepOffset(-30);
|
||||
expect(form.get('epgOffsetMinutes')?.value).toBe(-30);
|
||||
});
|
||||
|
||||
it('shows the range error under the stepper for a typed value out of range', () => {
|
||||
const form = configure(createForm(0));
|
||||
|
||||
expect(query('.settings-field-error')).toBeNull();
|
||||
|
||||
form.get('epgOffsetMinutes')?.setValue(900);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(query('.settings-stepper--invalid')).not.toBeNull();
|
||||
expect(query('.settings-field-error')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('offers Add in the empty state and in the header once a source exists', () => {
|
||||
const form = configure(createForm());
|
||||
const addButtons = () =>
|
||||
Array.from(
|
||||
(fixture.nativeElement as HTMLElement).querySelectorAll('button')
|
||||
).filter((button) =>
|
||||
button.textContent?.includes('SETTINGS.ADD_EPG_SOURCE')
|
||||
);
|
||||
|
||||
expect(query('[data-test-id="epg-sources-empty"]')).not.toBeNull();
|
||||
expect(addButtons()).toHaveLength(1);
|
||||
expect(query('.settings-section__actions')).toBeNull();
|
||||
|
||||
(form.get('epgUrl') as FormArray).push(new FormControl('https://x'));
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(query('[data-test-id="epg-sources-empty"]')).toBeNull();
|
||||
expect(addButtons()).toHaveLength(1);
|
||||
expect(
|
||||
query('.settings-section__actions')?.contains(addButtons()[0])
|
||||
).toBe(true);
|
||||
expect(query('.settings-section__count')?.textContent?.trim()).toBe(
|
||||
'1'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -5,13 +5,18 @@ import {
|
||||
ViewEncapsulation,
|
||||
ChangeDetectionStrategy,
|
||||
} from '@angular/core';
|
||||
import { FormArray, FormGroup, ReactiveFormsModule } from '@angular/forms';
|
||||
import {
|
||||
AbstractControl,
|
||||
FormArray,
|
||||
FormGroup,
|
||||
ReactiveFormsModule,
|
||||
} from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { EpgViewMode } from '@iptvnator/shared/interfaces';
|
||||
import { EpgSourceStatusComponent } from '@iptvnator/ui/epg';
|
||||
@@ -24,11 +29,11 @@ import { markSectionForCheckOnFormEvents } from './settings-section-form-render'
|
||||
imports: [
|
||||
EpgSourceStatusComponent,
|
||||
MatButtonModule,
|
||||
MatCheckboxModule,
|
||||
MatFormFieldModule,
|
||||
MatIconModule,
|
||||
MatInputModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatSlideToggleModule,
|
||||
MatTooltipModule,
|
||||
ReactiveFormsModule,
|
||||
TranslateModule,
|
||||
@@ -36,7 +41,7 @@ import { markSectionForCheckOnFormEvents } from './settings-section-form-render'
|
||||
templateUrl: './settings-epg-section.component.html',
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styles: [':host { display: contents; }'],
|
||||
styleUrls: ['./settings-epg-section.component.scss'],
|
||||
})
|
||||
export class SettingsEpgSectionComponent {
|
||||
readonly form = input.required<FormGroup>();
|
||||
@@ -59,4 +64,35 @@ export class SettingsEpgSectionComponent {
|
||||
readonly refreshAllEpg = output<void>();
|
||||
readonly clearEpgData = output<void>();
|
||||
readonly selectEpgViewMode = output<EpgViewMode>();
|
||||
|
||||
/** Minutes one stepper click shifts the displayed times by */
|
||||
readonly offsetStep = 30;
|
||||
|
||||
get offsetControl(): AbstractControl | null {
|
||||
return this.form().get('epgOffsetMinutes');
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the offset by one step within the accepted range. Typed values
|
||||
* keep going through the control's own validation; only the buttons
|
||||
* clamp, so an out-of-range typed value snaps back into range.
|
||||
*/
|
||||
stepOffset(delta: number): void {
|
||||
const control = this.offsetControl;
|
||||
if (!control) return;
|
||||
const current = Number(control.value);
|
||||
const next = Math.max(
|
||||
EPG_OFFSET_MIN,
|
||||
Math.min(
|
||||
EPG_OFFSET_MAX,
|
||||
(Number.isFinite(current) ? current : 0) + delta
|
||||
)
|
||||
);
|
||||
control.setValue(next);
|
||||
control.markAsDirty();
|
||||
this.form().markAsDirty();
|
||||
}
|
||||
}
|
||||
|
||||
const EPG_OFFSET_MIN = -720;
|
||||
const EPG_OFFSET_MAX = 720;
|
||||
@@ -1,4 +1,10 @@
|
||||
import { DestroyRef, inject, Injectable } from '@angular/core';
|
||||
import {
|
||||
DestroyRef,
|
||||
inject,
|
||||
Injectable,
|
||||
signal,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { FormArray, FormBuilder } from '@angular/forms';
|
||||
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
|
||||
@@ -16,6 +22,11 @@ import { SettingsSnackbarService } from './settings-snackbar.service';
|
||||
import { SettingsStore } from '../services/settings-store.service';
|
||||
import { SettingsService } from '../services/settings.service';
|
||||
import { AppDateLocaleService } from '../app-date-locales';
|
||||
import {
|
||||
diffSettingsValues,
|
||||
SETTINGS_RESTART_CONTROLS,
|
||||
} from './settings-change-tracking';
|
||||
import { SettingsLaunchValuesService } from './settings-launch-values.service';
|
||||
import {
|
||||
applyEpgUrlsToFormArray,
|
||||
createEpgUrlControl,
|
||||
@@ -36,6 +47,7 @@ export class SettingsFormFacade {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly epgBridge = inject(EpgRuntimeBridgeService);
|
||||
private readonly formBuilder = inject(FormBuilder);
|
||||
private readonly launchValues = inject(SettingsLaunchValuesService);
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
private readonly settingsService = inject(SettingsService);
|
||||
private readonly settingsSnackbar = inject(SettingsSnackbarService);
|
||||
@@ -50,6 +62,24 @@ export class SettingsFormFacade {
|
||||
/** Form array with epg sources — absent when EPG is unsupported */
|
||||
readonly epgUrl = this.form.get('epgUrl') as FormArray;
|
||||
|
||||
/**
|
||||
* Dotted paths of the staged values that differ from the saved ones.
|
||||
* Drives the save bar's change count and the dirty marks in the nav;
|
||||
* the form's own `dirty` flag stays what the save/leave guards read.
|
||||
*/
|
||||
readonly changedPaths = signal<readonly string[]>([]);
|
||||
|
||||
/** Controls whose saved value differs from what the running app uses. */
|
||||
readonly restartPendingControls = signal<readonly string[]>([]);
|
||||
|
||||
private savedSnapshot: unknown = {};
|
||||
|
||||
constructor() {
|
||||
this.form.valueChanges
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe(() => this.refreshChangedPaths());
|
||||
}
|
||||
|
||||
/** Trimmed, non-empty EPG source URLs currently in the form */
|
||||
get epgUrls(): string[] {
|
||||
return ((this.epgUrl?.value as string[] | undefined) ?? [])
|
||||
@@ -82,6 +112,23 @@ export class SettingsFormFacade {
|
||||
this.epgUrl.clear();
|
||||
this.setEpgUrls(currentSettings.epgUrl);
|
||||
}
|
||||
this.takeSavedSnapshot();
|
||||
// The app launched with the stored values; recorded once per app run,
|
||||
// not per settings visit, so a later visit compares against launch.
|
||||
this.launchValues.captureOnce(
|
||||
(control) => this.form.get(control)?.value
|
||||
);
|
||||
this.refreshRestartPending();
|
||||
}
|
||||
|
||||
/**
|
||||
* What the running app actually uses for a restart control when that
|
||||
* differs from the stored value (a frame-copy opt-in the engine could
|
||||
* not honour). Re-evaluates the pending notice.
|
||||
*/
|
||||
setRunningValue(control: string, value: unknown): void {
|
||||
this.launchValues.set(control, value);
|
||||
this.refreshRestartPending();
|
||||
}
|
||||
|
||||
bindDashboardControlsEnabledState(): void {
|
||||
@@ -164,7 +211,6 @@ export class SettingsFormFacade {
|
||||
this.form,
|
||||
this.settingsStore.getSettings()
|
||||
);
|
||||
|
||||
let cleanupError: EpgSourceReconciliationError | undefined;
|
||||
try {
|
||||
await this.settingsStore.updateSettings(settings, {
|
||||
@@ -176,7 +222,10 @@ export class SettingsFormFacade {
|
||||
// committed values, but retain the dirty form for cleanup retry.
|
||||
cleanupError = error;
|
||||
}
|
||||
if (!cleanupError) onSaved();
|
||||
if (!cleanupError) {
|
||||
onSaved();
|
||||
this.refreshRestartPending();
|
||||
}
|
||||
|
||||
if (window.electron) {
|
||||
window.electron.updateSettings(settings);
|
||||
@@ -189,9 +238,19 @@ export class SettingsFormFacade {
|
||||
if (cleanupError) throw cleanupError;
|
||||
}
|
||||
|
||||
/** Later: the reminder stays away until one of those settings changes. */
|
||||
dismissRestartNotice(): void {
|
||||
const saved = (this.savedSnapshot ?? {}) as Record<string, unknown>;
|
||||
for (const control of this.restartPendingControls()) {
|
||||
this.launchValues.dismiss(control, saved[control]);
|
||||
}
|
||||
this.restartPendingControls.set([]);
|
||||
}
|
||||
|
||||
/** Applies the saved language/theme and resets the dirty state */
|
||||
applySavedSettings(): void {
|
||||
this.form.markAsPristine();
|
||||
this.takeSavedSnapshot();
|
||||
// The switch re-renders every date with the new locale; its data is
|
||||
// a lazy chunk that must be registered first, and a newer choice
|
||||
// must win over an older one whose data arrives later.
|
||||
@@ -201,6 +260,43 @@ export class SettingsFormFacade {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart controls whose SAVED value the running app does not use yet.
|
||||
* Compares the last saved snapshot, never the draft form: a staged but
|
||||
* unsaved edit must not raise or clear the reminder. Called from an
|
||||
* effect (the engine probe), so the current list is read untracked and
|
||||
* only a changed list is written: a fresh array on every run would
|
||||
* re-trigger that effect for ever.
|
||||
*/
|
||||
private refreshRestartPending(): void {
|
||||
const running = this.launchValues.get();
|
||||
const saved = (this.savedSnapshot ?? {}) as Record<string, unknown>;
|
||||
const pending = Object.keys(SETTINGS_RESTART_CONTROLS).filter(
|
||||
(control) =>
|
||||
control in running &&
|
||||
saved[control] !== running[control] &&
|
||||
!this.launchValues.isDismissed(control, saved[control])
|
||||
);
|
||||
const current = untracked(this.restartPendingControls);
|
||||
if (
|
||||
current.length !== pending.length ||
|
||||
current.some((control, index) => control !== pending[index])
|
||||
) {
|
||||
this.restartPendingControls.set(pending);
|
||||
}
|
||||
}
|
||||
|
||||
private takeSavedSnapshot(): void {
|
||||
this.savedSnapshot = this.form.getRawValue();
|
||||
this.refreshChangedPaths();
|
||||
}
|
||||
|
||||
private refreshChangedPaths(): void {
|
||||
this.changedPaths.set(
|
||||
diffSettingsValues(this.form.getRawValue(), this.savedSnapshot)
|
||||
);
|
||||
}
|
||||
|
||||
private patchAndMarkDirty(
|
||||
value: SettingsFormPatch,
|
||||
controlName: string
|
||||
|
||||
@@ -1,276 +1,311 @@
|
||||
<section class="settings-group" id="general" [formGroup]="form()">
|
||||
<header class="settings-group__header">
|
||||
<div class="settings-group__header-icon">
|
||||
<mat-icon>settings</mat-icon>
|
||||
</div>
|
||||
<div class="settings-group__header-text">
|
||||
<h3>{{ 'SETTINGS.GENERAL' | translate }}</h3>
|
||||
<p>{{ 'SETTINGS.GENERAL_SUBTITLE' | translate }}</p>
|
||||
</div>
|
||||
<h2>{{ 'SETTINGS.GENERAL' | translate }}</h2>
|
||||
<p>{{ 'SETTINGS.GENERAL_SUBTITLE' | translate }}</p>
|
||||
</header>
|
||||
|
||||
<div class="setting-item" data-setting-id="language">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.LANGUAGE' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.LANGUAGE_DESCRIPTION' | translate }}</p>
|
||||
<section class="settings-section">
|
||||
<div class="settings-section__header">
|
||||
<h3>{{ 'SETTINGS.CARD_APPEARANCE' | translate }}</h3>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>{{
|
||||
'SETTINGS.VIDEO_PLAYER_PLACEHOLDER' | translate
|
||||
}}</mat-label>
|
||||
<mat-select
|
||||
formControlName="language"
|
||||
data-test-id="select-language"
|
||||
>
|
||||
@for (
|
||||
language of languageEnum() | keyvalue;
|
||||
track language
|
||||
) {
|
||||
<mat-option
|
||||
[value]="language.value"
|
||||
[attr.data-test-id]="language.value"
|
||||
>{{
|
||||
'LANGUAGES.' + language.key | translate
|
||||
}}</mat-option
|
||||
>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="setting-item setting-item--full-control"
|
||||
data-setting-id="theme"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.THEME' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.THEME_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<div
|
||||
class="theme-switcher"
|
||||
role="radiogroup"
|
||||
[attr.aria-label]="'SETTINGS.THEME' | translate"
|
||||
data-test-id="select-theme"
|
||||
>
|
||||
@for (themeOption of themeOptions(); track themeOption.value) {
|
||||
<button
|
||||
type="button"
|
||||
class="theme-switcher__option"
|
||||
[class.theme-switcher__option--selected]="
|
||||
form().value.theme === themeOption.value
|
||||
"
|
||||
[attr.aria-checked]="
|
||||
form().value.theme === themeOption.value
|
||||
"
|
||||
[attr.data-test-id]="themeOption.value"
|
||||
role="radio"
|
||||
(click)="selectTheme.emit(themeOption.value)"
|
||||
<div class="settings-card">
|
||||
<div class="setting-item" data-setting-id="language">
|
||||
<div class="setting-item__meta">
|
||||
<h4 id="language-label">
|
||||
{{ 'SETTINGS.LANGUAGE' | translate }}
|
||||
</h4>
|
||||
<p>{{ 'SETTINGS.LANGUAGE_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<mat-form-field
|
||||
class="settings-select"
|
||||
appearance="outline"
|
||||
subscriptSizing="dynamic"
|
||||
>
|
||||
<mat-icon>{{ themeOption.icon }}</mat-icon>
|
||||
<span>{{ themeOption.labelKey | translate }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="setting-item setting-item--full-control"
|
||||
data-setting-id="cover-size"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.COVER_SIZE' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.COVER_SIZE_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<div
|
||||
class="theme-switcher"
|
||||
role="radiogroup"
|
||||
[attr.aria-label]="'SETTINGS.COVER_SIZE' | translate"
|
||||
data-test-id="select-cover-size"
|
||||
>
|
||||
@for (option of coverSizeOptions(); track option.value) {
|
||||
<button
|
||||
type="button"
|
||||
class="theme-switcher__option"
|
||||
[class.theme-switcher__option--selected]="
|
||||
form().value.coverSize === option.value
|
||||
"
|
||||
[attr.aria-checked]="
|
||||
form().value.coverSize === option.value
|
||||
"
|
||||
[attr.data-test-id]="'cover-size-' + option.value"
|
||||
role="radio"
|
||||
(click)="selectCoverSize.emit(option.value)"
|
||||
>
|
||||
<mat-icon>{{ option.icon }}</mat-icon>
|
||||
<span>{{ option.labelKey | translate }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" data-setting-id="cover-titles">
|
||||
<div class="setting-item__meta">
|
||||
<h4 id="cover-titles-label">
|
||||
{{ 'SETTINGS.COVER_TITLES' | translate }}
|
||||
</h4>
|
||||
<p id="cover-titles-description">
|
||||
{{ 'SETTINGS.COVER_TITLES_DESCRIPTION' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox
|
||||
formControlName="showCoverTitles"
|
||||
aria-labelledby="cover-titles-label"
|
||||
aria-describedby="cover-titles-description"
|
||||
data-test-id="cover-titles-toggle"
|
||||
></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" data-setting-id="startup-behavior">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.STARTUP_BEHAVIOR' | translate }}</h4>
|
||||
<p>
|
||||
{{ 'SETTINGS.STARTUP_BEHAVIOR_DESCRIPTION' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>{{
|
||||
'SETTINGS.STARTUP_BEHAVIOR' | translate
|
||||
}}</mat-label>
|
||||
<mat-select
|
||||
formControlName="startupBehavior"
|
||||
data-test-id="select-startup-behavior"
|
||||
>
|
||||
@for (
|
||||
option of startupBehaviorOptions();
|
||||
track option.value
|
||||
) {
|
||||
<mat-option
|
||||
[value]="option.value"
|
||||
[attr.data-test-id]="option.value"
|
||||
<mat-select
|
||||
formControlName="language"
|
||||
aria-labelledby="language-label"
|
||||
data-test-id="select-language"
|
||||
>
|
||||
{{ option.labelKey | translate }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (supportsStartupWindowMode()) {
|
||||
<div
|
||||
class="setting-item"
|
||||
data-setting-id="startup-window-mode"
|
||||
data-test-id="startup-window-mode-setting"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.STARTUP_WINDOW_MODE' | translate }}</h4>
|
||||
<p>
|
||||
{{ 'SETTINGS.STARTUP_WINDOW_MODE_DESCRIPTION' | translate }}
|
||||
</p>
|
||||
@for (
|
||||
language of languageEnum() | keyvalue;
|
||||
track language
|
||||
) {
|
||||
<mat-option
|
||||
[value]="language.value"
|
||||
[attr.data-test-id]="language.value"
|
||||
>{{
|
||||
'LANGUAGES.' + language.key | translate
|
||||
}}</mat-option
|
||||
>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>{{
|
||||
'SETTINGS.STARTUP_WINDOW_MODE' | translate
|
||||
}}</mat-label>
|
||||
<mat-select
|
||||
formControlName="startupWindowMode"
|
||||
data-test-id="select-startup-window-mode"
|
||||
|
||||
<div class="setting-item" data-setting-id="theme">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.THEME' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.THEME_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<div
|
||||
class="theme-switcher"
|
||||
role="radiogroup"
|
||||
[attr.aria-label]="'SETTINGS.THEME' | translate"
|
||||
data-test-id="select-theme"
|
||||
>
|
||||
@for (
|
||||
option of startupWindowModeOptions();
|
||||
track option.value
|
||||
themeOption of themeOptions();
|
||||
track themeOption.value
|
||||
) {
|
||||
<mat-option
|
||||
[value]="option.value"
|
||||
[attr.data-test-id]="
|
||||
'startup-window-mode-' + option.value
|
||||
<button
|
||||
type="button"
|
||||
class="theme-switcher__option"
|
||||
[class.theme-switcher__option--selected]="
|
||||
form().value.theme === themeOption.value
|
||||
"
|
||||
[attr.aria-checked]="
|
||||
form().value.theme === themeOption.value
|
||||
"
|
||||
[attr.data-test-id]="themeOption.value"
|
||||
role="radio"
|
||||
(click)="selectTheme.emit(themeOption.value)"
|
||||
>
|
||||
{{ option.labelKey | translate }}
|
||||
</mat-option>
|
||||
<mat-icon>{{ themeOption.icon }}</mat-icon>
|
||||
<span>{{ themeOption.labelKey | translate }}</span>
|
||||
</button>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="setting-item" data-setting-id="show-captions">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.SHOW_CAPTIONS' | translate }}</h4>
|
||||
<p>
|
||||
{{ 'SETTINGS.SHOW_CAPTIONS_DESCRIPTION' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox formControlName="showCaptions"></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item" data-setting-id="cover-size">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.COVER_SIZE' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.COVER_SIZE_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<div
|
||||
class="theme-switcher"
|
||||
role="radiogroup"
|
||||
[attr.aria-label]="'SETTINGS.COVER_SIZE' | translate"
|
||||
data-test-id="select-cover-size"
|
||||
>
|
||||
@for (option of coverSizeOptions(); track option.value) {
|
||||
<button
|
||||
type="button"
|
||||
class="theme-switcher__option"
|
||||
[class.theme-switcher__option--selected]="
|
||||
form().value.coverSize === option.value
|
||||
"
|
||||
[attr.aria-checked]="
|
||||
form().value.coverSize === option.value
|
||||
"
|
||||
[attr.data-test-id]="
|
||||
'cover-size-' + option.value
|
||||
"
|
||||
role="radio"
|
||||
(click)="selectCoverSize.emit(option.value)"
|
||||
>
|
||||
<mat-icon>{{ option.icon }}</mat-icon>
|
||||
<span>{{ option.labelKey | translate }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" data-setting-id="strip-country-prefix">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.STRIP_COUNTRY_PREFIX' | translate }}</h4>
|
||||
<p>
|
||||
{{ 'SETTINGS.STRIP_COUNTRY_PREFIX_DESCRIPTION' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox formControlName="stripCountryPrefix"></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@if (supportsPortalConnectivityGuard()) {
|
||||
<section
|
||||
class="settings-group"
|
||||
[formGroup]="form()"
|
||||
data-test-id="portal-connectivity-setting"
|
||||
>
|
||||
<header class="settings-group__header">
|
||||
<div class="settings-group__header-icon">
|
||||
<mat-icon>hub</mat-icon>
|
||||
</div>
|
||||
<div class="settings-group__header-text">
|
||||
<h3>{{ 'SETTINGS.PORTAL_CONNECTIONS' | translate }}</h3>
|
||||
</div>
|
||||
</header>
|
||||
<div class="setting-item" data-setting-id="portal-connectivity-guard">
|
||||
<div class="setting-item__meta">
|
||||
<h4 id="portal-connectivity-label">
|
||||
{{ 'SETTINGS.PORTAL_CONNECTIVITY_GUARD' | translate }}
|
||||
</h4>
|
||||
<p id="portal-connectivity-description">
|
||||
{{
|
||||
'SETTINGS.PORTAL_CONNECTIVITY_GUARD_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
@if (form().get('portalConnectivityGuard')?.value === false) {
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.PORTAL_CONNECTIVITY_GUARD_DISABLED'
|
||||
| translate
|
||||
}}
|
||||
<div class="setting-item" data-setting-id="cover-titles">
|
||||
<div class="setting-item__meta">
|
||||
<h4 id="cover-titles-label">
|
||||
{{ 'SETTINGS.COVER_TITLES' | translate }}
|
||||
</h4>
|
||||
<p id="cover-titles-description">
|
||||
{{ 'SETTINGS.COVER_TITLES_DESCRIPTION' | translate }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox
|
||||
formControlName="portalConnectivityGuard"
|
||||
aria-labelledby="portal-connectivity-label"
|
||||
aria-describedby="portal-connectivity-description"
|
||||
data-test-id="portal-connectivity-toggle"
|
||||
></mat-checkbox>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-slide-toggle
|
||||
formControlName="showCoverTitles"
|
||||
aria-labelledby="cover-titles-label"
|
||||
aria-describedby="cover-titles-description"
|
||||
data-test-id="cover-titles-toggle"
|
||||
></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
<section class="settings-section">
|
||||
<div class="settings-section__header">
|
||||
<h3>{{ 'SETTINGS.CARD_STARTUP' | translate }}</h3>
|
||||
</div>
|
||||
<div class="settings-card">
|
||||
<div class="setting-item" data-setting-id="startup-behavior">
|
||||
<div class="setting-item__meta">
|
||||
<h4 id="startup-behavior-label">
|
||||
{{ 'SETTINGS.STARTUP_BEHAVIOR' | translate }}
|
||||
</h4>
|
||||
<p>
|
||||
{{ 'SETTINGS.STARTUP_BEHAVIOR_DESCRIPTION' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<mat-form-field
|
||||
class="settings-select"
|
||||
appearance="outline"
|
||||
subscriptSizing="dynamic"
|
||||
>
|
||||
<mat-select
|
||||
formControlName="startupBehavior"
|
||||
aria-labelledby="startup-behavior-label"
|
||||
data-test-id="select-startup-behavior"
|
||||
>
|
||||
@for (
|
||||
option of startupBehaviorOptions();
|
||||
track option.value
|
||||
) {
|
||||
<mat-option
|
||||
[value]="option.value"
|
||||
[attr.data-test-id]="option.value"
|
||||
>
|
||||
{{ option.labelKey | translate }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (supportsStartupWindowMode()) {
|
||||
<div
|
||||
class="setting-item"
|
||||
data-setting-id="startup-window-mode"
|
||||
data-test-id="startup-window-mode-setting"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<h4 id="startup-window-mode-label">
|
||||
{{ 'SETTINGS.STARTUP_WINDOW_MODE' | translate }}
|
||||
<span class="settings-chip">{{
|
||||
'SETTINGS.CHIP_RESTART' | translate
|
||||
}}</span>
|
||||
</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.STARTUP_WINDOW_MODE_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<mat-form-field
|
||||
class="settings-select"
|
||||
appearance="outline"
|
||||
subscriptSizing="dynamic"
|
||||
>
|
||||
<mat-select
|
||||
formControlName="startupWindowMode"
|
||||
aria-labelledby="startup-window-mode-label"
|
||||
data-test-id="select-startup-window-mode"
|
||||
>
|
||||
@for (
|
||||
option of startupWindowModeOptions();
|
||||
track option.value
|
||||
) {
|
||||
<mat-option
|
||||
[value]="option.value"
|
||||
[attr.data-test-id]="
|
||||
'startup-window-mode-' +
|
||||
option.value
|
||||
"
|
||||
>
|
||||
{{ option.labelKey | translate }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
<div class="settings-section__header">
|
||||
<h3>{{ 'SETTINGS.CARD_CHANNELS' | translate }}</h3>
|
||||
</div>
|
||||
<div class="settings-card">
|
||||
<div class="setting-item" data-setting-id="strip-country-prefix">
|
||||
<div class="setting-item__meta">
|
||||
<h4 id="strip-country-prefix-label">
|
||||
{{ 'SETTINGS.STRIP_COUNTRY_PREFIX' | translate }}
|
||||
</h4>
|
||||
<p id="strip-country-prefix-description">
|
||||
{{
|
||||
'SETTINGS.STRIP_COUNTRY_PREFIX_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-slide-toggle
|
||||
formControlName="stripCountryPrefix"
|
||||
aria-labelledby="strip-country-prefix-label"
|
||||
aria-describedby="strip-country-prefix-description"
|
||||
data-test-id="strip-country-prefix-toggle"
|
||||
></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (supportsPortalConnectivityGuard()) {
|
||||
<section
|
||||
class="settings-section"
|
||||
data-test-id="portal-connectivity-setting"
|
||||
>
|
||||
<div class="settings-section__header">
|
||||
<h3>{{ 'SETTINGS.PORTAL_CONNECTIONS' | translate }}</h3>
|
||||
</div>
|
||||
<div class="settings-card">
|
||||
<div
|
||||
class="setting-item"
|
||||
data-setting-id="portal-connectivity-guard"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<h4 id="portal-connectivity-label">
|
||||
{{ 'SETTINGS.PORTAL_CONNECTIVITY_GUARD' | translate }}
|
||||
</h4>
|
||||
<p id="portal-connectivity-description">
|
||||
{{
|
||||
'SETTINGS.PORTAL_CONNECTIVITY_GUARD_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
@if (
|
||||
form().get('portalConnectivityGuard')?.value ===
|
||||
false
|
||||
) {
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.PORTAL_CONNECTIVITY_GUARD_DISABLED'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-slide-toggle
|
||||
formControlName="portalConnectivityGuard"
|
||||
aria-labelledby="portal-connectivity-label"
|
||||
aria-describedby="portal-connectivity-description"
|
||||
data-test-id="portal-connectivity-toggle"
|
||||
></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
</section>
|
||||
@@ -7,10 +7,10 @@ import {
|
||||
ChangeDetectionStrategy,
|
||||
} from '@angular/core';
|
||||
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { CoverSize, Language, Theme } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
@@ -25,10 +25,10 @@ import { markSectionForCheckOnFormEvents } from './settings-section-form-render'
|
||||
selector: 'app-settings-general-section',
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatCheckboxModule,
|
||||
MatFormFieldModule,
|
||||
MatIconModule,
|
||||
MatSelectModule,
|
||||
MatSlideToggleModule,
|
||||
ReactiveFormsModule,
|
||||
TranslateModule,
|
||||
],
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { SETTINGS_RESTART_CONTROLS } from './settings-change-tracking';
|
||||
|
||||
/**
|
||||
* Values of the restart-only settings the running app started with. Root
|
||||
* provided on purpose: the settings page (and its form facade) is recreated
|
||||
* every time it opens, but what the app launched with does not change until
|
||||
* it restarts, so the restart notice must compare against one app-lifetime
|
||||
* record.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SettingsLaunchValuesService {
|
||||
private values: Record<string, unknown> | null = null;
|
||||
/** Saved values whose restart reminder the user dismissed with Later. */
|
||||
private dismissed: Record<string, unknown> = {};
|
||||
|
||||
/** Records the stored values once, on the first settings open. */
|
||||
captureOnce(read: (control: string) => unknown): void {
|
||||
if (this.values) return;
|
||||
const values: Record<string, unknown> = {};
|
||||
for (const control of Object.keys(SETTINGS_RESTART_CONTROLS)) {
|
||||
values[control] = read(control);
|
||||
}
|
||||
this.values = values;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the running app actually uses when that differs from the stored
|
||||
* value (a frame-copy opt-in the engine could not honour).
|
||||
*/
|
||||
set(control: string, value: unknown): void {
|
||||
if (!(control in SETTINGS_RESTART_CONTROLS)) return;
|
||||
this.values = { ...(this.values ?? {}), [control]: value };
|
||||
}
|
||||
|
||||
get(): Readonly<Record<string, unknown>> {
|
||||
return this.values ?? {};
|
||||
}
|
||||
|
||||
/** Later: this saved value needs no further reminder this app run. */
|
||||
dismiss(control: string, savedValue: unknown): void {
|
||||
this.dismissed = { ...this.dismissed, [control]: savedValue };
|
||||
}
|
||||
|
||||
/** A dismissed reminder returns only once the saved value changes. */
|
||||
isDismissed(control: string, savedValue: unknown): boolean {
|
||||
return (
|
||||
control in this.dismissed && this.dismissed[control] === savedValue
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -92,12 +92,26 @@ describe('buildSettingsSectionNavItems', () => {
|
||||
'playback',
|
||||
'epg',
|
||||
'dashboard',
|
||||
'remote-control',
|
||||
'tmdb',
|
||||
'parental',
|
||||
'remote-control',
|
||||
'backup',
|
||||
'reset',
|
||||
'about',
|
||||
]);
|
||||
});
|
||||
|
||||
it('groups the sections and pins About to the footer', () => {
|
||||
const items = buildSettingsSectionNavItems({
|
||||
supportsEpg: true,
|
||||
supportsRemoteControl: true,
|
||||
});
|
||||
const groupOf = (id: string) =>
|
||||
items.find((item) => item.id === id)?.group;
|
||||
|
||||
expect(groupOf('general')).toBe('app');
|
||||
expect(groupOf('tmdb')).toBe('library');
|
||||
expect(groupOf('remote-control')).toBe('devices');
|
||||
expect(groupOf('backup')).toBe('data');
|
||||
expect(groupOf('about')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -177,5 +177,6 @@ export function buildSettingsSectionNavItems({
|
||||
label: section.navLabelKey,
|
||||
icon: section.icon,
|
||||
visible: meetsSettingsRequirements(section.requires, capabilities),
|
||||
...(section.group ? { group: section.group } : {}),
|
||||
}));
|
||||
}
|
||||
@@ -1,145 +1,151 @@
|
||||
<section class="settings-group" id="parental">
|
||||
<header class="settings-group__header">
|
||||
<div class="settings-group__header-icon">
|
||||
<mat-icon>family_restroom</mat-icon>
|
||||
</div>
|
||||
<div class="settings-group__header-text">
|
||||
<h3>{{ 'SETTINGS.PARENTAL_LOCK.TITLE' | translate }}</h3>
|
||||
<p>{{ 'SETTINGS.PARENTAL_LOCK.DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
<h2>{{ 'SETTINGS.PARENTAL_LOCK.TITLE' | translate }}</h2>
|
||||
<p>{{ 'SETTINGS.PARENTAL_LOCK.DESCRIPTION' | translate }}</p>
|
||||
</header>
|
||||
|
||||
<div class="setting-item" data-setting-id="parental-lock-enable">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.PARENTAL_LOCK.ENABLE' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.PARENTAL_LOCK.ENABLE_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-slide-toggle
|
||||
data-test-id="parental-lock-enabled"
|
||||
[checked]="enabled()"
|
||||
[disabled]="busy()"
|
||||
(change)="onToggleEnabled($event)"
|
||||
[attr.aria-label]="'SETTINGS.PARENTAL_LOCK.ENABLE' | translate"
|
||||
></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (enabled()) {
|
||||
<div class="setting-item" data-setting-id="parental-lock-status">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.PARENTAL_LOCK.STATUS' | translate }}</h4>
|
||||
<p>
|
||||
{{
|
||||
(unlocked()
|
||||
? 'SETTINGS.PARENTAL_LOCK.STATUS_UNLOCKED'
|
||||
: 'SETTINGS.PARENTAL_LOCK.STATUS_LOCKED'
|
||||
) | translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
@if (unlocked()) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
data-test-id="parental-lock-lock-now"
|
||||
<section class="settings-section">
|
||||
<div class="settings-card">
|
||||
<div class="setting-item" data-setting-id="parental-lock-enable">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.PARENTAL_LOCK.ENABLE' | translate }}</h4>
|
||||
<p>
|
||||
{{ 'SETTINGS.PARENTAL_LOCK.ENABLE_DESCRIPTION' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-slide-toggle
|
||||
data-test-id="parental-lock-enabled"
|
||||
[checked]="enabled()"
|
||||
[disabled]="busy()"
|
||||
(click)="lockNow.emit()"
|
||||
>
|
||||
<mat-icon>lock</mat-icon>
|
||||
{{ 'SETTINGS.PARENTAL_LOCK.LOCK_NOW' | translate }}
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
data-test-id="parental-lock-unlock"
|
||||
[disabled]="busy()"
|
||||
(click)="unlock.emit()"
|
||||
>
|
||||
<mat-icon>lock_open</mat-icon>
|
||||
{{ 'SETTINGS.PARENTAL_LOCK.UNLOCK' | translate }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" data-setting-id="parental-lock-relock">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.PARENTAL_LOCK.RELOCK' | translate }}</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.PARENTAL_LOCK.RELOCK_DESCRIPTION' | translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>{{
|
||||
'SETTINGS.PARENTAL_LOCK.RELOCK' | translate
|
||||
}}</mat-label>
|
||||
<mat-select
|
||||
data-test-id="parental-lock-relock"
|
||||
[value]="relockMinutes()"
|
||||
[disabled]="busy() || !unlocked()"
|
||||
(selectionChange)="
|
||||
relockMinutesChanged.emit($event.value)
|
||||
(change)="onToggleEnabled($event)"
|
||||
[attr.aria-label]="
|
||||
'SETTINGS.PARENTAL_LOCK.ENABLE' | translate
|
||||
"
|
||||
>
|
||||
@for (option of relockOptions(); track option) {
|
||||
<mat-option [value]="option">
|
||||
{{
|
||||
relockLabelKey(option)
|
||||
| translate: { minutes: option }
|
||||
}}
|
||||
</mat-option>
|
||||
></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (enabled()) {
|
||||
<div class="setting-item" data-setting-id="parental-lock-status">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.PARENTAL_LOCK.STATUS' | translate }}</h4>
|
||||
<p>
|
||||
{{
|
||||
(unlocked()
|
||||
? 'SETTINGS.PARENTAL_LOCK.STATUS_UNLOCKED'
|
||||
: 'SETTINGS.PARENTAL_LOCK.STATUS_LOCKED'
|
||||
) | translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
@if (unlocked()) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
data-test-id="parental-lock-lock-now"
|
||||
[disabled]="busy()"
|
||||
(click)="lockNow.emit()"
|
||||
>
|
||||
<mat-icon>lock</mat-icon>
|
||||
{{ 'SETTINGS.PARENTAL_LOCK.LOCK_NOW' | translate }}
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
data-test-id="parental-lock-unlock"
|
||||
[disabled]="busy()"
|
||||
(click)="unlock.emit()"
|
||||
>
|
||||
<mat-icon>lock_open</mat-icon>
|
||||
{{ 'SETTINGS.PARENTAL_LOCK.UNLOCK' | translate }}
|
||||
</button>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" data-setting-id="parental-lock-change-pin">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.PARENTAL_LOCK.CHANGE_PIN' | translate }}</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.PARENTAL_LOCK.CHANGE_PIN_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
data-test-id="parental-lock-change-pin"
|
||||
[disabled]="busy() || !hasPin()"
|
||||
(click)="changePin.emit()"
|
||||
>
|
||||
<mat-icon>password</mat-icon>
|
||||
{{ 'SETTINGS.PARENTAL_LOCK.CHANGE_PIN' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item" data-setting-id="parental-lock-relock">
|
||||
<div class="setting-item__meta">
|
||||
<h4 id="parental-lock-relock-label">
|
||||
{{ 'SETTINGS.PARENTAL_LOCK.RELOCK' | translate }}
|
||||
</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.PARENTAL_LOCK.RELOCK_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<mat-form-field
|
||||
class="settings-select"
|
||||
appearance="outline"
|
||||
subscriptSizing="dynamic"
|
||||
>
|
||||
<mat-select
|
||||
data-test-id="parental-lock-relock"
|
||||
aria-labelledby="parental-lock-relock-label"
|
||||
[value]="relockMinutes()"
|
||||
[disabled]="busy() || !unlocked()"
|
||||
(selectionChange)="
|
||||
relockMinutesChanged.emit($event.value)
|
||||
"
|
||||
>
|
||||
@for (option of relockOptions(); track option) {
|
||||
<mat-option [value]="option">
|
||||
{{
|
||||
relockLabelKey(option)
|
||||
| translate: { minutes: option }
|
||||
}}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" data-setting-id="parental-lock-how-to">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.PARENTAL_LOCK.HOW_TO_LOCK' | translate }}</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.PARENTAL_LOCK.HOW_TO_LOCK_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div class="setting-item" data-setting-id="parental-lock-change-pin">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.PARENTAL_LOCK.CHANGE_PIN' | translate }}</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.PARENTAL_LOCK.CHANGE_PIN_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
data-test-id="parental-lock-change-pin"
|
||||
[disabled]="busy() || !hasPin()"
|
||||
(click)="changePin.emit()"
|
||||
>
|
||||
<mat-icon>password</mat-icon>
|
||||
{{ 'SETTINGS.PARENTAL_LOCK.CHANGE_PIN' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-item__meta">
|
||||
<p>{{ 'SETTINGS.PARENTAL_LOCK.DISCLAIMER' | translate }}</p>
|
||||
<div class="setting-item" data-setting-id="parental-lock-how-to">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.PARENTAL_LOCK.HOW_TO_LOCK' | translate }}</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.PARENTAL_LOCK.HOW_TO_LOCK_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="settings-foot">
|
||||
<span>{{ 'SETTINGS.PARENTAL_LOCK.DISCLAIMER' | translate }}</span>
|
||||
</div>
|
||||
</section>
|
||||
@@ -74,6 +74,7 @@ function createForm(player: VideoPlayer): FormGroup {
|
||||
webPlayerSharedControls: new FormControl(false),
|
||||
playerAmbientMode: new FormControl(false),
|
||||
detailTrailerBackdrop: new FormControl(false),
|
||||
showCaptions: new FormControl(false),
|
||||
playerUpNextRail: new FormControl(true),
|
||||
playerUpNextCard: new FormControl(true),
|
||||
fullscreenChannelPanel: new FormControl(true),
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -167,31 +167,31 @@ describe('SettingsPlaybackSectionComponent', () => {
|
||||
}
|
||||
);
|
||||
|
||||
it('binds the shared web controls checkbox to the settings form', () => {
|
||||
it('binds the shared web controls switch to the settings form', () => {
|
||||
const form = createForm();
|
||||
form.controls['webPlayerSharedControls'].setValue(true);
|
||||
fixture.componentRef.setInput('form', form);
|
||||
fixture.detectChanges();
|
||||
|
||||
const checkbox = (
|
||||
const toggle = (
|
||||
fixture.nativeElement as HTMLElement
|
||||
).querySelector<HTMLInputElement>(
|
||||
'[data-test-id="web-player-shared-controls-toggle"] input[type="checkbox"]'
|
||||
).querySelector<HTMLButtonElement>(
|
||||
'[data-test-id="web-player-shared-controls-toggle"] button[role="switch"]'
|
||||
);
|
||||
|
||||
expect(checkbox?.checked).toBe(true);
|
||||
expect(toggle?.getAttribute('aria-checked')).toBe('true');
|
||||
});
|
||||
|
||||
it('labels the rendered native shared web controls checkbox', () => {
|
||||
it('labels the rendered shared web controls switch', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
const checkbox = (
|
||||
const toggle = (
|
||||
fixture.nativeElement as HTMLElement
|
||||
).querySelector<HTMLInputElement>(
|
||||
'[data-test-id="web-player-shared-controls-toggle"] input[type="checkbox"]'
|
||||
).querySelector<HTMLButtonElement>(
|
||||
'[data-test-id="web-player-shared-controls-toggle"] button[role="switch"]'
|
||||
);
|
||||
|
||||
expect(checkbox?.getAttribute('aria-label')).toBe(
|
||||
expect(toggle?.getAttribute('aria-label')).toBe(
|
||||
WEB_PLAYER_SHARED_CONTROLS_LABEL
|
||||
);
|
||||
});
|
||||
@@ -458,6 +458,7 @@ function createForm(player = VideoPlayer.VideoJs): FormGroup {
|
||||
webPlayerSharedControls: new FormControl(false),
|
||||
playerAmbientMode: new FormControl(false),
|
||||
detailTrailerBackdrop: new FormControl(false),
|
||||
showCaptions: new FormControl(false),
|
||||
playerUpNextRail: new FormControl(true),
|
||||
playerUpNextCard: new FormControl(true),
|
||||
fullscreenChannelPanel: new FormControl(true),
|
||||
|
||||
@@ -8,11 +8,12 @@ import {
|
||||
} from '@angular/core';
|
||||
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import {
|
||||
StreamFormat,
|
||||
@@ -27,11 +28,12 @@ import { markSectionForCheckOnFormEvents } from './settings-section-form-render'
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatButtonModule,
|
||||
MatCheckboxModule,
|
||||
MatFormFieldModule,
|
||||
MatIconModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
MatSlideToggleModule,
|
||||
MatTooltipModule,
|
||||
ReactiveFormsModule,
|
||||
TranslateModule,
|
||||
],
|
||||
@@ -114,4 +116,8 @@ export class SettingsPlaybackSectionComponent {
|
||||
const player = this.form().value.player;
|
||||
return player === VideoPlayer.MPV || player === VideoPlayer.VLC;
|
||||
}
|
||||
|
||||
isEmbeddedMpvSelected(): boolean {
|
||||
return this.form().value.player === VideoPlayer.EmbeddedMpv;
|
||||
}
|
||||
}
|
||||
@@ -1,134 +1,153 @@
|
||||
<section class="settings-group" id="remote-control" [formGroup]="form()">
|
||||
<header class="settings-group__header">
|
||||
<div class="settings-group__header-icon">
|
||||
<mat-icon>settings_remote</mat-icon>
|
||||
</div>
|
||||
<div class="settings-group__header-text">
|
||||
<h3>{{ 'SETTINGS.REMOTE_CONTROL' | translate }}</h3>
|
||||
<p>{{ 'SETTINGS.REMOTE_CONTROL_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
<h2>{{ 'SETTINGS.REMOTE_CONTROL' | translate }}</h2>
|
||||
<p>{{ 'SETTINGS.REMOTE_CONTROL_DESCRIPTION' | translate }}</p>
|
||||
</header>
|
||||
|
||||
<div class="setting-item" data-setting-id="remote-control">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.REMOTE_CONTROL' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.REMOTE_CONTROL_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox
|
||||
formControlName="remoteControl"
|
||||
data-test-id="remote-control-enabled"
|
||||
></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (form().value.remoteControl === true) {
|
||||
<div class="setting-item" data-setting-id="remote-control-port">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.REMOTE_CONTROL_PORT' | translate }}</h4>
|
||||
<p>
|
||||
{{ 'SETTINGS.REMOTE_CONTROL_PORT_DESCRIPTION' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>{{
|
||||
'SETTINGS.PORT_RANGE_LABEL' | translate
|
||||
}}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="number"
|
||||
id="remoteControlPort"
|
||||
data-test-id="remote-control-port"
|
||||
formControlName="remoteControlPort"
|
||||
min="1"
|
||||
max="65535"
|
||||
step="1"
|
||||
/>
|
||||
@if (
|
||||
form().get('remoteControlPort')?.hasError('required')
|
||||
) {
|
||||
<mat-error>{{
|
||||
'SETTINGS.PORT_REQUIRED' | translate
|
||||
}}</mat-error>
|
||||
}
|
||||
@if (form().get('remoteControlPort')?.hasError('min')) {
|
||||
<mat-error>{{
|
||||
'SETTINGS.PORT_MIN' | translate
|
||||
}}</mat-error>
|
||||
}
|
||||
@if (form().get('remoteControlPort')?.hasError('max')) {
|
||||
<mat-error>{{
|
||||
'SETTINGS.PORT_MAX' | translate
|
||||
}}</mat-error>
|
||||
}
|
||||
@if (form().get('remoteControlPort')?.hasError('pattern')) {
|
||||
<mat-error>{{
|
||||
'SETTINGS.PORT_PATTERN' | translate
|
||||
}}</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (localIpAddresses().length > 0) {
|
||||
<div class="setting-item" data-setting-id="remote-control-url">
|
||||
<section class="settings-section">
|
||||
<div class="settings-card">
|
||||
<div class="setting-item" data-setting-id="remote-control">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.REMOTE_CONTROL_URL' | translate }}</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.REMOTE_CONTROL_URL_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
<h4 id="remote-control-label">
|
||||
{{ 'SETTINGS.REMOTE_CONTROL' | translate }}
|
||||
</h4>
|
||||
<p>{{ 'SETTINGS.REMOTE_CONTROL_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
<div class="setting-item__control remote-control-urls">
|
||||
@for (ip of localIpAddresses(); track ip) {
|
||||
<div class="remote-control-url-item">
|
||||
<div class="url-row">
|
||||
<a
|
||||
[href]="
|
||||
'http://' +
|
||||
ip +
|
||||
':' +
|
||||
form().value.remoteControlPort
|
||||
"
|
||||
target="_blank"
|
||||
class="remote-control-url"
|
||||
>
|
||||
http://{{ ip }}:{{
|
||||
form().value.remoteControlPort
|
||||
}}
|
||||
</a>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
(click)="toggleQrCode.emit(ip)"
|
||||
[matTooltip]="
|
||||
'SETTINGS.SHOW_QR_CODE' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>qr_code_2</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
@if (visibleQrCodeIp() === ip) {
|
||||
<div class="qr-code-container">
|
||||
<qrcode
|
||||
[qrdata]="
|
||||
'http://' +
|
||||
ip +
|
||||
':' +
|
||||
form().value.remoteControlPort
|
||||
"
|
||||
[width]="150"
|
||||
errorCorrectionLevel="M"
|
||||
></qrcode>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-slide-toggle
|
||||
formControlName="remoteControl"
|
||||
aria-labelledby="remote-control-label"
|
||||
data-test-id="remote-control-enabled"
|
||||
></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (form().value.remoteControl === true) {
|
||||
<div class="setting-item" data-setting-id="remote-control-port">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.REMOTE_CONTROL_PORT' | translate }}</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.REMOTE_CONTROL_PORT_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<mat-form-field
|
||||
class="settings-select"
|
||||
appearance="outline"
|
||||
subscriptSizing="dynamic"
|
||||
>
|
||||
<mat-label>{{
|
||||
'SETTINGS.PORT_RANGE_LABEL' | translate
|
||||
}}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="number"
|
||||
id="remoteControlPort"
|
||||
data-test-id="remote-control-port"
|
||||
formControlName="remoteControlPort"
|
||||
min="1"
|
||||
max="65535"
|
||||
step="1"
|
||||
/>
|
||||
@if (
|
||||
form()
|
||||
.get('remoteControlPort')
|
||||
?.hasError('required')
|
||||
) {
|
||||
<mat-error>{{
|
||||
'SETTINGS.PORT_REQUIRED' | translate
|
||||
}}</mat-error>
|
||||
}
|
||||
@if (form().get('remoteControlPort')?.hasError('min')) {
|
||||
<mat-error>{{
|
||||
'SETTINGS.PORT_MIN' | translate
|
||||
}}</mat-error>
|
||||
}
|
||||
@if (form().get('remoteControlPort')?.hasError('max')) {
|
||||
<mat-error>{{
|
||||
'SETTINGS.PORT_MAX' | translate
|
||||
}}</mat-error>
|
||||
}
|
||||
@if (
|
||||
form()
|
||||
.get('remoteControlPort')
|
||||
?.hasError('pattern')
|
||||
) {
|
||||
<mat-error>{{
|
||||
'SETTINGS.PORT_PATTERN' | translate
|
||||
}}</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (localIpAddresses().length > 0) {
|
||||
<div class="setting-item" data-setting-id="remote-control-url">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.REMOTE_CONTROL_URL' | translate }}</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.REMOTE_CONTROL_URL_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control remote-control-urls">
|
||||
@for (ip of localIpAddresses(); track ip) {
|
||||
<div class="remote-control-url-item">
|
||||
<div class="url-row">
|
||||
<a
|
||||
[href]="
|
||||
'http://' +
|
||||
ip +
|
||||
':' +
|
||||
form().value.remoteControlPort
|
||||
"
|
||||
target="_blank"
|
||||
class="remote-control-url"
|
||||
>
|
||||
http://{{ ip }}:{{
|
||||
form().value.remoteControlPort
|
||||
}}
|
||||
</a>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
(click)="toggleQrCode.emit(ip)"
|
||||
[matTooltip]="
|
||||
'SETTINGS.SHOW_QR_CODE' | translate
|
||||
"
|
||||
[attr.aria-label]="
|
||||
'SETTINGS.SHOW_QR_CODE' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>qr_code_2</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
@if (visibleQrCodeIp() === ip) {
|
||||
<div class="qr-code-container">
|
||||
<qrcode
|
||||
[qrdata]="
|
||||
'http://' +
|
||||
ip +
|
||||
':' +
|
||||
form().value
|
||||
.remoteControlPort
|
||||
"
|
||||
[width]="150"
|
||||
errorCorrectionLevel="M"
|
||||
></qrcode>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
@@ -0,0 +1,45 @@
|
||||
// Remote control page: the address list with QR codes.
|
||||
app-settings-remote-control-section {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
// ─── Remote control ────────────────────────────────────────────────────────
|
||||
.remote-control-urls {
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.url-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.remote-control-url {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12.5px;
|
||||
color: var(--app-heading-color);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.qr-code-container {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
// Pane width, see settings.component.scss.
|
||||
@container (max-width: 600px) {
|
||||
.remote-control-urls,
|
||||
.qr-code-container,
|
||||
.url-row {
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,10 @@ import {
|
||||
} from '@angular/core';
|
||||
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { QRCodeComponent } from 'angularx-qrcode';
|
||||
@@ -20,10 +20,10 @@ import { markSectionForCheckOnFormEvents } from './settings-section-form-render'
|
||||
selector: 'app-settings-remote-control-section',
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
MatCheckboxModule,
|
||||
MatFormFieldModule,
|
||||
MatIconModule,
|
||||
MatInputModule,
|
||||
MatSlideToggleModule,
|
||||
MatTooltipModule,
|
||||
QRCodeComponent,
|
||||
ReactiveFormsModule,
|
||||
@@ -32,7 +32,7 @@ import { markSectionForCheckOnFormEvents } from './settings-section-form-render'
|
||||
templateUrl: './settings-remote-control-section.component.html',
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styles: [':host { display: contents; }'],
|
||||
styleUrls: ['./settings-remote-control-section.component.scss'],
|
||||
})
|
||||
export class SettingsRemoteControlSectionComponent {
|
||||
readonly form = input.required<FormGroup>();
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
<section class="settings-group" id="reset">
|
||||
<header class="settings-group__header">
|
||||
<div class="settings-group__header-icon">
|
||||
<mat-icon>delete_sweep</mat-icon>
|
||||
</div>
|
||||
<div class="settings-group__header-text">
|
||||
<h3>{{ 'SETTINGS.RESET' | translate }}</h3>
|
||||
<p>{{ 'SETTINGS.RESET_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div
|
||||
class="setting-item danger-zone"
|
||||
data-setting-id="remove-all-playlists"
|
||||
>
|
||||
<div class="danger-zone__header">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.REMOVE_ALL' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.REMOVE_ALL_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
<button
|
||||
mat-button
|
||||
class="danger-zone__button app-destructive-button"
|
||||
[class.danger-zone__button--busy]="isRemovingAllPlaylists()"
|
||||
[disabled]="!canRemoveAllPlaylists()"
|
||||
[attr.aria-busy]="isRemovingAllPlaylists()"
|
||||
(click)="
|
||||
$event.preventDefault();
|
||||
$event.stopPropagation();
|
||||
removeAll.emit()
|
||||
"
|
||||
>
|
||||
@if (isRemovingAllPlaylists()) {
|
||||
<mat-progress-spinner
|
||||
class="button-spinner"
|
||||
diameter="16"
|
||||
mode="indeterminate"
|
||||
/>
|
||||
<span class="inline-actions__label">{{
|
||||
'SETTINGS.REMOVE_ALL_IN_PROGRESS' | translate
|
||||
}}</span>
|
||||
} @else {
|
||||
<span class="inline-actions__label">{{
|
||||
'SETTINGS.REMOVE_ALL_BUTTON' | translate
|
||||
}}</span>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
<div class="danger-zone__summary" aria-live="polite">
|
||||
<span class="danger-zone__summary-pill">
|
||||
<mat-icon class="danger-zone__summary-icon">{{
|
||||
sourceIcons.m3u
|
||||
}}</mat-icon>
|
||||
<span class="danger-zone__summary-label">{{
|
||||
'HOME.PLAYLIST_TYPES.M3U' | translate
|
||||
}}</span>
|
||||
<span class="danger-zone__summary-count">{{
|
||||
playlistDeleteSummary().m3u
|
||||
}}</span>
|
||||
</span>
|
||||
<span class="danger-zone__summary-pill">
|
||||
<mat-icon class="danger-zone__summary-icon">{{
|
||||
sourceIcons.xtream
|
||||
}}</mat-icon>
|
||||
<span class="danger-zone__summary-label">{{
|
||||
'HOME.PLAYLIST_TYPES.XTREAM' | translate
|
||||
}}</span>
|
||||
<span class="danger-zone__summary-count">{{
|
||||
playlistDeleteSummary().xtream
|
||||
}}</span>
|
||||
</span>
|
||||
<span class="danger-zone__summary-pill">
|
||||
<mat-icon class="danger-zone__summary-icon">{{
|
||||
sourceIcons.stalker
|
||||
}}</mat-icon>
|
||||
<span class="danger-zone__summary-label">{{
|
||||
'HOME.PLAYLIST_TYPES.STALKER' | translate
|
||||
}}</span>
|
||||
<span class="danger-zone__summary-count">{{
|
||||
playlistDeleteSummary().stalker
|
||||
}}</span>
|
||||
</span>
|
||||
</div>
|
||||
@if (removeAllProgressLabel(); as progressLabel) {
|
||||
<p class="danger-zone__progress">
|
||||
{{ progressLabel }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
@@ -1,37 +0,0 @@
|
||||
import {
|
||||
Component,
|
||||
input,
|
||||
output,
|
||||
ViewEncapsulation,
|
||||
ChangeDetectionStrategy,
|
||||
} from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { SOURCE_TYPE_ICONS } from '@iptvnator/shared/interfaces';
|
||||
import { SettingsPlaylistDeleteSummary } from './settings.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings-reset-section',
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatProgressSpinnerModule,
|
||||
TranslateModule,
|
||||
],
|
||||
templateUrl: './settings-reset-section.component.html',
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styles: [':host { display: contents; }'],
|
||||
})
|
||||
export class SettingsResetSectionComponent {
|
||||
readonly sourceIcons = SOURCE_TYPE_ICONS;
|
||||
readonly isRemovingAllPlaylists = input(false);
|
||||
readonly canRemoveAllPlaylists = input(false);
|
||||
readonly playlistDeleteSummary =
|
||||
input.required<SettingsPlaylistDeleteSummary>();
|
||||
readonly removeAllProgressLabel = input<string | null>(null);
|
||||
|
||||
readonly removeAll = output<void>();
|
||||
}
|
||||
@@ -21,6 +21,11 @@ const EN = JSON.parse(
|
||||
readFileSync(join(SETTINGS_DIR, '../../assets/i18n/en.json'), 'utf8')
|
||||
);
|
||||
|
||||
/**
|
||||
* Reveal anchors: any element carrying `data-setting-id` (rows, but also
|
||||
* the About cards and footer), plus every `setting-item` row so a titled
|
||||
* row without an anchor is caught too.
|
||||
*/
|
||||
function readTemplateRows(): TemplateRow[] {
|
||||
const rows: TemplateRow[] = [];
|
||||
const files = readdirSync(SETTINGS_DIR).filter((file) =>
|
||||
@@ -31,7 +36,9 @@ function readTemplateRows(): TemplateRow[] {
|
||||
const html = readFileSync(join(SETTINGS_DIR, file), 'utf8');
|
||||
// Row containers only: `setting-item` itself, not `__meta` etc.
|
||||
const starts = [
|
||||
...html.matchAll(/<div\b[^>]*class="setting-item(?:\s[^"]*)?"/g),
|
||||
...html.matchAll(
|
||||
/<\w+\b(?:[^>]*class="setting-item(?:\s[^"]*)?"|[^>]*data-setting-id=")/g
|
||||
),
|
||||
];
|
||||
starts.forEach((match, index) => {
|
||||
const end = starts[index + 1]?.index ?? html.length;
|
||||
@@ -43,11 +50,11 @@ function readTemplateRows(): TemplateRow[] {
|
||||
id: tag.match(/data-setting-id="([^"]+)"/)?.[1] ?? null,
|
||||
labelKey:
|
||||
chunk.match(
|
||||
/<h4[^>]*>\s*\{\{\s*'(SETTINGS\.[A-Z0-9_]+)'/
|
||||
/<h4[^>]*>\s*\{\{\s*'(SETTINGS\.[A-Z0-9_.]+)'/
|
||||
)?.[1] ?? null,
|
||||
descriptionKey:
|
||||
meta.match(
|
||||
/<p[^>]*>\s*\{\{\s*'(SETTINGS\.[A-Z0-9_]+)'/
|
||||
/<p[^>]*>\s*\{\{\s*'(SETTINGS\.[A-Z0-9_.]+)'/
|
||||
)?.[1] ?? null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,21 +4,19 @@
|
||||
aria-labelledby="settings-search-results-title"
|
||||
>
|
||||
<header class="settings-group__header">
|
||||
<div class="settings-group__header-text">
|
||||
<h3 id="settings-search-results-title">
|
||||
{{ 'SETTINGS.SEARCH_RESULTS_TITLE' | translate }}
|
||||
</h3>
|
||||
<p aria-live="polite" data-test-id="settings-search-summary">
|
||||
{{
|
||||
'SETTINGS.SEARCH_RESULTS_COUNT'
|
||||
| translate: { count: results().length, query: query() }
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<h2 id="settings-search-results-title">
|
||||
{{ 'SETTINGS.SEARCH_RESULTS_TITLE' | translate }}
|
||||
</h2>
|
||||
<p aria-live="polite" data-test-id="settings-search-summary">
|
||||
{{
|
||||
'SETTINGS.SEARCH_RESULTS_COUNT'
|
||||
| translate: { count: results().length, query: query() }
|
||||
}}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
@if (results().length > 0) {
|
||||
<ul class="settings-search-results__list">
|
||||
<ul class="settings-search-results__list settings-card">
|
||||
@for (result of results(); track result.entry.id) {
|
||||
<li>
|
||||
<button
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
gap: 14px;
|
||||
width: 100%;
|
||||
min-height: 56px;
|
||||
padding: 12px 10px;
|
||||
padding: 12px 16px;
|
||||
border: 0;
|
||||
border-top: 1px solid var(--settings-item-border);
|
||||
border-radius: 0;
|
||||
@@ -37,6 +37,11 @@
|
||||
|
||||
li:first-child .settings-search-result {
|
||||
border-top: 0;
|
||||
border-radius: 12px 12px 0 0;
|
||||
}
|
||||
|
||||
li:last-child .settings-search-result {
|
||||
border-radius: 0 0 12px 12px;
|
||||
}
|
||||
|
||||
.settings-search-result__icon,
|
||||
@@ -54,16 +59,15 @@ li:first-child .settings-search-result {
|
||||
}
|
||||
|
||||
.settings-search-result__label {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
font-size: 13.5px;
|
||||
font-weight: 500;
|
||||
color: var(--app-heading-color);
|
||||
}
|
||||
|
||||
.settings-search-result__description {
|
||||
overflow: hidden;
|
||||
color: var(--settings-muted);
|
||||
font-size: 0.84rem;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.45;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -71,11 +75,12 @@ li:first-child .settings-search-result {
|
||||
|
||||
.settings-search-result__section {
|
||||
flex-shrink: 0;
|
||||
padding: 2px 10px;
|
||||
border: 1px solid var(--settings-item-border);
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
color: var(--settings-muted);
|
||||
font-size: 0.78rem;
|
||||
background: var(--settings-chip-bg);
|
||||
color: var(--settings-chip-fg);
|
||||
font-size: 10.5px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,176 +1,177 @@
|
||||
<section class="settings-group" id="tmdb" [formGroup]="form()">
|
||||
<header class="settings-group__header">
|
||||
<div class="settings-group__header-icon">
|
||||
<mat-icon>movie</mat-icon>
|
||||
</div>
|
||||
<div class="settings-group__header-text">
|
||||
<h3>{{ 'SETTINGS.TMDB' | translate }}</h3>
|
||||
<p>{{ 'SETTINGS.TMDB_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
<h2>{{ 'SETTINGS.TMDB' | translate }}</h2>
|
||||
<p>{{ 'SETTINGS.TMDB_DESCRIPTION' | translate }}</p>
|
||||
</header>
|
||||
|
||||
<div formGroupName="tmdb">
|
||||
<div class="setting-item" data-setting-id="tmdb-enable">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.TMDB_ENABLE' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.TMDB_ENABLE_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox
|
||||
formControlName="enabled"
|
||||
data-test-id="tmdb-enabled"
|
||||
></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (form().value.tmdb?.enabled === true) {
|
||||
<div class="setting-item" data-setting-id="tmdb-api-key">
|
||||
<section class="settings-section">
|
||||
<div class="settings-card" formGroupName="tmdb">
|
||||
<div class="setting-item" data-setting-id="tmdb-enable">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.TMDB_API_KEY' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.TMDB_API_KEY_DESCRIPTION' | translate }}</p>
|
||||
<p>
|
||||
<a
|
||||
href="https://www.themoviedb.org/settings/api"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
data-test-id="tmdb-get-key-link"
|
||||
<h4 id="tmdb-enable-label">
|
||||
{{ 'SETTINGS.TMDB_ENABLE' | translate }}
|
||||
</h4>
|
||||
<p>{{ 'SETTINGS.TMDB_ENABLE_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-slide-toggle
|
||||
formControlName="enabled"
|
||||
aria-labelledby="tmdb-enable-label"
|
||||
data-test-id="tmdb-enabled"
|
||||
></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (form().value.tmdb?.enabled === true) {
|
||||
<div class="setting-item" data-setting-id="tmdb-api-key">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.TMDB_API_KEY' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.TMDB_API_KEY_DESCRIPTION' | translate }}</p>
|
||||
<p>
|
||||
<a
|
||||
href="https://www.themoviedb.org/settings/api"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
data-test-id="tmdb-get-key-link"
|
||||
>
|
||||
{{ 'SETTINGS.TMDB_GET_KEY_HINT' | translate }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__control--stack">
|
||||
<mat-form-field
|
||||
class="settings-text-field"
|
||||
appearance="outline"
|
||||
subscriptSizing="dynamic"
|
||||
>
|
||||
{{ 'SETTINGS.TMDB_GET_KEY_HINT' | translate }}
|
||||
</a>
|
||||
<mat-label>{{
|
||||
'SETTINGS.TMDB_API_KEY' | translate
|
||||
}}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
formControlName="apiKey"
|
||||
data-test-id="tmdb-api-key"
|
||||
/>
|
||||
</mat-form-field>
|
||||
<div class="tmdb-key-test">
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
[disabled]="
|
||||
!enteredApiKey || keyTestState() === 'testing'
|
||||
"
|
||||
(click)="testApiKey()"
|
||||
data-test-id="tmdb-test-key"
|
||||
>
|
||||
@if (keyTestState() === 'testing') {
|
||||
<mat-spinner diameter="16" />
|
||||
} @else {
|
||||
<mat-icon>network_check</mat-icon>
|
||||
}
|
||||
{{ 'SETTINGS.TMDB_TEST_BUTTON' | translate }}
|
||||
</button>
|
||||
@if (keyTestState() === 'success') {
|
||||
<span
|
||||
class="tmdb-key-test__result tmdb-key-test__result--success"
|
||||
data-test-id="tmdb-test-success"
|
||||
>
|
||||
<mat-icon>check_circle</mat-icon>
|
||||
{{ 'SETTINGS.TMDB_TEST_SUCCESS' | translate }}
|
||||
</span>
|
||||
} @else if (keyTestState() === 'error') {
|
||||
<span
|
||||
class="tmdb-key-test__result tmdb-key-test__result--error"
|
||||
data-test-id="tmdb-test-error"
|
||||
>
|
||||
<mat-icon>error</mat-icon>
|
||||
{{ 'SETTINGS.TMDB_TEST_FAILED' | translate }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (m3uVodDetailsControl; as m3uVodDetails) {
|
||||
<div
|
||||
class="setting-item"
|
||||
data-setting-id="tmdb-m3u-vod-details"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<h4 id="tmdb-m3u-vod-details-label">
|
||||
{{ 'SETTINGS.TMDB_M3U_VOD_DETAILS' | translate }}
|
||||
</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.TMDB_M3U_VOD_DETAILS_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-slide-toggle
|
||||
[formControl]="m3uVodDetails"
|
||||
aria-labelledby="tmdb-m3u-vod-details-label"
|
||||
data-test-id="tmdb-m3u-vod-details"
|
||||
></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
<div class="settings-section__header">
|
||||
<h3>{{ 'SETTINGS.CARD_CACHE' | translate }}</h3>
|
||||
</div>
|
||||
<div class="settings-card">
|
||||
<div class="setting-item" data-setting-id="tmdb-cache">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.TMDB_CACHE' | translate }}</h4>
|
||||
<p>
|
||||
{{ 'SETTINGS.TMDB_CACHE_DESCRIPTION' | translate }}
|
||||
</p>
|
||||
<p class="tmdb-cache__size" data-test-id="tmdb-cache-size">
|
||||
@if (cacheError()) {
|
||||
<span class="tmdb-cache__error">
|
||||
{{ 'SETTINGS.TMDB_CACHE_ERROR' | translate }}
|
||||
</span>
|
||||
} @else if (cacheStats(); as stats) {
|
||||
{{
|
||||
'SETTINGS.TMDB_CACHE_SIZE'
|
||||
| translate
|
||||
: {
|
||||
entries: stats.entries,
|
||||
size: formatBytes(stats.bytes),
|
||||
}
|
||||
}}
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<mat-form-field
|
||||
appearance="outline"
|
||||
subscriptSizing="dynamic"
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
[disabled]="isClearing() || cacheStats()?.entries === 0"
|
||||
(click)="clearCache()"
|
||||
data-test-id="tmdb-clear-cache"
|
||||
>
|
||||
<mat-label>{{
|
||||
'SETTINGS.TMDB_API_KEY' | translate
|
||||
}}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
formControlName="apiKey"
|
||||
data-test-id="tmdb-api-key"
|
||||
/>
|
||||
</mat-form-field>
|
||||
<div class="tmdb-key-test">
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
[disabled]="
|
||||
!enteredApiKey || keyTestState() === 'testing'
|
||||
"
|
||||
(click)="testApiKey()"
|
||||
data-test-id="tmdb-test-key"
|
||||
>
|
||||
@if (keyTestState() === 'testing') {
|
||||
<mat-spinner diameter="16" />
|
||||
} @else {
|
||||
<mat-icon>network_check</mat-icon>
|
||||
}
|
||||
{{ 'SETTINGS.TMDB_TEST_BUTTON' | translate }}
|
||||
</button>
|
||||
@if (keyTestState() === 'success') {
|
||||
<span
|
||||
class="tmdb-key-test__result tmdb-key-test__result--success"
|
||||
data-test-id="tmdb-test-success"
|
||||
>
|
||||
<mat-icon>check_circle</mat-icon>
|
||||
{{ 'SETTINGS.TMDB_TEST_SUCCESS' | translate }}
|
||||
</span>
|
||||
} @else if (keyTestState() === 'error') {
|
||||
<span
|
||||
class="tmdb-key-test__result tmdb-key-test__result--error"
|
||||
data-test-id="tmdb-test-error"
|
||||
>
|
||||
<mat-icon>error</mat-icon>
|
||||
{{ 'SETTINGS.TMDB_TEST_FAILED' | translate }}
|
||||
</span>
|
||||
@if (isClearing()) {
|
||||
<mat-spinner diameter="16" />
|
||||
} @else {
|
||||
<mat-icon>delete_sweep</mat-icon>
|
||||
}
|
||||
</div>
|
||||
{{ 'SETTINGS.TMDB_CACHE_CLEAR' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (form().value.tmdb?.enabled === true) {
|
||||
@if (m3uVodDetailsControl; as m3uVodDetails) {
|
||||
<div
|
||||
class="setting-item"
|
||||
data-setting-id="tmdb-m3u-vod-details"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<h4>
|
||||
{{ 'SETTINGS.TMDB_M3U_VOD_DETAILS' | translate }}
|
||||
</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.TMDB_M3U_VOD_DETAILS_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox
|
||||
[formControl]="m3uVodDetails"
|
||||
data-test-id="tmdb-m3u-vod-details"
|
||||
></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<div class="setting-item" data-setting-id="tmdb-cache">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.TMDB_CACHE' | translate }}</h4>
|
||||
<p>
|
||||
{{ 'SETTINGS.TMDB_CACHE_DESCRIPTION' | translate }}
|
||||
</p>
|
||||
<p class="tmdb-cache__size" data-test-id="tmdb-cache-size">
|
||||
@if (cacheError()) {
|
||||
<span class="tmdb-cache__error">
|
||||
{{ 'SETTINGS.TMDB_CACHE_ERROR' | translate }}
|
||||
</span>
|
||||
} @else if (cacheStats(); as stats) {
|
||||
{{
|
||||
'SETTINGS.TMDB_CACHE_SIZE'
|
||||
| translate
|
||||
: {
|
||||
entries: stats.entries,
|
||||
size: formatBytes(stats.bytes),
|
||||
}
|
||||
}}
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
[disabled]="isClearing() || cacheStats()?.entries === 0"
|
||||
(click)="clearCache()"
|
||||
data-test-id="tmdb-clear-cache"
|
||||
>
|
||||
@if (isClearing()) {
|
||||
<mat-spinner diameter="16" />
|
||||
} @else {
|
||||
<mat-icon>delete_sweep</mat-icon>
|
||||
}
|
||||
{{ 'SETTINGS.TMDB_CACHE_CLEAR' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="setting-item setting-item--full-control">
|
||||
<div class="setting-item__meta">
|
||||
<img
|
||||
src="assets/images/tmdb-logo.svg"
|
||||
alt="TMDB"
|
||||
style="height: 20px; margin-bottom: 8px"
|
||||
/>
|
||||
<p>{{ 'SETTINGS.TMDB_ATTRIBUTION' | translate }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-foot">
|
||||
<img src="assets/images/tmdb-logo.svg" alt="TMDB" />
|
||||
<span>{{ 'SETTINGS.TMDB_ATTRIBUTION' | translate }}</span>
|
||||
</div>
|
||||
</section>
|
||||
@@ -93,7 +93,9 @@ describe('SettingsTmdbSectionComponent', () => {
|
||||
const checkbox = queryByTestId('tmdb-m3u-vod-details');
|
||||
expect(checkbox).not.toBeNull();
|
||||
|
||||
(checkbox?.querySelector('input') as HTMLInputElement).click();
|
||||
(
|
||||
checkbox?.querySelector('button[role="switch"]') as HTMLButtonElement
|
||||
).click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.m3uVodDetailsControl?.value).toBe(
|
||||
|
||||
@@ -8,11 +8,11 @@ import {
|
||||
} from '@angular/core';
|
||||
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { TmdbApiService, TmdbCacheService } from '@iptvnator/services';
|
||||
import type { TmdbCacheStats } from '@iptvnator/shared/interfaces';
|
||||
@@ -24,11 +24,11 @@ type TmdbKeyTestState = 'idle' | 'testing' | 'success' | 'error';
|
||||
selector: 'app-settings-tmdb-section',
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
MatCheckboxModule,
|
||||
MatFormFieldModule,
|
||||
MatIconModule,
|
||||
MatInputModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatSlideToggleModule,
|
||||
ReactiveFormsModule,
|
||||
TranslateModule,
|
||||
],
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
VideoPlayer,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { SettingsContextService } from '@iptvnator/workspace/shell/util/settings-context';
|
||||
import { SettingsStore } from '../services/settings-store.service';
|
||||
import { SettingsComponent } from './settings.component';
|
||||
import {
|
||||
@@ -62,10 +63,10 @@ describe('SettingsComponent form', () => {
|
||||
|
||||
it('stages the desktop portal pause setting until Save', async () => {
|
||||
const checkbox = (fixture.nativeElement as HTMLElement).querySelector(
|
||||
'[data-test-id="portal-connectivity-toggle"] input'
|
||||
) as HTMLInputElement;
|
||||
'[data-test-id="portal-connectivity-toggle"] button[role="switch"]'
|
||||
) as HTMLButtonElement;
|
||||
expect(checkbox).not.toBeNull();
|
||||
expect(checkbox.checked).toBe(true);
|
||||
expect(checkbox.getAttribute('aria-checked')).toBe('true');
|
||||
checkbox.click();
|
||||
fixture.detectChanges();
|
||||
expect(
|
||||
@@ -74,7 +75,7 @@ describe('SettingsComponent form', () => {
|
||||
expect(window.electron.updateSettings).not.toHaveBeenCalled();
|
||||
component.form.hydrateFromStore();
|
||||
fixture.detectChanges();
|
||||
expect(checkbox.checked).toBe(true);
|
||||
expect(checkbox.getAttribute('aria-checked')).toBe('true');
|
||||
checkbox.click();
|
||||
await component.form.save(() => undefined);
|
||||
expect(window.electron.updateSettings).toHaveBeenCalledWith(
|
||||
@@ -84,10 +85,10 @@ describe('SettingsComponent form', () => {
|
||||
|
||||
it('stages the posters-only cover wall until Save', async () => {
|
||||
const checkbox = (fixture.nativeElement as HTMLElement).querySelector(
|
||||
'[data-test-id="cover-titles-toggle"] input'
|
||||
) as HTMLInputElement;
|
||||
'[data-test-id="cover-titles-toggle"] button[role="switch"]'
|
||||
) as HTMLButtonElement;
|
||||
expect(checkbox).not.toBeNull();
|
||||
expect(checkbox.checked).toBe(true);
|
||||
expect(checkbox.getAttribute('aria-checked')).toBe('true');
|
||||
|
||||
checkbox.click();
|
||||
fixture.detectChanges();
|
||||
@@ -377,6 +378,244 @@ describe('SettingsComponent form', () => {
|
||||
expect(unsavedBar()).toBeNull();
|
||||
});
|
||||
|
||||
it('counts the staged changes and marks their pages in the navigation', () => {
|
||||
const settingsCtx = TestBed.inject(SettingsContextService);
|
||||
const message = () =>
|
||||
(fixture.nativeElement as HTMLElement).querySelector(
|
||||
'[data-test-id="settings-unsaved-message"]'
|
||||
)?.textContent;
|
||||
|
||||
component.settingsForm.get('theme')?.setValue(Theme.DarkTheme);
|
||||
component.settingsForm.markAsDirty();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.changeCount()).toBe(1);
|
||||
expect(component.unsavedMessageKey()).toBe(
|
||||
'SETTINGS.UNSAVED_CHANGES_ONE'
|
||||
);
|
||||
expect(settingsCtx.dirtySections()).toEqual(new Set(['general']));
|
||||
|
||||
component.settingsForm
|
||||
.get('dashboardRails.hero')
|
||||
?.setValue(!DEFAULT_DASHBOARD_RAILS.hero);
|
||||
component.settingsForm.get('showCaptions')?.setValue(true);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.changeCount()).toBe(3);
|
||||
expect(component.unsavedMessageKey()).toBe(
|
||||
'SETTINGS.UNSAVED_CHANGES_COUNT'
|
||||
);
|
||||
expect(message()).toContain('SETTINGS.UNSAVED_CHANGES_COUNT');
|
||||
expect(settingsCtx.dirtySections()).toEqual(
|
||||
new Set(['general', 'dashboard', 'playback'])
|
||||
);
|
||||
|
||||
// Reverting by hand leaves the form dirty but counts nothing.
|
||||
component.settingsForm.get('theme')?.setValue(Theme.SystemTheme);
|
||||
component.settingsForm
|
||||
.get('dashboardRails.hero')
|
||||
?.setValue(DEFAULT_DASHBOARD_RAILS.hero);
|
||||
component.settingsForm.get('showCaptions')?.setValue(false);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.changeCount()).toBe(0);
|
||||
expect(component.unsavedMessageKey()).toBe(
|
||||
'SETTINGS.UNSAVED_CHANGES'
|
||||
);
|
||||
expect(settingsCtx.dirtySections().size).toBe(0);
|
||||
expect(unsavedBar()).not.toBeNull();
|
||||
});
|
||||
|
||||
it('saves on Ctrl/Cmd+S while valid and dirty, and swallows the browser save otherwise', async () => {
|
||||
settingsStore.updateSettings.mockResolvedValue(undefined);
|
||||
const press = (init: KeyboardEventInit) => {
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key: 's',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
...init,
|
||||
});
|
||||
document.dispatchEvent(event);
|
||||
return event;
|
||||
};
|
||||
|
||||
// Nothing staged: the page still keeps the browser dialog away.
|
||||
expect(press({ ctrlKey: true }).defaultPrevented).toBe(true);
|
||||
expect(settingsStore.updateSettings).not.toHaveBeenCalled();
|
||||
|
||||
component.settingsForm.get('theme')?.setValue(Theme.DarkTheme);
|
||||
component.settingsForm.markAsDirty();
|
||||
fixture.detectChanges();
|
||||
|
||||
// A plain "s" or a chord with Shift is not the shortcut.
|
||||
expect(press({}).defaultPrevented).toBe(false);
|
||||
expect(
|
||||
press({ metaKey: true, shiftKey: true }).defaultPrevented
|
||||
).toBe(false);
|
||||
expect(settingsStore.updateSettings).not.toHaveBeenCalled();
|
||||
|
||||
// The phone drawer makes the page inert: the chord is not ours.
|
||||
const host = fixture.nativeElement as HTMLElement;
|
||||
host.setAttribute('inert', '');
|
||||
expect(press({ metaKey: true }).defaultPrevented).toBe(false);
|
||||
expect(settingsStore.updateSettings).not.toHaveBeenCalled();
|
||||
host.removeAttribute('inert');
|
||||
|
||||
// Nor while a dialog (the leave dialog) or a select panel is open.
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className = 'cdk-overlay-backdrop';
|
||||
document.body.appendChild(backdrop);
|
||||
expect(press({ metaKey: true }).defaultPrevented).toBe(false);
|
||||
expect(settingsStore.updateSettings).not.toHaveBeenCalled();
|
||||
backdrop.remove();
|
||||
|
||||
expect(press({ metaKey: true }).defaultPrevented).toBe(true);
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(settingsStore.updateSettings).toHaveBeenCalledTimes(1);
|
||||
expect(component.settingsForm.pristine).toBe(true);
|
||||
});
|
||||
|
||||
it('notices a saved change that waits for a restart until dismissed', async () => {
|
||||
settingsStore.updateSettings.mockResolvedValue(undefined);
|
||||
const notice = () =>
|
||||
(fixture.nativeElement as HTMLElement).querySelector(
|
||||
'[data-test-id="settings-restart-notice"]'
|
||||
);
|
||||
|
||||
component.settingsForm.get('theme')?.setValue(Theme.DarkTheme);
|
||||
component.settingsForm.markAsDirty();
|
||||
component.onSubmit();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
// A live change says nothing about restarting.
|
||||
expect(notice()).toBeNull();
|
||||
|
||||
component.settingsForm
|
||||
.get('startupWindowMode')
|
||||
?.setValue('maximized');
|
||||
component.settingsForm.markAsDirty();
|
||||
component.onSubmit();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.form.restartPendingControls()).toEqual([
|
||||
'startupWindowMode',
|
||||
]);
|
||||
expect(notice()).not.toBeNull();
|
||||
|
||||
// Saving the launch value back withdraws the notice: nothing
|
||||
// waits for a restart any more.
|
||||
component.settingsForm.get('startupWindowMode')?.setValue('normal');
|
||||
component.settingsForm.markAsDirty();
|
||||
component.onSubmit();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.form.restartPendingControls()).toEqual([]);
|
||||
expect(notice()).toBeNull();
|
||||
|
||||
component.settingsForm
|
||||
.get('startupWindowMode')
|
||||
?.setValue('fullscreen');
|
||||
component.settingsForm.markAsDirty();
|
||||
component.onSubmit();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
expect(notice()).not.toBeNull();
|
||||
|
||||
(
|
||||
notice()?.querySelector(
|
||||
'[data-test-id="settings-restart-later"]'
|
||||
) as HTMLButtonElement
|
||||
).click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(notice()).toBeNull();
|
||||
|
||||
// Later holds: an unrelated save does not bring the reminder back.
|
||||
component.settingsForm.get('theme')?.setValue(Theme.LightTheme);
|
||||
component.settingsForm.markAsDirty();
|
||||
component.onSubmit();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
expect(notice()).toBeNull();
|
||||
|
||||
// A different restart value is news again.
|
||||
component.settingsForm
|
||||
.get('startupWindowMode')
|
||||
?.setValue('maximized');
|
||||
component.settingsForm.markAsDirty();
|
||||
component.onSubmit();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
expect(notice()).not.toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the launch values across settings visits', async () => {
|
||||
settingsStore.updateSettings.mockResolvedValue(undefined);
|
||||
settingsStore.updateSettings.mockImplementation((settings) => {
|
||||
settingsStore._setSettings(settings);
|
||||
return Promise.resolve(undefined);
|
||||
});
|
||||
component.settingsForm
|
||||
.get('startupWindowMode')
|
||||
?.setValue('maximized');
|
||||
component.settingsForm.markAsDirty();
|
||||
component.onSubmit();
|
||||
await fixture.whenStable();
|
||||
expect(component.form.restartPendingControls()).toEqual([
|
||||
'startupWindowMode',
|
||||
]);
|
||||
|
||||
// Leave settings and come back: the page and its form are new,
|
||||
// but the app still runs with the launch value.
|
||||
fixture.destroy();
|
||||
fixture = TestBed.createComponent(SettingsComponent);
|
||||
component = fixture.componentInstance;
|
||||
stubSettingsSideEffects(component);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
expect(component.form.restartPendingControls()).toEqual([
|
||||
'startupWindowMode',
|
||||
]);
|
||||
|
||||
// Saving the launch value back withdraws the notice here too.
|
||||
component.settingsForm.get('startupWindowMode')?.setValue('normal');
|
||||
component.settingsForm.markAsDirty();
|
||||
component.onSubmit();
|
||||
await fixture.whenStable();
|
||||
expect(component.form.restartPendingControls()).toEqual([]);
|
||||
});
|
||||
|
||||
it('compares the frame-copy opt-in against the engine the app runs', async () => {
|
||||
settingsStore.updateSettings.mockResolvedValue(undefined);
|
||||
// Stored true, but the engine could not honour it at launch.
|
||||
component.form.setRunningValue('embeddedMpvFrameCopy', false);
|
||||
component.settingsForm.get('embeddedMpvFrameCopy')?.setValue(true);
|
||||
component.settingsForm.markAsDirty();
|
||||
component.onSubmit();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(component.form.restartPendingControls()).toEqual([
|
||||
'embeddedMpvFrameCopy',
|
||||
]);
|
||||
|
||||
// A staged, unsaved edit back to the launch value changes nothing:
|
||||
// the saved setting still needs the restart.
|
||||
component.settingsForm.get('embeddedMpvFrameCopy')?.setValue(false);
|
||||
component.form.setRunningValue('embeddedMpvFrameCopy', false);
|
||||
expect(component.form.restartPendingControls()).toEqual([
|
||||
'embeddedMpvFrameCopy',
|
||||
]);
|
||||
|
||||
// The engine catches up (a later probe reports frame copy): the
|
||||
// notice goes without another save.
|
||||
component.form.setRunningValue('embeddedMpvFrameCopy', true);
|
||||
expect(component.form.restartPendingControls()).toEqual([]);
|
||||
});
|
||||
|
||||
it('discard reverts a staged cover size (regression: eager persist made it stick)', () => {
|
||||
const largeCoverButton = (
|
||||
fixture.nativeElement as HTMLElement
|
||||
|
||||
@@ -12,6 +12,30 @@
|
||||
{{ 'SETTINGS.TITLE' | translate }}
|
||||
</span>
|
||||
|
||||
<!-- A saved change that only applies after a restart: said once,
|
||||
at the top, until dismissed. -->
|
||||
@if (form.restartPendingControls().length > 0) {
|
||||
<div
|
||||
class="settings-restart-bar"
|
||||
role="status"
|
||||
data-test-id="settings-restart-notice"
|
||||
>
|
||||
<mat-icon>restart_alt</mat-icon>
|
||||
<span>{{
|
||||
'SETTINGS.RESTART_NOTICE'
|
||||
| translate: { settings: restartPendingLabels() }
|
||||
}}</span>
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
(click)="form.dismissRestartNotice()"
|
||||
data-test-id="settings-restart-later"
|
||||
>
|
||||
{{ 'SETTINGS.RESTART_LATER' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="settings-content">
|
||||
<!-- Only the routed section renders. The parent keeps the shared
|
||||
form alive across section switches, so edits staged on one
|
||||
@@ -96,21 +120,14 @@
|
||||
/>
|
||||
}
|
||||
@case ('backup') {
|
||||
<!-- Backup & data: the former Reset page is this
|
||||
page's last card, since it is one action. -->
|
||||
<app-settings-backup-section
|
||||
[isPwa]="isPwa"
|
||||
[isRemovingAllPlaylists]="
|
||||
playlistReset.isRemovingAllPlaylists()
|
||||
"
|
||||
[isExportingData]="backup.isExportingData()"
|
||||
(importData)="importData()"
|
||||
(exportData)="exportData()"
|
||||
/>
|
||||
}
|
||||
@case ('reset') {
|
||||
<app-settings-reset-section
|
||||
[isRemovingAllPlaylists]="
|
||||
playlistReset.isRemovingAllPlaylists()
|
||||
"
|
||||
[canRemoveAllPlaylists]="
|
||||
playlistReset.canRemoveAll()
|
||||
"
|
||||
@@ -120,6 +137,8 @@
|
||||
[removeAllProgressLabel]="
|
||||
playlistReset.removeAllProgressLabel()
|
||||
"
|
||||
(importData)="importData()"
|
||||
(exportData)="exportData()"
|
||||
(removeAll)="removeAll()"
|
||||
/>
|
||||
}
|
||||
@@ -142,6 +161,7 @@
|
||||
(openAppUpdateReleaseNotes)="
|
||||
appUpdate.openReleaseNotes()
|
||||
"
|
||||
(openBackup)="openSection('backup')"
|
||||
/>
|
||||
}
|
||||
@default {
|
||||
@@ -168,16 +188,19 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Discord-style unsaved-changes bar: appears the moment any edit
|
||||
makes the form dirty — on whichever section page — and vanishes
|
||||
once saved or discarded. It replaces the always-visible footer
|
||||
Save button of the single-scroll layout. -->
|
||||
<!-- Save bar: appears the moment any edit makes the form dirty — on
|
||||
whichever section page — and vanishes once saved or discarded.
|
||||
It floats over the content column and counts the staged
|
||||
changes; the navigation marks the pages they are on. -->
|
||||
@if (!settingsForm.pristine) {
|
||||
<footer class="unsaved-bar" data-test-id="settings-unsaved-bar">
|
||||
<div class="unsaved-bar__message">
|
||||
<mat-icon>edit</mat-icon>
|
||||
<span>{{ 'SETTINGS.UNSAVED_CHANGES' | translate }}</span>
|
||||
</div>
|
||||
<span class="unsaved-bar__dot" aria-hidden="true"></span>
|
||||
<span
|
||||
class="unsaved-bar__message"
|
||||
data-test-id="settings-unsaved-message"
|
||||
>
|
||||
{{ unsavedMessageKey() | translate: { count: changeCount() } }}
|
||||
</span>
|
||||
<div class="unsaved-bar__actions">
|
||||
<button
|
||||
mat-button
|
||||
@@ -194,8 +217,10 @@
|
||||
[disabled]="!settingsForm.valid"
|
||||
data-test-id="save-settings"
|
||||
>
|
||||
<mat-icon>save</mat-icon>
|
||||
{{ 'SETTINGS.SAVE_CHANGES' | translate }}
|
||||
<kbd class="settings-kbd" aria-hidden="true">{{
|
||||
saveShortcutLabel
|
||||
}}</kbd>
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -151,10 +151,10 @@ describe('SettingsComponent search', () => {
|
||||
it('scrolls to, focuses and briefly highlights a revealed row', () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
settingsSearch.reveal(entry('show-captions'));
|
||||
settingsSearch.reveal(entry('cover-titles'));
|
||||
fixture.detectChanges();
|
||||
|
||||
const row = query('[data-setting-id="show-captions"]') as HTMLElement;
|
||||
const row = query('[data-setting-id="cover-titles"]') as HTMLElement;
|
||||
expect(scrollIntoView).toHaveBeenCalled();
|
||||
expect(scrollIntoView.mock.contexts[0]).toBe(row);
|
||||
expect(row.classList).toContain(SETTINGS_REVEALED_CLASS);
|
||||
|
||||
@@ -239,7 +239,7 @@ describe('SettingsComponent', () => {
|
||||
});
|
||||
|
||||
it('enables the global wipe action only once a playlist exists', () => {
|
||||
setSettingsSection('reset');
|
||||
setSettingsSection('backup');
|
||||
fixture.detectChanges();
|
||||
|
||||
const deleteButton = () =>
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
computed,
|
||||
effect,
|
||||
ElementRef,
|
||||
HostListener,
|
||||
inject,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
@@ -23,12 +24,20 @@ import {
|
||||
RuntimeCapabilitiesService,
|
||||
} from '@iptvnator/services';
|
||||
import { VodSourceDiscoveryService } from '@iptvnator/portal/shared/data-access';
|
||||
import { Language, StreamFormat } from '@iptvnator/shared/interfaces';
|
||||
import { firstValueFrom, map } from 'rxjs';
|
||||
import {
|
||||
ELECTRON_BRIDGE_APP_UPDATE_STATUSES,
|
||||
Language,
|
||||
StreamFormat,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { firstValueFrom, map, startWith } from 'rxjs';
|
||||
import { BUILD_COMMIT } from '../../environments/build-commit';
|
||||
import { SettingsAboutSectionComponent } from './settings-about-section.component';
|
||||
import { SettingsAppUpdateFacade } from './settings-app-update.facade';
|
||||
import { SettingsBackupSectionComponent } from './settings-backup-section.component';
|
||||
import {
|
||||
sectionOfSettingsPath,
|
||||
SETTINGS_RESTART_CONTROLS,
|
||||
} from './settings-change-tracking';
|
||||
import { SettingsDashboardSectionComponent } from './settings-dashboard-section.component';
|
||||
import { SettingsEmbeddedMpvFacade } from './settings-embedded-mpv.facade';
|
||||
import { SettingsEpgFacade } from './settings-epg.facade';
|
||||
@@ -51,7 +60,6 @@ import { SettingsParentalLockSectionComponent } from './settings-parental-sectio
|
||||
import { SettingsPlaybackSectionComponent } from './settings-playback-section.component';
|
||||
import { SettingsRemoteControlFacade } from './settings-remote-control.facade';
|
||||
import { SettingsRemoteControlSectionComponent } from './settings-remote-control-section.component';
|
||||
import { SettingsResetSectionComponent } from './settings-reset-section.component';
|
||||
import { SettingsSearchFacade } from './settings-search.facade';
|
||||
import { SettingsSearchResultsComponent } from './settings-search-results.component';
|
||||
import { SettingsTmdbSectionComponent } from './settings-tmdb-section.component';
|
||||
@@ -98,7 +106,6 @@ export const SETTINGS_DEFAULT_SECTION = 'general';
|
||||
SettingsParentalLockSectionComponent,
|
||||
SettingsPlaybackSectionComponent,
|
||||
SettingsRemoteControlSectionComponent,
|
||||
SettingsResetSectionComponent,
|
||||
SettingsSearchResultsComponent,
|
||||
SettingsTmdbSectionComponent,
|
||||
],
|
||||
@@ -179,6 +186,42 @@ export class SettingsComponent
|
||||
/** Git commit the app was built from (CI builds only) */
|
||||
readonly buildCommit = BUILD_COMMIT;
|
||||
|
||||
/** Staged leaf values that differ from the saved settings */
|
||||
readonly changeCount = computed(() => this.form.changedPaths().length);
|
||||
|
||||
/** Save bar text: a count once something actually differs */
|
||||
readonly unsavedMessageKey = computed(() => {
|
||||
const count = this.changeCount();
|
||||
if (count === 1) return 'SETTINGS.UNSAVED_CHANGES_ONE';
|
||||
return count > 1
|
||||
? 'SETTINGS.UNSAVED_CHANGES_COUNT'
|
||||
: 'SETTINGS.UNSAVED_CHANGES';
|
||||
});
|
||||
|
||||
/** Re-reads translated text after a language switch */
|
||||
private readonly languageTick = toSignal(
|
||||
this.translate.onLangChange.pipe(startWith(null)),
|
||||
{ initialValue: null }
|
||||
);
|
||||
|
||||
/** Rows whose saved change waits for a restart, as one readable list */
|
||||
readonly restartPendingLabels = computed(() => {
|
||||
this.languageTick();
|
||||
return this.form
|
||||
.restartPendingControls()
|
||||
.map((control) =>
|
||||
this.translate.instant(SETTINGS_RESTART_CONTROLS[control])
|
||||
)
|
||||
.join(', ');
|
||||
});
|
||||
|
||||
/** The Save button advertises the platform's save chord */
|
||||
readonly saveShortcutLabel = /mac|iphone|ipad/i.test(
|
||||
navigator.platform || navigator.userAgent
|
||||
)
|
||||
? '⌘S'
|
||||
: 'Ctrl+S';
|
||||
|
||||
readonly themeOptions = SETTINGS_THEME_OPTIONS;
|
||||
readonly coverSizeOptions = SETTINGS_COVER_SIZE_OPTIONS;
|
||||
readonly startupBehaviorOptions = SETTINGS_STARTUP_BEHAVIOR_OPTIONS;
|
||||
@@ -242,6 +285,63 @@ export class SettingsComponent
|
||||
activeSection: this.activeSection,
|
||||
ready: this.formReady.asReadonly(),
|
||||
});
|
||||
|
||||
// The navigation marks pages with staged edits and shows the
|
||||
// installed version (with an update badge) beside About.
|
||||
effect(() => {
|
||||
const sections = new Set<string>();
|
||||
for (const path of this.form.changedPaths()) {
|
||||
const section = sectionOfSettingsPath(path);
|
||||
if (section) sections.add(section);
|
||||
}
|
||||
this.settingsCtx.setDirtySections(sections);
|
||||
});
|
||||
// The frame-copy opt-in may not be what the engine runs (it was not
|
||||
// available at launch): the restart notice compares against that.
|
||||
effect(() => {
|
||||
if (this.embeddedMpv.support()) {
|
||||
this.form.setRunningValue(
|
||||
'embeddedMpvFrameCopy',
|
||||
this.embeddedMpv.frameCopyActive()
|
||||
);
|
||||
}
|
||||
});
|
||||
effect(() => {
|
||||
const status = this.appUpdate.status()?.status;
|
||||
this.settingsCtx.setVersion(this.appUpdate.version() || null);
|
||||
this.settingsCtx.setUpdateAvailable(
|
||||
status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available ||
|
||||
status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cmd/Ctrl+S saves the staged edits. The browser's own save-page dialog
|
||||
* is suppressed on every settings page, also while nothing is staged.
|
||||
* While the page is inert (the phone drawer is open over it) or an
|
||||
* overlay with a backdrop is up (the unsaved-changes dialog, a select
|
||||
* panel), the chord belongs to that surface, not to the obscured form:
|
||||
* saving under the leave dialog would make its Discard keep the edits.
|
||||
*/
|
||||
@HostListener('document:keydown', ['$event'])
|
||||
onSaveShortcut(event: KeyboardEvent): void {
|
||||
const host = this.hostElement.nativeElement;
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
!(event.metaKey || event.ctrlKey) ||
|
||||
event.altKey ||
|
||||
event.shiftKey ||
|
||||
event.key.toLowerCase() !== 's' ||
|
||||
host.closest('[inert]') ||
|
||||
host.ownerDocument.querySelector('.cdk-overlay-backdrop')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
if (!this.settingsForm.pristine && this.settingsForm.valid) {
|
||||
this.onSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -274,6 +374,13 @@ export class SettingsComponent
|
||||
this.settingsCtx.reset();
|
||||
}
|
||||
|
||||
/** Switches section page in place, like the sidebar links do */
|
||||
openSection(sectionId: string): void {
|
||||
void this.router.navigate(['/workspace/settings', sectionId], {
|
||||
replaceUrl: true,
|
||||
});
|
||||
}
|
||||
|
||||
/** Picks a recording folder in the desktop shell and stages it in the form */
|
||||
async selectRecordingFolder(): Promise<void> {
|
||||
const folder = await this.embeddedMpv.selectRecordingFolder();
|
||||
|
||||
@@ -13,6 +13,8 @@ export interface SettingsSection {
|
||||
label: string;
|
||||
icon: string;
|
||||
visible: boolean;
|
||||
/** Navigation group; absent for sections pinned to the nav footer. */
|
||||
group?: string;
|
||||
}
|
||||
|
||||
export interface ThemeOption {
|
||||
|
||||
@@ -270,7 +270,7 @@
|
||||
"SEARCH_RESULTS_COUNT": "الإعدادات المطابقة لـ «{{query}}»: {{count}}",
|
||||
"SEARCH_EMPTY_HINT": "لا توجد إعدادات مطابقة. جرّب كلمة أخرى، مثل المشغل أو السمة أو EPG.",
|
||||
"GENERAL": "عام",
|
||||
"GENERAL_SUBTITLE": "تغيير تكوين التطبيق",
|
||||
"GENERAL_SUBTITLE": "اللغة والمظهر وطريقة بدء التطبيق.",
|
||||
"PLAYBACK": "التشغيل",
|
||||
"EPG_SOURCES": "مصادر EPG",
|
||||
"EPG_SOURCES_DESCRIPTION": "أضف رابطًا واحدًا أو أكثر كمصادر لـ EPG",
|
||||
@@ -290,9 +290,37 @@
|
||||
"NAV_EPG": "EPG",
|
||||
"NAV_REMOTE": "عن بعد",
|
||||
"NAV_DATA": "البيانات",
|
||||
"NAV_BACKUP": "نسخ احتياطي",
|
||||
"NAV_RESET": "إعادة تعيين",
|
||||
"NAV_BACKUP": "النسخ الاحتياطي والبيانات",
|
||||
"NAV_ABOUT": "حول",
|
||||
"NAV_GROUP_APP": "التطبيق",
|
||||
"NAV_GROUP_LIBRARY": "المكتبة",
|
||||
"NAV_GROUP_DEVICES": "الأجهزة",
|
||||
"CARD_APPEARANCE": "المظهر",
|
||||
"CARD_STARTUP": "بدء التشغيل",
|
||||
"CARD_CHANNELS": "القنوات",
|
||||
"CARD_PLAYER": "المشغّل",
|
||||
"CARD_WATCHING": "المشاهدة",
|
||||
"CARD_XTREAM": "Xtream",
|
||||
"CARD_DESKTOP": "سطح المكتب",
|
||||
"CARD_GUIDE": "الدليل",
|
||||
"CARD_SOURCES": "المصادر",
|
||||
"CARD_XTREAM_PLAYLISTS": "قوائم تشغيل Xtream",
|
||||
"CARD_RAILS": "الصفوف",
|
||||
"CARD_CACHE": "ذاكرة التخزين المؤقت",
|
||||
"EPG_SUBTITLE": "عرض دليل البرامج ومصادر XMLTV.",
|
||||
"PLAYBACK_SUBTITLE": "المشغّل والترجمات وتنسيق البث.",
|
||||
"CLEAR_EPG_DATA_DESCRIPTION": "يزيل جميع بيانات البرامج التي تم تنزيلها. تبقى المصادر ويمكن تحديثها.",
|
||||
"EPG_OFFSET_STEP_DOWN": "أبكر بـ {{minutes}} دقيقة",
|
||||
"EPG_OFFSET_STEP_UP": "أبعد بـ {{minutes}} دقيقة",
|
||||
"CHIP_RESTART": "إعادة تشغيل",
|
||||
"CHIP_EXPERIMENTAL": "تجريبي",
|
||||
"UPDATE_UP_TO_DATE": "محدّث",
|
||||
"UPDATE_AVAILABLE_CHIP": "يتوفر تحديث",
|
||||
"UNSAVED_CHANGES_ONE": "تغيير واحد غير محفوظ",
|
||||
"UNSAVED_CHANGES_COUNT": "تغييرات غير محفوظة: {{count}}",
|
||||
"RESTART_NOTICE": "سيتم تطبيق {{settings}} بعد إعادة التشغيل.",
|
||||
"RESTART_LATER": "لاحقاً",
|
||||
"APP_UPDATE_CHANNEL_OPEN_BACKUP": "فتح النسخ الاحتياطي والبيانات",
|
||||
"NAV_TMDB": "البيانات الوصفية",
|
||||
"TMDB": "البيانات الوصفية (TMDB)",
|
||||
"TMDB_DESCRIPTION": "إثراء تفاصيل الأفلام والمسلسلات ببيانات من The Movie Database",
|
||||
|
||||
@@ -270,7 +270,7 @@
|
||||
"SEARCH_RESULTS_COUNT": "الإعدادات اللي كتطابق «{{query}}»: {{count}}",
|
||||
"SEARCH_EMPTY_HINT": "ما كاين حتى إعداد مطابق. جرب كلمة أخرى، بحال المشغل ولا المظهر ولا EPG.",
|
||||
"GENERAL": "عام",
|
||||
"GENERAL_SUBTITLE": "بدل تكوين التطبيق",
|
||||
"GENERAL_SUBTITLE": "اللغة والمظهر وكيفاش كيبدا التطبيق.",
|
||||
"PLAYBACK": "التشغيل",
|
||||
"EPG_SOURCES": "مصادر EPG",
|
||||
"EPG_SOURCES_DESCRIPTION": "زيد رابط ولا أكثر كمصادر EPG",
|
||||
@@ -290,9 +290,37 @@
|
||||
"NAV_EPG": "EPG",
|
||||
"NAV_REMOTE": "التحكم عن بعد",
|
||||
"NAV_DATA": "البيانات",
|
||||
"NAV_BACKUP": "نسخة احتياطية",
|
||||
"NAV_RESET": "إعادة الضبط",
|
||||
"NAV_BACKUP": "النسخة الاحتياطية والمعطيات",
|
||||
"NAV_ABOUT": "حول",
|
||||
"NAV_GROUP_APP": "التطبيق",
|
||||
"NAV_GROUP_LIBRARY": "المكتبة",
|
||||
"NAV_GROUP_DEVICES": "الأجهزة",
|
||||
"CARD_APPEARANCE": "المظهر",
|
||||
"CARD_STARTUP": "بدء التشغيل",
|
||||
"CARD_CHANNELS": "القنوات",
|
||||
"CARD_PLAYER": "المشغّل",
|
||||
"CARD_WATCHING": "المشاهدة",
|
||||
"CARD_XTREAM": "Xtream",
|
||||
"CARD_DESKTOP": "سطح المكتب",
|
||||
"CARD_GUIDE": "الدليل",
|
||||
"CARD_SOURCES": "المصادر",
|
||||
"CARD_XTREAM_PLAYLISTS": "ليستات Xtream",
|
||||
"CARD_RAILS": "الصفوف",
|
||||
"CARD_CACHE": "الكاش",
|
||||
"EPG_SUBTITLE": "عرض دليل البرامج ومصادر XMLTV.",
|
||||
"PLAYBACK_SUBTITLE": "المشغّل والترجمات وتنسيق البث.",
|
||||
"CLEAR_EPG_DATA_DESCRIPTION": "كيمسح جميع معطيات البرامج اللي تحملات. المصادر كتبقى وتقدر تحدثها.",
|
||||
"EPG_OFFSET_STEP_DOWN": "قبل بـ {{minutes}} دقيقة",
|
||||
"EPG_OFFSET_STEP_UP": "من بعد بـ {{minutes}} دقيقة",
|
||||
"CHIP_RESTART": "إعادة تشغيل",
|
||||
"CHIP_EXPERIMENTAL": "تجريبي",
|
||||
"UPDATE_UP_TO_DATE": "محدّث",
|
||||
"UPDATE_AVAILABLE_CHIP": "كاين تحديث",
|
||||
"UNSAVED_CHANGES_ONE": "تغيير واحد ماتسجلش",
|
||||
"UNSAVED_CHANGES_COUNT": "تغييرات ماتسجلاتش: {{count}}",
|
||||
"RESTART_NOTICE": "{{settings}} غادي يتطبق من بعد ما تعاود تشغل التطبيق.",
|
||||
"RESTART_LATER": "من بعد",
|
||||
"APP_UPDATE_CHANNEL_OPEN_BACKUP": "فتح النسخة الاحتياطية والمعطيات",
|
||||
"NAV_TMDB": "الميتاداتا",
|
||||
"TMDB": "الميتاداتا (TMDB)",
|
||||
"TMDB_DESCRIPTION": "غنّي تفاصيل الأفلام والسلاسل بمعطيات من The Movie Database",
|
||||
|
||||
@@ -270,7 +270,7 @@
|
||||
"SEARCH_RESULTS_COUNT": "Налады па запыце «{{query}}»: {{count}}",
|
||||
"SEARCH_EMPTY_HINT": "Нічога не знойдзена. Паспрабуйце іншае слова, напрыклад «прайгравальнік», «тэма» ці «EPG».",
|
||||
"GENERAL": "Агульныя",
|
||||
"GENERAL_SUBTITLE": "Змяніце канфігурацыю праграмы",
|
||||
"GENERAL_SUBTITLE": "Мова, знешні выгляд і запуск праграмы.",
|
||||
"PLAYBACK": "Прайграванне",
|
||||
"EPG_SOURCES": "Крыніцы EPG",
|
||||
"EPG_SOURCES_DESCRIPTION": "Дадайце адзін або некалькі URL у якасці крыніц EPG",
|
||||
@@ -290,9 +290,37 @@
|
||||
"NAV_EPG": "EPG",
|
||||
"NAV_REMOTE": "Кіраванне",
|
||||
"NAV_DATA": "Дадзеныя",
|
||||
"NAV_BACKUP": "Рэзервовая копія",
|
||||
"NAV_RESET": "Скід",
|
||||
"NAV_BACKUP": "Рэзервовая копія і дадзеныя",
|
||||
"NAV_ABOUT": "Аб праграме",
|
||||
"NAV_GROUP_APP": "Праграма",
|
||||
"NAV_GROUP_LIBRARY": "Бібліятэка",
|
||||
"NAV_GROUP_DEVICES": "Прылады",
|
||||
"CARD_APPEARANCE": "Знешні выгляд",
|
||||
"CARD_STARTUP": "Запуск",
|
||||
"CARD_CHANNELS": "Каналы",
|
||||
"CARD_PLAYER": "Плэер",
|
||||
"CARD_WATCHING": "Прагляд",
|
||||
"CARD_XTREAM": "Xtream",
|
||||
"CARD_DESKTOP": "Настольная версія",
|
||||
"CARD_GUIDE": "Тэлегід",
|
||||
"CARD_SOURCES": "Крыніцы",
|
||||
"CARD_XTREAM_PLAYLISTS": "Плэйлісты Xtream",
|
||||
"CARD_RAILS": "Шэрагі",
|
||||
"CARD_CACHE": "Кэш",
|
||||
"EPG_SUBTITLE": "Адлюстраванне тэлегіда і крыніцы XMLTV.",
|
||||
"PLAYBACK_SUBTITLE": "Плэер, субтытры і фармат патоку.",
|
||||
"CLEAR_EPG_DATA_DESCRIPTION": "Выдаляе ўсе спампаваныя даныя тэлепраграмы. Крыніцы застаюцца, іх можна абнавіць.",
|
||||
"EPG_OFFSET_STEP_DOWN": "На {{minutes}} хвілін раней",
|
||||
"EPG_OFFSET_STEP_UP": "На {{minutes}} хвілін пазней",
|
||||
"CHIP_RESTART": "Перазапуск",
|
||||
"CHIP_EXPERIMENTAL": "Эксперыментальна",
|
||||
"UPDATE_UP_TO_DATE": "Актуальная версія",
|
||||
"UPDATE_AVAILABLE_CHIP": "Даступна абнаўленне",
|
||||
"UNSAVED_CHANGES_ONE": "1 незахаваная змена",
|
||||
"UNSAVED_CHANGES_COUNT": "Незахаваныя змены: {{count}}",
|
||||
"RESTART_NOTICE": "{{settings}} уступіць у сілу пасля перазапуску.",
|
||||
"RESTART_LATER": "Пазней",
|
||||
"APP_UPDATE_CHANNEL_OPEN_BACKUP": "Адкрыць «Рэзервовая копія і дадзеныя»",
|
||||
"NAV_TMDB": "Метаданыя",
|
||||
"TMDB": "Метаданыя (TMDB)",
|
||||
"TMDB_DESCRIPTION": "Узбагачайце дэталі фільмаў і серыялаў дадзенымі з The Movie Database",
|
||||
|
||||
@@ -270,7 +270,7 @@
|
||||
"SEARCH_RESULTS_COUNT": "Einstellungen zu „{{query}}“: {{count}}",
|
||||
"SEARCH_EMPTY_HINT": "Keine passenden Einstellungen. Ein anderes Wort versuchen, etwa Player, Design oder EPG.",
|
||||
"GENERAL": "Allgemein",
|
||||
"GENERAL_SUBTITLE": "Allgemeine App-Einstellungen ändern",
|
||||
"GENERAL_SUBTITLE": "Sprache, Darstellung und Verhalten beim Start.",
|
||||
"PLAYBACK": "Wiedergabe",
|
||||
"EPG_SOURCES": "EPG-Quellen",
|
||||
"EPG_SOURCES_DESCRIPTION": "Eine oder mehrere XMLTV-Quellen hinzufügen: Links oder lokale Dateien",
|
||||
@@ -290,9 +290,37 @@
|
||||
"NAV_EPG": "EPG",
|
||||
"NAV_REMOTE": "Remote",
|
||||
"NAV_DATA": "Daten",
|
||||
"NAV_BACKUP": "Datensicherung",
|
||||
"NAV_RESET": "Zurücksetzen",
|
||||
"NAV_BACKUP": "Datensicherung & Daten",
|
||||
"NAV_ABOUT": "Info",
|
||||
"NAV_GROUP_APP": "App",
|
||||
"NAV_GROUP_LIBRARY": "Bibliothek",
|
||||
"NAV_GROUP_DEVICES": "Geräte",
|
||||
"CARD_APPEARANCE": "Darstellung",
|
||||
"CARD_STARTUP": "Start",
|
||||
"CARD_CHANNELS": "Sender",
|
||||
"CARD_PLAYER": "Player",
|
||||
"CARD_WATCHING": "Ansehen",
|
||||
"CARD_XTREAM": "Xtream",
|
||||
"CARD_DESKTOP": "Desktop",
|
||||
"CARD_GUIDE": "Programmführer",
|
||||
"CARD_SOURCES": "Quellen",
|
||||
"CARD_XTREAM_PLAYLISTS": "Xtream-Playlists",
|
||||
"CARD_RAILS": "Reihen",
|
||||
"CARD_CACHE": "Cache",
|
||||
"EPG_SUBTITLE": "Darstellung des Programmführers und XMLTV-Quellen.",
|
||||
"PLAYBACK_SUBTITLE": "Player, Untertitel und Stream-Format.",
|
||||
"CLEAR_EPG_DATA_DESCRIPTION": "Entfernt alle heruntergeladenen Programmdaten. Die Quellen bleiben erhalten und können aktualisiert werden.",
|
||||
"EPG_OFFSET_STEP_DOWN": "{{minutes}} Minuten früher",
|
||||
"EPG_OFFSET_STEP_UP": "{{minutes}} Minuten später",
|
||||
"CHIP_RESTART": "Neustart",
|
||||
"CHIP_EXPERIMENTAL": "Experimentell",
|
||||
"UPDATE_UP_TO_DATE": "Aktuell",
|
||||
"UPDATE_AVAILABLE_CHIP": "Update verfügbar",
|
||||
"UNSAVED_CHANGES_ONE": "1 ungespeicherte Änderung",
|
||||
"UNSAVED_CHANGES_COUNT": "{{count}} ungespeicherte Änderungen",
|
||||
"RESTART_NOTICE": "{{settings}} wird nach einem Neustart übernommen.",
|
||||
"RESTART_LATER": "Später",
|
||||
"APP_UPDATE_CHANNEL_OPEN_BACKUP": "Datensicherung & Daten öffnen",
|
||||
"NAV_TMDB": "Metadaten",
|
||||
"TMDB": "Metadaten (TMDB)",
|
||||
"TMDB_DESCRIPTION": "Film- und Seriendetails mit Daten von The Movie Database anreichern",
|
||||
|
||||
@@ -270,7 +270,7 @@
|
||||
"SEARCH_RESULTS_COUNT": "Ρυθμίσεις για «{{query}}»: {{count}}",
|
||||
"SEARCH_EMPTY_HINT": "Δεν βρέθηκαν ρυθμίσεις. Δοκιμάστε άλλη λέξη, π.χ. αναπαραγωγή, θέμα ή EPG.",
|
||||
"GENERAL": "Γενικά",
|
||||
"GENERAL_SUBTITLE": "Αλλαγή της διαμόρφωσης της εφαρμογής",
|
||||
"GENERAL_SUBTITLE": "Γλώσσα, εμφάνιση και εκκίνηση της εφαρμογής.",
|
||||
"PLAYBACK": "Αναπαραγωγή",
|
||||
"EPG_SOURCES": "Πηγές EPG",
|
||||
"EPG_SOURCES_DESCRIPTION": "Προσθέστε μία ή περισσότερες διευθύνσεις URL ως πηγές EPG",
|
||||
@@ -290,9 +290,37 @@
|
||||
"NAV_EPG": "EPG",
|
||||
"NAV_REMOTE": "Τηλεχειριστήριο",
|
||||
"NAV_DATA": "Δεδομένα",
|
||||
"NAV_BACKUP": "Αντίγραφο ασφαλείας",
|
||||
"NAV_RESET": "Επαναφορά",
|
||||
"NAV_BACKUP": "Αντίγραφο ασφαλείας και δεδομένα",
|
||||
"NAV_ABOUT": "Πληροφορίες",
|
||||
"NAV_GROUP_APP": "Εφαρμογή",
|
||||
"NAV_GROUP_LIBRARY": "Βιβλιοθήκη",
|
||||
"NAV_GROUP_DEVICES": "Συσκευές",
|
||||
"CARD_APPEARANCE": "Εμφάνιση",
|
||||
"CARD_STARTUP": "Εκκίνηση",
|
||||
"CARD_CHANNELS": "Κανάλια",
|
||||
"CARD_PLAYER": "Πρόγραμμα αναπαραγωγής",
|
||||
"CARD_WATCHING": "Παρακολούθηση",
|
||||
"CARD_XTREAM": "Xtream",
|
||||
"CARD_DESKTOP": "Επιφάνεια εργασίας",
|
||||
"CARD_GUIDE": "Οδηγός",
|
||||
"CARD_SOURCES": "Πηγές",
|
||||
"CARD_XTREAM_PLAYLISTS": "Λίστες Xtream",
|
||||
"CARD_RAILS": "Σειρές",
|
||||
"CARD_CACHE": "Προσωρινή μνήμη",
|
||||
"EPG_SUBTITLE": "Εμφάνιση οδηγού προγράμματος και πηγές XMLTV.",
|
||||
"PLAYBACK_SUBTITLE": "Αναπαραγωγή, υπότιτλοι και μορφή ροής.",
|
||||
"CLEAR_EPG_DATA_DESCRIPTION": "Αφαιρεί όλα τα ληφθέντα δεδομένα προγράμματος. Οι πηγές παραμένουν και μπορούν να ανανεωθούν.",
|
||||
"EPG_OFFSET_STEP_DOWN": "{{minutes}} λεπτά νωρίτερα",
|
||||
"EPG_OFFSET_STEP_UP": "{{minutes}} λεπτά αργότερα",
|
||||
"CHIP_RESTART": "Επανεκκίνηση",
|
||||
"CHIP_EXPERIMENTAL": "Πειραματικό",
|
||||
"UPDATE_UP_TO_DATE": "Ενημερωμένο",
|
||||
"UPDATE_AVAILABLE_CHIP": "Διαθέσιμη ενημέρωση",
|
||||
"UNSAVED_CHANGES_ONE": "1 μη αποθηκευμένη αλλαγή",
|
||||
"UNSAVED_CHANGES_COUNT": "Μη αποθηκευμένες αλλαγές: {{count}}",
|
||||
"RESTART_NOTICE": "{{settings}} θα εφαρμοστεί μετά την επανεκκίνηση.",
|
||||
"RESTART_LATER": "Αργότερα",
|
||||
"APP_UPDATE_CHANNEL_OPEN_BACKUP": "Άνοιγμα Αντίγραφο ασφαλείας και δεδομένα",
|
||||
"NAV_TMDB": "Μεταδεδομένα",
|
||||
"TMDB": "Μεταδεδομένα (TMDB)",
|
||||
"TMDB_DESCRIPTION": "Εμπλουτίστε τις λεπτομέρειες ταινιών και σειρών με δεδομένα από το The Movie Database",
|
||||
|
||||
@@ -270,7 +270,7 @@
|
||||
"SEARCH_RESULTS_COUNT": "Settings matching “{{query}}”: {{count}}",
|
||||
"SEARCH_EMPTY_HINT": "No settings match. Try another word, such as player, theme or EPG.",
|
||||
"GENERAL": "General",
|
||||
"GENERAL_SUBTITLE": "Change the configuration of the application",
|
||||
"GENERAL_SUBTITLE": "Language, appearance and how the app starts.",
|
||||
"PLAYBACK": "Playback",
|
||||
"EPG_SOURCES": "EPG sources",
|
||||
"EPG_SOURCES_DESCRIPTION": "Add one or more XMLTV sources: remote links or local files",
|
||||
@@ -290,9 +290,37 @@
|
||||
"NAV_EPG": "EPG",
|
||||
"NAV_REMOTE": "Remote",
|
||||
"NAV_DATA": "Data",
|
||||
"NAV_BACKUP": "Backup",
|
||||
"NAV_RESET": "Reset",
|
||||
"NAV_BACKUP": "Backup & data",
|
||||
"NAV_ABOUT": "About",
|
||||
"NAV_GROUP_APP": "App",
|
||||
"NAV_GROUP_LIBRARY": "Library",
|
||||
"NAV_GROUP_DEVICES": "Devices",
|
||||
"CARD_APPEARANCE": "Appearance",
|
||||
"CARD_STARTUP": "Startup",
|
||||
"CARD_CHANNELS": "Channels",
|
||||
"CARD_PLAYER": "Player",
|
||||
"CARD_WATCHING": "Watching",
|
||||
"CARD_XTREAM": "Xtream",
|
||||
"CARD_DESKTOP": "Desktop",
|
||||
"CARD_GUIDE": "Guide",
|
||||
"CARD_SOURCES": "Sources",
|
||||
"CARD_XTREAM_PLAYLISTS": "Xtream playlists",
|
||||
"CARD_RAILS": "Rails",
|
||||
"CARD_CACHE": "Cache",
|
||||
"EPG_SUBTITLE": "Programme guide display and XMLTV sources.",
|
||||
"PLAYBACK_SUBTITLE": "Player, subtitles and stream format.",
|
||||
"CLEAR_EPG_DATA_DESCRIPTION": "Removes all downloaded programme data. Sources stay and can be refreshed.",
|
||||
"EPG_OFFSET_STEP_DOWN": "Earlier by {{minutes}} minutes",
|
||||
"EPG_OFFSET_STEP_UP": "Later by {{minutes}} minutes",
|
||||
"CHIP_RESTART": "Restart",
|
||||
"CHIP_EXPERIMENTAL": "Experimental",
|
||||
"UPDATE_UP_TO_DATE": "Up to date",
|
||||
"UPDATE_AVAILABLE_CHIP": "Update available",
|
||||
"UNSAVED_CHANGES_ONE": "1 unsaved change",
|
||||
"UNSAVED_CHANGES_COUNT": "{{count}} unsaved changes",
|
||||
"RESTART_NOTICE": "{{settings}} will apply after restart.",
|
||||
"RESTART_LATER": "Later",
|
||||
"APP_UPDATE_CHANNEL_OPEN_BACKUP": "Open Backup & data",
|
||||
"NAV_TMDB": "Metadata",
|
||||
"TMDB": "Metadata (TMDB)",
|
||||
"TMDB_DESCRIPTION": "Enrich movie and series details with data from The Movie Database",
|
||||
|
||||
@@ -270,7 +270,7 @@
|
||||
"SEARCH_RESULTS_COUNT": "Ajustes que coinciden con «{{query}}»: {{count}}",
|
||||
"SEARCH_EMPTY_HINT": "Ningún ajuste coincide. Prueba con otra palabra, como reproductor, tema o EPG.",
|
||||
"GENERAL": "General",
|
||||
"GENERAL_SUBTITLE": "Cambia la configuracion general de la aplicacion",
|
||||
"GENERAL_SUBTITLE": "Idioma, apariencia y cómo se inicia la aplicación.",
|
||||
"PLAYBACK": "Reproduccion",
|
||||
"EPG_SOURCES": "Fuentes EPG",
|
||||
"EPG_SOURCES_DESCRIPTION": "Agrega una o varias URL como fuentes EPG",
|
||||
@@ -290,9 +290,37 @@
|
||||
"NAV_EPG": "EPG",
|
||||
"NAV_REMOTE": "Remoto",
|
||||
"NAV_DATA": "Datos",
|
||||
"NAV_BACKUP": "Copia de seguridad",
|
||||
"NAV_RESET": "Restablecer",
|
||||
"NAV_BACKUP": "Copia de seguridad y datos",
|
||||
"NAV_ABOUT": "Acerca de",
|
||||
"NAV_GROUP_APP": "Aplicación",
|
||||
"NAV_GROUP_LIBRARY": "Biblioteca",
|
||||
"NAV_GROUP_DEVICES": "Dispositivos",
|
||||
"CARD_APPEARANCE": "Apariencia",
|
||||
"CARD_STARTUP": "Inicio",
|
||||
"CARD_CHANNELS": "Canales",
|
||||
"CARD_PLAYER": "Reproductor",
|
||||
"CARD_WATCHING": "Visualización",
|
||||
"CARD_XTREAM": "Xtream",
|
||||
"CARD_DESKTOP": "Escritorio",
|
||||
"CARD_GUIDE": "Guía",
|
||||
"CARD_SOURCES": "Fuentes",
|
||||
"CARD_XTREAM_PLAYLISTS": "Listas Xtream",
|
||||
"CARD_RAILS": "Filas",
|
||||
"CARD_CACHE": "Caché",
|
||||
"EPG_SUBTITLE": "Visualización de la guía de programación y fuentes XMLTV.",
|
||||
"PLAYBACK_SUBTITLE": "Reproductor, subtítulos y formato de transmisión.",
|
||||
"CLEAR_EPG_DATA_DESCRIPTION": "Elimina todos los datos de programación descargados. Las fuentes se conservan y pueden actualizarse.",
|
||||
"EPG_OFFSET_STEP_DOWN": "{{minutes}} minutos antes",
|
||||
"EPG_OFFSET_STEP_UP": "{{minutes}} minutos después",
|
||||
"CHIP_RESTART": "Reinicio",
|
||||
"CHIP_EXPERIMENTAL": "Experimental",
|
||||
"UPDATE_UP_TO_DATE": "Actualizado",
|
||||
"UPDATE_AVAILABLE_CHIP": "Actualización disponible",
|
||||
"UNSAVED_CHANGES_ONE": "1 cambio sin guardar",
|
||||
"UNSAVED_CHANGES_COUNT": "{{count}} cambios sin guardar",
|
||||
"RESTART_NOTICE": "{{settings}} se aplicará tras reiniciar.",
|
||||
"RESTART_LATER": "Más tarde",
|
||||
"APP_UPDATE_CHANNEL_OPEN_BACKUP": "Abrir Copia de seguridad y datos",
|
||||
"NAV_TMDB": "Metadatos",
|
||||
"TMDB": "Metadatos (TMDB)",
|
||||
"TMDB_DESCRIPTION": "Enriquece los detalles de películas y series con datos de The Movie Database",
|
||||
|
||||
@@ -270,7 +270,7 @@
|
||||
"SEARCH_RESULTS_COUNT": "Paramètres correspondant à « {{query}} » : {{count}}",
|
||||
"SEARCH_EMPTY_HINT": "Aucun paramètre ne correspond. Essayez un autre mot, par exemple lecteur, thème ou EPG.",
|
||||
"GENERAL": "Général",
|
||||
"GENERAL_SUBTITLE": "Modifier la configuration de l'application",
|
||||
"GENERAL_SUBTITLE": "Langue, apparence et démarrage de l'application.",
|
||||
"PLAYBACK": "Lecture",
|
||||
"EPG_SOURCES": "Sources EPG",
|
||||
"EPG_SOURCES_DESCRIPTION": "Ajoutez une ou plusieurs URL comme sources EPG",
|
||||
@@ -290,9 +290,37 @@
|
||||
"NAV_EPG": "EPG",
|
||||
"NAV_REMOTE": "Télécommande",
|
||||
"NAV_DATA": "Données",
|
||||
"NAV_BACKUP": "Sauvegarde",
|
||||
"NAV_RESET": "Réinitialiser",
|
||||
"NAV_BACKUP": "Sauvegarde et données",
|
||||
"NAV_ABOUT": "À propos",
|
||||
"NAV_GROUP_APP": "Application",
|
||||
"NAV_GROUP_LIBRARY": "Bibliothèque",
|
||||
"NAV_GROUP_DEVICES": "Appareils",
|
||||
"CARD_APPEARANCE": "Apparence",
|
||||
"CARD_STARTUP": "Démarrage",
|
||||
"CARD_CHANNELS": "Chaînes",
|
||||
"CARD_PLAYER": "Lecteur",
|
||||
"CARD_WATCHING": "Visionnage",
|
||||
"CARD_XTREAM": "Xtream",
|
||||
"CARD_DESKTOP": "Bureau",
|
||||
"CARD_GUIDE": "Guide",
|
||||
"CARD_SOURCES": "Sources",
|
||||
"CARD_XTREAM_PLAYLISTS": "Listes Xtream",
|
||||
"CARD_RAILS": "Rangées",
|
||||
"CARD_CACHE": "Cache",
|
||||
"EPG_SUBTITLE": "Affichage du guide des programmes et sources XMLTV.",
|
||||
"PLAYBACK_SUBTITLE": "Lecteur, sous-titres et format de flux.",
|
||||
"CLEAR_EPG_DATA_DESCRIPTION": "Supprime toutes les données de programmes téléchargées. Les sources restent et peuvent être actualisées.",
|
||||
"EPG_OFFSET_STEP_DOWN": "{{minutes}} minutes plus tôt",
|
||||
"EPG_OFFSET_STEP_UP": "{{minutes}} minutes plus tard",
|
||||
"CHIP_RESTART": "Redémarrage",
|
||||
"CHIP_EXPERIMENTAL": "Expérimental",
|
||||
"UPDATE_UP_TO_DATE": "À jour",
|
||||
"UPDATE_AVAILABLE_CHIP": "Mise à jour disponible",
|
||||
"UNSAVED_CHANGES_ONE": "1 modification non enregistrée",
|
||||
"UNSAVED_CHANGES_COUNT": "{{count}} modifications non enregistrées",
|
||||
"RESTART_NOTICE": "{{settings}} s'appliquera après un redémarrage.",
|
||||
"RESTART_LATER": "Plus tard",
|
||||
"APP_UPDATE_CHANNEL_OPEN_BACKUP": "Ouvrir Sauvegarde et données",
|
||||
"NAV_TMDB": "Métadonnées",
|
||||
"TMDB": "Métadonnées (TMDB)",
|
||||
"TMDB_DESCRIPTION": "Enrichir les détails des films et séries avec les données de The Movie Database",
|
||||
|
||||
@@ -270,7 +270,7 @@
|
||||
"SEARCH_RESULTS_COUNT": "„{{query}}” kifejezésre illeszkedő beállítások: {{count}}",
|
||||
"SEARCH_EMPTY_HINT": "Nincs illeszkedő beállítás. Próbáljon másik szót, például lejátszó, téma vagy EPG.",
|
||||
"GENERAL": "Általános",
|
||||
"GENERAL_SUBTITLE": "Az alkalmazás beállításainak módosítása",
|
||||
"GENERAL_SUBTITLE": "Nyelv, megjelenés és az alkalmazás indítása.",
|
||||
"PLAYBACK": "Lejátszás",
|
||||
"EPG_SOURCES": "EPG-források",
|
||||
"EPG_SOURCES_DESCRIPTION": "Egy vagy több URL-cím hozzáadása EPG-forrásként",
|
||||
@@ -290,9 +290,37 @@
|
||||
"NAV_EPG": "EPG",
|
||||
"NAV_REMOTE": "Távvezérlés",
|
||||
"NAV_DATA": "Adatok",
|
||||
"NAV_BACKUP": "Biztonsági mentés",
|
||||
"NAV_RESET": "Alaphelyzetbe állítás",
|
||||
"NAV_BACKUP": "Biztonsági mentés és adatok",
|
||||
"NAV_ABOUT": "Névjegy",
|
||||
"NAV_GROUP_APP": "Alkalmazás",
|
||||
"NAV_GROUP_LIBRARY": "Könyvtár",
|
||||
"NAV_GROUP_DEVICES": "Eszközök",
|
||||
"CARD_APPEARANCE": "Megjelenés",
|
||||
"CARD_STARTUP": "Indítás",
|
||||
"CARD_CHANNELS": "Csatornák",
|
||||
"CARD_PLAYER": "Lejátszó",
|
||||
"CARD_WATCHING": "Megtekintés",
|
||||
"CARD_XTREAM": "Xtream",
|
||||
"CARD_DESKTOP": "Asztali",
|
||||
"CARD_GUIDE": "Műsorújság",
|
||||
"CARD_SOURCES": "Források",
|
||||
"CARD_XTREAM_PLAYLISTS": "Xtream lejátszási listák",
|
||||
"CARD_RAILS": "Sorok",
|
||||
"CARD_CACHE": "Gyorsítótár",
|
||||
"EPG_SUBTITLE": "Műsorújság megjelenítése és XMLTV-források.",
|
||||
"PLAYBACK_SUBTITLE": "Lejátszó, feliratok és streamformátum.",
|
||||
"CLEAR_EPG_DATA_DESCRIPTION": "Eltávolítja az összes letöltött műsoradatot. A források megmaradnak, és frissíthetők.",
|
||||
"EPG_OFFSET_STEP_DOWN": "{{minutes}} perccel korábban",
|
||||
"EPG_OFFSET_STEP_UP": "{{minutes}} perccel később",
|
||||
"CHIP_RESTART": "Újraindítás",
|
||||
"CHIP_EXPERIMENTAL": "Kísérleti",
|
||||
"UPDATE_UP_TO_DATE": "Naprakész",
|
||||
"UPDATE_AVAILABLE_CHIP": "Frissítés érhető el",
|
||||
"UNSAVED_CHANGES_ONE": "1 nem mentett módosítás",
|
||||
"UNSAVED_CHANGES_COUNT": "{{count}} nem mentett módosítás",
|
||||
"RESTART_NOTICE": "{{settings}} újraindítás után lép érvénybe.",
|
||||
"RESTART_LATER": "Később",
|
||||
"APP_UPDATE_CHANNEL_OPEN_BACKUP": "Biztonsági mentés és adatok megnyitása",
|
||||
"NAV_TMDB": "Metaadatok",
|
||||
"TMDB": "Metaadatok (TMDB)",
|
||||
"TMDB_DESCRIPTION": "Filmek és sorozatok adatainak kiegészítése a The Movie Database adataival",
|
||||
|
||||
@@ -270,7 +270,7 @@
|
||||
"SEARCH_RESULTS_COUNT": "Impostazioni corrispondenti a “{{query}}”: {{count}}",
|
||||
"SEARCH_EMPTY_HINT": "Nessuna impostazione corrisponde. Prova un'altra parola, ad esempio riproduttore, tema o EPG.",
|
||||
"GENERAL": "Generale",
|
||||
"GENERAL_SUBTITLE": "Modifica le impostazioni generali dell'applicazione",
|
||||
"GENERAL_SUBTITLE": "Lingua, aspetto e avvio dell'app.",
|
||||
"PLAYBACK": "Riproduzione",
|
||||
"EPG_SOURCES": "Sorgenti EPG",
|
||||
"EPG_SOURCES_DESCRIPTION": "Aggiungi uno o piu URL come sorgenti EPG",
|
||||
@@ -290,9 +290,37 @@
|
||||
"NAV_EPG": "EPG",
|
||||
"NAV_REMOTE": "Remote",
|
||||
"NAV_DATA": "Dati",
|
||||
"NAV_BACKUP": "Copia di sicurezza",
|
||||
"NAV_RESET": "Reimpostazione",
|
||||
"NAV_BACKUP": "Copia di sicurezza e dati",
|
||||
"NAV_ABOUT": "Info",
|
||||
"NAV_GROUP_APP": "App",
|
||||
"NAV_GROUP_LIBRARY": "Libreria",
|
||||
"NAV_GROUP_DEVICES": "Dispositivi",
|
||||
"CARD_APPEARANCE": "Aspetto",
|
||||
"CARD_STARTUP": "Avvio",
|
||||
"CARD_CHANNELS": "Canali",
|
||||
"CARD_PLAYER": "Lettore",
|
||||
"CARD_WATCHING": "Visione",
|
||||
"CARD_XTREAM": "Xtream",
|
||||
"CARD_DESKTOP": "Desktop",
|
||||
"CARD_GUIDE": "Guida",
|
||||
"CARD_SOURCES": "Fonti",
|
||||
"CARD_XTREAM_PLAYLISTS": "Playlist Xtream",
|
||||
"CARD_RAILS": "Righe",
|
||||
"CARD_CACHE": "Cache",
|
||||
"EPG_SUBTITLE": "Visualizzazione della guida programmi e fonti XMLTV.",
|
||||
"PLAYBACK_SUBTITLE": "Lettore, sottotitoli e formato dello stream.",
|
||||
"CLEAR_EPG_DATA_DESCRIPTION": "Rimuove tutti i dati dei programmi scaricati. Le fonti restano e possono essere aggiornate.",
|
||||
"EPG_OFFSET_STEP_DOWN": "{{minutes}} minuti prima",
|
||||
"EPG_OFFSET_STEP_UP": "{{minutes}} minuti dopo",
|
||||
"CHIP_RESTART": "Riavvio",
|
||||
"CHIP_EXPERIMENTAL": "Sperimentale",
|
||||
"UPDATE_UP_TO_DATE": "Aggiornato",
|
||||
"UPDATE_AVAILABLE_CHIP": "Aggiornamento disponibile",
|
||||
"UNSAVED_CHANGES_ONE": "1 modifica non salvata",
|
||||
"UNSAVED_CHANGES_COUNT": "{{count}} modifiche non salvate",
|
||||
"RESTART_NOTICE": "{{settings}} verrà applicato dopo il riavvio.",
|
||||
"RESTART_LATER": "Più tardi",
|
||||
"APP_UPDATE_CHANNEL_OPEN_BACKUP": "Apri Copia di sicurezza e dati",
|
||||
"NAV_TMDB": "Metadati",
|
||||
"TMDB": "Metadati (TMDB)",
|
||||
"TMDB_DESCRIPTION": "Arricchisci i dettagli di film e serie con dati da The Movie Database",
|
||||
|
||||
@@ -270,7 +270,7 @@
|
||||
"SEARCH_RESULTS_COUNT": "「{{query}}」に一致する設定: {{count}}",
|
||||
"SEARCH_EMPTY_HINT": "一致する設定がありません。「プレーヤー」「テーマ」「EPG」など別の言葉を試してください。",
|
||||
"GENERAL": "一般",
|
||||
"GENERAL_SUBTITLE": "アプリケーションの設定を変更",
|
||||
"GENERAL_SUBTITLE": "言語、外観、アプリの起動方法。",
|
||||
"PLAYBACK": "再生",
|
||||
"EPG_SOURCES": "EPGソース",
|
||||
"EPG_SOURCES_DESCRIPTION": "EPGソースとして1つ以上のURLを追加",
|
||||
@@ -290,9 +290,37 @@
|
||||
"NAV_EPG": "EPG",
|
||||
"NAV_REMOTE": "リモート",
|
||||
"NAV_DATA": "データ",
|
||||
"NAV_BACKUP": "バックアップ",
|
||||
"NAV_RESET": "リセット",
|
||||
"NAV_BACKUP": "バックアップとデータ",
|
||||
"NAV_ABOUT": "アプリについて",
|
||||
"NAV_GROUP_APP": "アプリ",
|
||||
"NAV_GROUP_LIBRARY": "ライブラリ",
|
||||
"NAV_GROUP_DEVICES": "デバイス",
|
||||
"CARD_APPEARANCE": "外観",
|
||||
"CARD_STARTUP": "起動",
|
||||
"CARD_CHANNELS": "チャンネル",
|
||||
"CARD_PLAYER": "プレーヤー",
|
||||
"CARD_WATCHING": "視聴",
|
||||
"CARD_XTREAM": "Xtream",
|
||||
"CARD_DESKTOP": "デスクトップ",
|
||||
"CARD_GUIDE": "番組表",
|
||||
"CARD_SOURCES": "ソース",
|
||||
"CARD_XTREAM_PLAYLISTS": "Xtream プレイリスト",
|
||||
"CARD_RAILS": "レール",
|
||||
"CARD_CACHE": "キャッシュ",
|
||||
"EPG_SUBTITLE": "番組表の表示と XMLTV ソース。",
|
||||
"PLAYBACK_SUBTITLE": "プレーヤー、字幕、ストリーム形式。",
|
||||
"CLEAR_EPG_DATA_DESCRIPTION": "ダウンロード済みの番組データをすべて削除します。ソースは残り、再取得できます。",
|
||||
"EPG_OFFSET_STEP_DOWN": "{{minutes}} 分早める",
|
||||
"EPG_OFFSET_STEP_UP": "{{minutes}} 分遅らせる",
|
||||
"CHIP_RESTART": "再起動",
|
||||
"CHIP_EXPERIMENTAL": "試験的",
|
||||
"UPDATE_UP_TO_DATE": "最新",
|
||||
"UPDATE_AVAILABLE_CHIP": "アップデートあり",
|
||||
"UNSAVED_CHANGES_ONE": "未保存の変更: 1件",
|
||||
"UNSAVED_CHANGES_COUNT": "未保存の変更: {{count}}件",
|
||||
"RESTART_NOTICE": "{{settings}} は再起動後に適用されます。",
|
||||
"RESTART_LATER": "後で",
|
||||
"APP_UPDATE_CHANNEL_OPEN_BACKUP": "バックアップとデータを開く",
|
||||
"NAV_TMDB": "メタデータ",
|
||||
"TMDB": "メタデータ(TMDB)",
|
||||
"TMDB_DESCRIPTION": "The Movie Database のデータで映画やシリーズの詳細情報を充実させます",
|
||||
|
||||
@@ -270,7 +270,7 @@
|
||||
"SEARCH_RESULTS_COUNT": "“{{query}}”와 일치하는 설정: {{count}}",
|
||||
"SEARCH_EMPTY_HINT": "일치하는 설정이 없습니다. 플레이어, 테마, EPG 같은 다른 단어로 검색해 보세요.",
|
||||
"GENERAL": "일반",
|
||||
"GENERAL_SUBTITLE": "애플리케이션 구성을 변경합니다",
|
||||
"GENERAL_SUBTITLE": "언어, 모양 및 앱 시작 방식.",
|
||||
"PLAYBACK": "재생",
|
||||
"EPG_SOURCES": "EPG 소스",
|
||||
"EPG_SOURCES_DESCRIPTION": "하나 이상의 URL을 EPG 소스로 추가합니다",
|
||||
@@ -290,9 +290,37 @@
|
||||
"NAV_EPG": "EPG",
|
||||
"NAV_REMOTE": "원격",
|
||||
"NAV_DATA": "데이터",
|
||||
"NAV_BACKUP": "백업",
|
||||
"NAV_RESET": "초기화",
|
||||
"NAV_BACKUP": "백업 및 데이터",
|
||||
"NAV_ABOUT": "정보",
|
||||
"NAV_GROUP_APP": "앱",
|
||||
"NAV_GROUP_LIBRARY": "라이브러리",
|
||||
"NAV_GROUP_DEVICES": "기기",
|
||||
"CARD_APPEARANCE": "모양",
|
||||
"CARD_STARTUP": "시작",
|
||||
"CARD_CHANNELS": "채널",
|
||||
"CARD_PLAYER": "플레이어",
|
||||
"CARD_WATCHING": "시청",
|
||||
"CARD_XTREAM": "Xtream",
|
||||
"CARD_DESKTOP": "데스크톱",
|
||||
"CARD_GUIDE": "편성표",
|
||||
"CARD_SOURCES": "소스",
|
||||
"CARD_XTREAM_PLAYLISTS": "Xtream 재생목록",
|
||||
"CARD_RAILS": "레일",
|
||||
"CARD_CACHE": "캐시",
|
||||
"EPG_SUBTITLE": "편성표 표시 및 XMLTV 소스.",
|
||||
"PLAYBACK_SUBTITLE": "플레이어, 자막 및 스트림 형식.",
|
||||
"CLEAR_EPG_DATA_DESCRIPTION": "다운로드한 편성 데이터를 모두 삭제합니다. 소스는 유지되며 다시 불러올 수 있습니다.",
|
||||
"EPG_OFFSET_STEP_DOWN": "{{minutes}}분 앞당기기",
|
||||
"EPG_OFFSET_STEP_UP": "{{minutes}}분 늦추기",
|
||||
"CHIP_RESTART": "다시 시작",
|
||||
"CHIP_EXPERIMENTAL": "실험적",
|
||||
"UPDATE_UP_TO_DATE": "최신 상태",
|
||||
"UPDATE_AVAILABLE_CHIP": "업데이트 가능",
|
||||
"UNSAVED_CHANGES_ONE": "저장되지 않은 변경 사항 1개",
|
||||
"UNSAVED_CHANGES_COUNT": "저장되지 않은 변경 사항 {{count}}개",
|
||||
"RESTART_NOTICE": "{{settings}} 설정은 다시 시작한 후 적용됩니다.",
|
||||
"RESTART_LATER": "나중에",
|
||||
"APP_UPDATE_CHANNEL_OPEN_BACKUP": "백업 및 데이터 열기",
|
||||
"NAV_TMDB": "메타데이터",
|
||||
"TMDB": "메타데이터 (TMDB)",
|
||||
"TMDB_DESCRIPTION": "The Movie Database의 데이터로 영화 및 시리즈 상세 정보를 보강합니다",
|
||||
|
||||
@@ -270,7 +270,7 @@
|
||||
"SEARCH_RESULTS_COUNT": "Instellingen voor “{{query}}”: {{count}}",
|
||||
"SEARCH_EMPTY_HINT": "Geen instellingen gevonden. Probeer een ander woord, zoals speler, thema of EPG.",
|
||||
"GENERAL": "Algemeen",
|
||||
"GENERAL_SUBTITLE": "Wijzig de configuratie van de applicatie",
|
||||
"GENERAL_SUBTITLE": "Taal, weergave en hoe de app start.",
|
||||
"PLAYBACK": "Afspelen",
|
||||
"EPG_SOURCES": "EPG-bronnen",
|
||||
"EPG_SOURCES_DESCRIPTION": "Voeg een of meer URL's toe als EPG-bron",
|
||||
@@ -290,9 +290,37 @@
|
||||
"NAV_EPG": "EPG",
|
||||
"NAV_REMOTE": "Afstandsbediening",
|
||||
"NAV_DATA": "Gegevens",
|
||||
"NAV_BACKUP": "Back-up",
|
||||
"NAV_RESET": "Resetten",
|
||||
"NAV_BACKUP": "Back-up en gegevens",
|
||||
"NAV_ABOUT": "Over",
|
||||
"NAV_GROUP_APP": "App",
|
||||
"NAV_GROUP_LIBRARY": "Bibliotheek",
|
||||
"NAV_GROUP_DEVICES": "Apparaten",
|
||||
"CARD_APPEARANCE": "Weergave",
|
||||
"CARD_STARTUP": "Opstarten",
|
||||
"CARD_CHANNELS": "Zenders",
|
||||
"CARD_PLAYER": "Speler",
|
||||
"CARD_WATCHING": "Kijken",
|
||||
"CARD_XTREAM": "Xtream",
|
||||
"CARD_DESKTOP": "Desktop",
|
||||
"CARD_GUIDE": "Gids",
|
||||
"CARD_SOURCES": "Bronnen",
|
||||
"CARD_XTREAM_PLAYLISTS": "Xtream-afspeellijsten",
|
||||
"CARD_RAILS": "Rijen",
|
||||
"CARD_CACHE": "Cache",
|
||||
"EPG_SUBTITLE": "Weergave van de programmagids en XMLTV-bronnen.",
|
||||
"PLAYBACK_SUBTITLE": "Speler, ondertitels en streamformaat.",
|
||||
"CLEAR_EPG_DATA_DESCRIPTION": "Verwijdert alle gedownloade programmagegevens. De bronnen blijven staan en kunnen worden vernieuwd.",
|
||||
"EPG_OFFSET_STEP_DOWN": "{{minutes}} minuten eerder",
|
||||
"EPG_OFFSET_STEP_UP": "{{minutes}} minuten later",
|
||||
"CHIP_RESTART": "Herstart",
|
||||
"CHIP_EXPERIMENTAL": "Experimenteel",
|
||||
"UPDATE_UP_TO_DATE": "Up-to-date",
|
||||
"UPDATE_AVAILABLE_CHIP": "Update beschikbaar",
|
||||
"UNSAVED_CHANGES_ONE": "1 niet-opgeslagen wijziging",
|
||||
"UNSAVED_CHANGES_COUNT": "{{count}} niet-opgeslagen wijzigingen",
|
||||
"RESTART_NOTICE": "{{settings}} wordt toegepast na een herstart.",
|
||||
"RESTART_LATER": "Later",
|
||||
"APP_UPDATE_CHANNEL_OPEN_BACKUP": "Back-up en gegevens openen",
|
||||
"NAV_TMDB": "Metadata",
|
||||
"TMDB": "Metadata (TMDB)",
|
||||
"TMDB_DESCRIPTION": "Verrijk film- en seriedetails met gegevens van The Movie Database",
|
||||
|
||||
@@ -270,7 +270,7 @@
|
||||
"SEARCH_RESULTS_COUNT": "Ustawienia pasujące do „{{query}}”: {{count}}",
|
||||
"SEARCH_EMPTY_HINT": "Brak pasujących ustawień. Spróbuj innego słowa, np. odtwarzacz, motyw lub EPG.",
|
||||
"GENERAL": "Ogólne",
|
||||
"GENERAL_SUBTITLE": "Zmień konfigurację aplikacji",
|
||||
"GENERAL_SUBTITLE": "Język, wygląd i sposób uruchamiania aplikacji.",
|
||||
"PLAYBACK": "Odtwarzanie",
|
||||
"EPG_SOURCES": "Źródła EPG",
|
||||
"EPG_SOURCES_DESCRIPTION": "Dodaj jeden lub więcej adresów URL jako źródła EPG",
|
||||
@@ -290,9 +290,37 @@
|
||||
"NAV_EPG": "EPG",
|
||||
"NAV_REMOTE": "Pilot",
|
||||
"NAV_DATA": "Dane",
|
||||
"NAV_BACKUP": "Kopia zapasowa",
|
||||
"NAV_RESET": "Resetowanie",
|
||||
"NAV_BACKUP": "Kopia zapasowa i dane",
|
||||
"NAV_ABOUT": "O aplikacji",
|
||||
"NAV_GROUP_APP": "Aplikacja",
|
||||
"NAV_GROUP_LIBRARY": "Biblioteka",
|
||||
"NAV_GROUP_DEVICES": "Urządzenia",
|
||||
"CARD_APPEARANCE": "Wygląd",
|
||||
"CARD_STARTUP": "Uruchamianie",
|
||||
"CARD_CHANNELS": "Kanały",
|
||||
"CARD_PLAYER": "Odtwarzacz",
|
||||
"CARD_WATCHING": "Oglądanie",
|
||||
"CARD_XTREAM": "Xtream",
|
||||
"CARD_DESKTOP": "Komputer",
|
||||
"CARD_GUIDE": "Przewodnik",
|
||||
"CARD_SOURCES": "Źródła",
|
||||
"CARD_XTREAM_PLAYLISTS": "Playlisty Xtream",
|
||||
"CARD_RAILS": "Wiersze",
|
||||
"CARD_CACHE": "Pamięć podręczna",
|
||||
"EPG_SUBTITLE": "Wyświetlanie przewodnika po programach i źródła XMLTV.",
|
||||
"PLAYBACK_SUBTITLE": "Odtwarzacz, napisy i format strumienia.",
|
||||
"CLEAR_EPG_DATA_DESCRIPTION": "Usuwa wszystkie pobrane dane programów. Źródła pozostają i można je odświeżyć.",
|
||||
"EPG_OFFSET_STEP_DOWN": "O {{minutes}} minut wcześniej",
|
||||
"EPG_OFFSET_STEP_UP": "O {{minutes}} minut później",
|
||||
"CHIP_RESTART": "Restart",
|
||||
"CHIP_EXPERIMENTAL": "Eksperymentalne",
|
||||
"UPDATE_UP_TO_DATE": "Aktualna",
|
||||
"UPDATE_AVAILABLE_CHIP": "Dostępna aktualizacja",
|
||||
"UNSAVED_CHANGES_ONE": "1 niezapisana zmiana",
|
||||
"UNSAVED_CHANGES_COUNT": "Niezapisane zmiany: {{count}}",
|
||||
"RESTART_NOTICE": "{{settings}} zostanie zastosowane po ponownym uruchomieniu.",
|
||||
"RESTART_LATER": "Później",
|
||||
"APP_UPDATE_CHANNEL_OPEN_BACKUP": "Otwórz Kopia zapasowa i dane",
|
||||
"NAV_TMDB": "Metadane",
|
||||
"TMDB": "Metadane (TMDB)",
|
||||
"TMDB_DESCRIPTION": "Wzbogacaj szczegóły filmów i seriali o dane z The Movie Database",
|
||||
|
||||
@@ -270,7 +270,7 @@
|
||||
"SEARCH_RESULTS_COUNT": "Configurações correspondentes a “{{query}}”: {{count}}",
|
||||
"SEARCH_EMPTY_HINT": "Nenhuma configuração corresponde. Tente outra palavra, como reprodutor, tema ou EPG.",
|
||||
"GENERAL": "Geral",
|
||||
"GENERAL_SUBTITLE": "Altere a configuração do aplicativo",
|
||||
"GENERAL_SUBTITLE": "Idioma, aparência e como o aplicativo inicia.",
|
||||
"PLAYBACK": "Reprodução",
|
||||
"EPG_SOURCES": "Fontes de EPG",
|
||||
"EPG_SOURCES_DESCRIPTION": "Adicione uma ou mais URLs como fontes de EPG",
|
||||
@@ -290,9 +290,37 @@
|
||||
"NAV_EPG": "EPG",
|
||||
"NAV_REMOTE": "Remoto",
|
||||
"NAV_DATA": "Dados",
|
||||
"NAV_BACKUP": "Cópia de segurança",
|
||||
"NAV_RESET": "Redefinir",
|
||||
"NAV_BACKUP": "Cópia de segurança e dados",
|
||||
"NAV_ABOUT": "Sobre",
|
||||
"NAV_GROUP_APP": "Aplicativo",
|
||||
"NAV_GROUP_LIBRARY": "Biblioteca",
|
||||
"NAV_GROUP_DEVICES": "Dispositivos",
|
||||
"CARD_APPEARANCE": "Aparência",
|
||||
"CARD_STARTUP": "Inicialização",
|
||||
"CARD_CHANNELS": "Canais",
|
||||
"CARD_PLAYER": "Reprodutor",
|
||||
"CARD_WATCHING": "Visualização",
|
||||
"CARD_XTREAM": "Xtream",
|
||||
"CARD_DESKTOP": "Área de trabalho",
|
||||
"CARD_GUIDE": "Guia",
|
||||
"CARD_SOURCES": "Fontes",
|
||||
"CARD_XTREAM_PLAYLISTS": "Listas Xtream",
|
||||
"CARD_RAILS": "Fileiras",
|
||||
"CARD_CACHE": "Cache",
|
||||
"EPG_SUBTITLE": "Exibição do guia de programação e fontes XMLTV.",
|
||||
"PLAYBACK_SUBTITLE": "Reprodutor, legendas e formato de transmissão.",
|
||||
"CLEAR_EPG_DATA_DESCRIPTION": "Remove todos os dados de programação baixados. As fontes permanecem e podem ser atualizadas.",
|
||||
"EPG_OFFSET_STEP_DOWN": "{{minutes}} minutos antes",
|
||||
"EPG_OFFSET_STEP_UP": "{{minutes}} minutos depois",
|
||||
"CHIP_RESTART": "Reinício",
|
||||
"CHIP_EXPERIMENTAL": "Experimental",
|
||||
"UPDATE_UP_TO_DATE": "Atualizado",
|
||||
"UPDATE_AVAILABLE_CHIP": "Atualização disponível",
|
||||
"UNSAVED_CHANGES_ONE": "1 alteração não salva",
|
||||
"UNSAVED_CHANGES_COUNT": "{{count}} alterações não salvas",
|
||||
"RESTART_NOTICE": "{{settings}} será aplicado após reiniciar.",
|
||||
"RESTART_LATER": "Mais tarde",
|
||||
"APP_UPDATE_CHANNEL_OPEN_BACKUP": "Abrir Cópia de segurança e dados",
|
||||
"NAV_TMDB": "Metadados",
|
||||
"TMDB": "Metadados (TMDB)",
|
||||
"TMDB_DESCRIPTION": "Enriqueça os detalhes de filmes e séries com dados do The Movie Database",
|
||||
|
||||
@@ -270,7 +270,7 @@
|
||||
"SEARCH_RESULTS_COUNT": "Настройки по запросу «{{query}}»: {{count}}",
|
||||
"SEARCH_EMPTY_HINT": "Ничего не найдено. Попробуйте другое слово, например «плеер», «тема» или «EPG».",
|
||||
"GENERAL": "Общие",
|
||||
"GENERAL_SUBTITLE": "Изменение основных параметров приложения",
|
||||
"GENERAL_SUBTITLE": "Язык, внешний вид и запуск приложения.",
|
||||
"PLAYBACK": "Воспроизведение",
|
||||
"EPG_SOURCES": "Источники EPG",
|
||||
"EPG_SOURCES_DESCRIPTION": "Добавьте один или несколько источников XMLTV: ссылки или локальные файлы",
|
||||
@@ -290,9 +290,37 @@
|
||||
"NAV_EPG": "EPG",
|
||||
"NAV_REMOTE": "Пульт",
|
||||
"NAV_DATA": "Данные",
|
||||
"NAV_BACKUP": "Резервная копия",
|
||||
"NAV_RESET": "Сброс",
|
||||
"NAV_BACKUP": "Резервная копия и данные",
|
||||
"NAV_ABOUT": "Инфо",
|
||||
"NAV_GROUP_APP": "Приложение",
|
||||
"NAV_GROUP_LIBRARY": "Библиотека",
|
||||
"NAV_GROUP_DEVICES": "Устройства",
|
||||
"CARD_APPEARANCE": "Внешний вид",
|
||||
"CARD_STARTUP": "Запуск",
|
||||
"CARD_CHANNELS": "Каналы",
|
||||
"CARD_PLAYER": "Плеер",
|
||||
"CARD_WATCHING": "Просмотр",
|
||||
"CARD_XTREAM": "Xtream",
|
||||
"CARD_DESKTOP": "Настольная версия",
|
||||
"CARD_GUIDE": "Телегид",
|
||||
"CARD_SOURCES": "Источники",
|
||||
"CARD_XTREAM_PLAYLISTS": "Плейлисты Xtream",
|
||||
"CARD_RAILS": "Ряды",
|
||||
"CARD_CACHE": "Кэш",
|
||||
"EPG_SUBTITLE": "Отображение телегида и источники XMLTV.",
|
||||
"PLAYBACK_SUBTITLE": "Плеер, субтитры и формат потока.",
|
||||
"CLEAR_EPG_DATA_DESCRIPTION": "Удаляет все загруженные данные телепрограммы. Источники остаются, их можно обновить.",
|
||||
"EPG_OFFSET_STEP_DOWN": "На {{minutes}} минут раньше",
|
||||
"EPG_OFFSET_STEP_UP": "На {{minutes}} минут позже",
|
||||
"CHIP_RESTART": "Перезапуск",
|
||||
"CHIP_EXPERIMENTAL": "Экспериментально",
|
||||
"UPDATE_UP_TO_DATE": "Актуальная версия",
|
||||
"UPDATE_AVAILABLE_CHIP": "Доступно обновление",
|
||||
"UNSAVED_CHANGES_ONE": "1 несохранённое изменение",
|
||||
"UNSAVED_CHANGES_COUNT": "Несохранённые изменения: {{count}}",
|
||||
"RESTART_NOTICE": "{{settings}} вступит в силу после перезапуска.",
|
||||
"RESTART_LATER": "Позже",
|
||||
"APP_UPDATE_CHANNEL_OPEN_BACKUP": "Открыть «Резервная копия и данные»",
|
||||
"NAV_TMDB": "Метаданные",
|
||||
"TMDB": "Метаданные (TMDB)",
|
||||
"TMDB_DESCRIPTION": "Обогащение информации о фильмах и сериалах данными из The Movie Database",
|
||||
|
||||
@@ -270,7 +270,7 @@
|
||||
"SEARCH_RESULTS_COUNT": "“{{query}}” ile eşleşen ayarlar: {{count}}",
|
||||
"SEARCH_EMPTY_HINT": "Eşleşen ayar yok. Oynatıcı, tema veya EPG gibi başka bir kelime deneyin.",
|
||||
"GENERAL": "Genel",
|
||||
"GENERAL_SUBTITLE": "Uygulamanın yapılandırmasını değiştirin",
|
||||
"GENERAL_SUBTITLE": "Dil, görünüm ve uygulamanın nasıl başlayacağı.",
|
||||
"PLAYBACK": "Oynatma",
|
||||
"EPG_SOURCES": "EPG kaynakları",
|
||||
"EPG_SOURCES_DESCRIPTION": "EPG kaynağı olarak bir veya daha fazla URL ekleyin",
|
||||
@@ -290,9 +290,37 @@
|
||||
"NAV_EPG": "EPG",
|
||||
"NAV_REMOTE": "Uzaktan",
|
||||
"NAV_DATA": "Veri",
|
||||
"NAV_BACKUP": "Yedekleme",
|
||||
"NAV_RESET": "Sıfırla",
|
||||
"NAV_BACKUP": "Yedekleme ve veriler",
|
||||
"NAV_ABOUT": "Hakkında",
|
||||
"NAV_GROUP_APP": "Uygulama",
|
||||
"NAV_GROUP_LIBRARY": "Kitaplık",
|
||||
"NAV_GROUP_DEVICES": "Cihazlar",
|
||||
"CARD_APPEARANCE": "Görünüm",
|
||||
"CARD_STARTUP": "Başlangıç",
|
||||
"CARD_CHANNELS": "Kanallar",
|
||||
"CARD_PLAYER": "Oynatıcı",
|
||||
"CARD_WATCHING": "İzleme",
|
||||
"CARD_XTREAM": "Xtream",
|
||||
"CARD_DESKTOP": "Masaüstü",
|
||||
"CARD_GUIDE": "Rehber",
|
||||
"CARD_SOURCES": "Kaynaklar",
|
||||
"CARD_XTREAM_PLAYLISTS": "Xtream oynatma listeleri",
|
||||
"CARD_RAILS": "Satırlar",
|
||||
"CARD_CACHE": "Önbellek",
|
||||
"EPG_SUBTITLE": "Program rehberi görünümü ve XMLTV kaynakları.",
|
||||
"PLAYBACK_SUBTITLE": "Oynatıcı, altyazılar ve akış biçimi.",
|
||||
"CLEAR_EPG_DATA_DESCRIPTION": "İndirilen tüm program verilerini kaldırır. Kaynaklar kalır ve yenilenebilir.",
|
||||
"EPG_OFFSET_STEP_DOWN": "{{minutes}} dakika önce",
|
||||
"EPG_OFFSET_STEP_UP": "{{minutes}} dakika sonra",
|
||||
"CHIP_RESTART": "Yeniden başlatma",
|
||||
"CHIP_EXPERIMENTAL": "Deneysel",
|
||||
"UPDATE_UP_TO_DATE": "Güncel",
|
||||
"UPDATE_AVAILABLE_CHIP": "Güncelleme mevcut",
|
||||
"UNSAVED_CHANGES_ONE": "1 kaydedilmemiş değişiklik",
|
||||
"UNSAVED_CHANGES_COUNT": "{{count}} kaydedilmemiş değişiklik",
|
||||
"RESTART_NOTICE": "{{settings}} yeniden başlatmanın ardından uygulanacak.",
|
||||
"RESTART_LATER": "Daha sonra",
|
||||
"APP_UPDATE_CHANNEL_OPEN_BACKUP": "Yedekleme ve verileri aç",
|
||||
"NAV_TMDB": "Meta veri",
|
||||
"TMDB": "Meta veri (TMDB)",
|
||||
"TMDB_DESCRIPTION": "Film ve dizi ayrıntılarını The Movie Database verileriyle zenginleştirin",
|
||||
|
||||
@@ -270,7 +270,7 @@
|
||||
"SEARCH_RESULTS_COUNT": "与“{{query}}”匹配的设置:{{count}}",
|
||||
"SEARCH_EMPTY_HINT": "没有匹配的设置。试试其他词,例如“播放器”“主题”或“EPG”。",
|
||||
"GENERAL": "通用",
|
||||
"GENERAL_SUBTITLE": "更改应用程序的配置",
|
||||
"GENERAL_SUBTITLE": "语言、外观和应用启动方式。",
|
||||
"PLAYBACK": "播放",
|
||||
"EPG_SOURCES": "EPG 源",
|
||||
"EPG_SOURCES_DESCRIPTION": "添加一个或多个 URL 作为 EPG 源",
|
||||
@@ -290,9 +290,37 @@
|
||||
"NAV_EPG": "EPG",
|
||||
"NAV_REMOTE": "远程",
|
||||
"NAV_DATA": "数据",
|
||||
"NAV_BACKUP": "备份",
|
||||
"NAV_RESET": "重置",
|
||||
"NAV_BACKUP": "备份与数据",
|
||||
"NAV_ABOUT": "关于",
|
||||
"NAV_GROUP_APP": "应用",
|
||||
"NAV_GROUP_LIBRARY": "媒体库",
|
||||
"NAV_GROUP_DEVICES": "设备",
|
||||
"CARD_APPEARANCE": "外观",
|
||||
"CARD_STARTUP": "启动",
|
||||
"CARD_CHANNELS": "频道",
|
||||
"CARD_PLAYER": "播放器",
|
||||
"CARD_WATCHING": "观看",
|
||||
"CARD_XTREAM": "Xtream",
|
||||
"CARD_DESKTOP": "桌面版",
|
||||
"CARD_GUIDE": "节目指南",
|
||||
"CARD_SOURCES": "来源",
|
||||
"CARD_XTREAM_PLAYLISTS": "Xtream 播放列表",
|
||||
"CARD_RAILS": "内容行",
|
||||
"CARD_CACHE": "缓存",
|
||||
"EPG_SUBTITLE": "节目指南显示与 XMLTV 来源。",
|
||||
"PLAYBACK_SUBTITLE": "播放器、字幕和流格式。",
|
||||
"CLEAR_EPG_DATA_DESCRIPTION": "删除所有已下载的节目数据。来源将保留并可刷新。",
|
||||
"EPG_OFFSET_STEP_DOWN": "提前 {{minutes}} 分钟",
|
||||
"EPG_OFFSET_STEP_UP": "推后 {{minutes}} 分钟",
|
||||
"CHIP_RESTART": "需重启",
|
||||
"CHIP_EXPERIMENTAL": "实验性",
|
||||
"UPDATE_UP_TO_DATE": "已是最新",
|
||||
"UPDATE_AVAILABLE_CHIP": "有可用更新",
|
||||
"UNSAVED_CHANGES_ONE": "1 项未保存的更改",
|
||||
"UNSAVED_CHANGES_COUNT": "{{count}} 项未保存的更改",
|
||||
"RESTART_NOTICE": "{{settings}} 将在重启后生效。",
|
||||
"RESTART_LATER": "稍后",
|
||||
"APP_UPDATE_CHANNEL_OPEN_BACKUP": "打开“备份与数据”",
|
||||
"NAV_TMDB": "元数据",
|
||||
"TMDB": "元数据(TMDB)",
|
||||
"TMDB_DESCRIPTION": "使用来自 The Movie Database 的数据丰富电影和剧集详情",
|
||||
|
||||
@@ -270,7 +270,7 @@
|
||||
"SEARCH_RESULTS_COUNT": "符合「{{query}}」的設定:{{count}}",
|
||||
"SEARCH_EMPTY_HINT": "沒有符合的設定。試試其他詞,例如「播放器」「主題」或「EPG」。",
|
||||
"GENERAL": "一般",
|
||||
"GENERAL_SUBTITLE": "更改應用程式的配置",
|
||||
"GENERAL_SUBTITLE": "語言、外觀與應用程式的啟動方式。",
|
||||
"PLAYBACK": "播放",
|
||||
"EPG_SOURCES": "EPG 來源",
|
||||
"EPG_SOURCES_DESCRIPTION": "新增一個或多個網址作為 EPG 來源",
|
||||
@@ -290,9 +290,37 @@
|
||||
"NAV_EPG": "EPG",
|
||||
"NAV_REMOTE": "遠端",
|
||||
"NAV_DATA": "資料",
|
||||
"NAV_BACKUP": "備份",
|
||||
"NAV_RESET": "重設",
|
||||
"NAV_BACKUP": "備份與資料",
|
||||
"NAV_ABOUT": "關於",
|
||||
"NAV_GROUP_APP": "應用程式",
|
||||
"NAV_GROUP_LIBRARY": "媒體庫",
|
||||
"NAV_GROUP_DEVICES": "裝置",
|
||||
"CARD_APPEARANCE": "外觀",
|
||||
"CARD_STARTUP": "啟動",
|
||||
"CARD_CHANNELS": "頻道",
|
||||
"CARD_PLAYER": "播放器",
|
||||
"CARD_WATCHING": "觀看",
|
||||
"CARD_XTREAM": "Xtream",
|
||||
"CARD_DESKTOP": "桌面版",
|
||||
"CARD_GUIDE": "節目表",
|
||||
"CARD_SOURCES": "來源",
|
||||
"CARD_XTREAM_PLAYLISTS": "Xtream 播放清單",
|
||||
"CARD_RAILS": "內容列",
|
||||
"CARD_CACHE": "快取",
|
||||
"EPG_SUBTITLE": "節目表顯示與 XMLTV 來源。",
|
||||
"PLAYBACK_SUBTITLE": "播放器、字幕與串流格式。",
|
||||
"CLEAR_EPG_DATA_DESCRIPTION": "刪除所有已下載的節目資料。來源會保留並可重新整理。",
|
||||
"EPG_OFFSET_STEP_DOWN": "提前 {{minutes}} 分鐘",
|
||||
"EPG_OFFSET_STEP_UP": "延後 {{minutes}} 分鐘",
|
||||
"CHIP_RESTART": "需重新啟動",
|
||||
"CHIP_EXPERIMENTAL": "實驗性",
|
||||
"UPDATE_UP_TO_DATE": "已是最新",
|
||||
"UPDATE_AVAILABLE_CHIP": "有可用更新",
|
||||
"UNSAVED_CHANGES_ONE": "1 項未儲存的變更",
|
||||
"UNSAVED_CHANGES_COUNT": "{{count}} 項未儲存的變更",
|
||||
"RESTART_NOTICE": "{{settings}} 將在重新啟動後生效。",
|
||||
"RESTART_LATER": "稍後",
|
||||
"APP_UPDATE_CHANNEL_OPEN_BACKUP": "開啟「備份與資料」",
|
||||
"NAV_TMDB": "中繼資料",
|
||||
"TMDB": "中繼資料(TMDB)",
|
||||
"TMDB_DESCRIPTION": "使用 The Movie Database 的資料豐富電影與影集的詳細資訊",
|
||||
|
||||
Reference in new issue
Block a user