mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 19:46:16 -08:00
* fix(i18n): follow runtime language switches in stored and memoized labels
A label translated once and kept kept its language after Settings ->
Language until the data reloaded or the app restarted. The Stalker store
baked `translate.instant('PORTALS.ALL_CATEGORIES' | 'PORTALS.ALL_RADIO')`
into its category list, so the every-item genre stayed English in the
rail, the live header, the fullscreen panel title, the catalog title and
the search scope.
Stalker: the every-item genre now carries `labelKey` and an empty
`category_name`; the category views render the key through the translate
pipe. `getSelectedCategoryName` becomes `getSelectedCategoryLabel`
({ name, labelKey }) and every text consumer goes through
`stalkerCategoryLabelText()` inside a computed that reads a language
signal, so no consumer can show the empty name or a stale translation.
Same class elsewhere (computed or stored `instant` text without a language
signal): the Xtream import overlay title and progress (shell-provided),
the Settings About version note (stored in a signal on the page where the
language changes), the remove-all-playlists progress, the context panel's
status/error text and category search, and the movie/series heroes
(`createVodDetailsHeroState`, `createSeriesHeroState`, the Xtream movie
presenter), cast & crew "Director", the M3U sidebar count, the M3U movie
hero and the collection panel title. These read
`toSignal(onLangChange.pipe(startWith(null)))` and also recover when the
translation file lands after the first render.
Documents the rule in the UI guidelines and the Stalker store contract.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(stalker): record getSelectedCategoryLabel in the store API baseline
getSelectedCategoryName is gone without an alias: a name-only selector is
empty for the every-item genre, whose label is a translation key.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(i18n): one translation tick that also follows late dictionaries
Greptile asked for coverage of a dictionary that lands after the first
render. That exposed a gap: every hand-written tick listened to
`onLangChange` only, but a start-up without a saved language never calls
`use()` - the default dictionary landing fires `onDefaultLangChange` alone,
so computeds that ran before it kept raw keys. Dictionary updates
(`onTranslationChange`) were missed the same way.
`injectTranslationTick()` in `@iptvnator/pipes` merges the three events the
translate pipe re-renders on (the embedded MPV player already did). All 37
ticks use it now, including the hero factories' `language` dependency. New
specs cover the delayed default-language load, a late `use()` dictionary
and a dictionary update; the hero specs add the start-up case.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(i18n): stub the Xtream selection the merged search scope reads
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(i18n): series view reads the shared translation tick
#1871 added an onLangChange-only tick for the synthetic episode titles; a
start-up dictionary that lands without use() would leave raw keys.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(i18n): move injectTranslationTick to @iptvnator/services
The tick injects TranslateService and subscribes, so it is injectable
runtime state; nx-workspace-boundaries.md reserves type:util for pure
helpers and contracts. @iptvnator/services is the shared type:data-access
project every consumer domain may depend on, and it imports none of them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
520 lines
20 KiB
TypeScript
520 lines
20 KiB
TypeScript
import {
|
|
Component,
|
|
computed,
|
|
effect,
|
|
ElementRef,
|
|
HostListener,
|
|
inject,
|
|
OnDestroy,
|
|
OnInit,
|
|
signal,
|
|
ViewEncapsulation,
|
|
ChangeDetectionStrategy,
|
|
} from '@angular/core';
|
|
import { toSignal } from '@angular/core/rxjs-interop';
|
|
import { ReactiveFormsModule } from '@angular/forms';
|
|
import { MatButtonModule } from '@angular/material/button';
|
|
import { MatDialog } from '@angular/material/dialog';
|
|
import { MatIconModule } from '@angular/material/icon';
|
|
import { ActivatedRoute, Router } from '@angular/router';
|
|
import { SettingsContextService } from '@iptvnator/workspace/shell/util/settings-context';
|
|
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
|
import {
|
|
EpgSourceReconciliationError,
|
|
RuntimeCapabilitiesService,
|
|
injectTranslationTick,
|
|
} from '@iptvnator/services';
|
|
import { VodSourceDiscoveryService } from '@iptvnator/portal/shared/data-access';
|
|
import {
|
|
ELECTRON_BRIDGE_APP_UPDATE_STATUSES,
|
|
Language,
|
|
StreamFormat,
|
|
} from '@iptvnator/shared/interfaces';
|
|
import { firstValueFrom, map } 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';
|
|
import { SettingsEpgSectionComponent } from './settings-epg-section.component';
|
|
import { SettingsFormFacade } from './settings-form.facade';
|
|
import { SettingsGeneralSectionComponent } from './settings-general-section.component';
|
|
import { SettingsSection } from './settings.models';
|
|
import {
|
|
buildSettingsPlayerOptions,
|
|
buildSettingsSectionNavItems,
|
|
SETTINGS_COVER_SIZE_OPTIONS,
|
|
SETTINGS_EPG_VIEW_MODE_OPTIONS,
|
|
SETTINGS_STARTUP_BEHAVIOR_OPTIONS,
|
|
SETTINGS_STARTUP_WINDOW_MODE_OPTIONS,
|
|
SETTINGS_UPDATE_CHANNEL_OPTIONS,
|
|
SETTINGS_THEME_OPTIONS,
|
|
} from './settings-options';
|
|
import { SettingsParentalLockFacade } from './settings-parental-lock.facade';
|
|
import { SettingsParentalLockSectionComponent } from './settings-parental-section.component';
|
|
import { SettingsPlaybackSectionComponent } from './settings-playback-section.component';
|
|
import { SettingsRemoteControlFacade } from './settings-remote-control.facade';
|
|
import { SettingsRemoteControlSectionComponent } from './settings-remote-control-section.component';
|
|
import { SettingsSearchFacade } from './settings-search.facade';
|
|
import { SettingsSearchResultsComponent } from './settings-search-results.component';
|
|
import { SettingsTmdbSectionComponent } from './settings-tmdb-section.component';
|
|
import {
|
|
SettingsUnsavedChangesChoice,
|
|
SettingsUnsavedChangesDialogComponent,
|
|
} from './settings-unsaved-changes-dialog.component';
|
|
import { SettingsLeaveConfirmation } from './settings-unsaved-changes.guard';
|
|
import { SettingsUnloadGuardService } from './settings-unload-guard.service';
|
|
import { SettingsBackupFacade } from './settings-backup.facade';
|
|
import { SettingsPlaylistResetFacade } from './settings-playlist-reset.facade';
|
|
import { SettingsSnackbarService } from './settings-snackbar.service';
|
|
|
|
export const SETTINGS_DEFAULT_SECTION = 'general';
|
|
|
|
/**
|
|
* Thin coordinator for the settings page. The behaviour of each section lives
|
|
* in a dedicated facade the template binds to directly; what stays here is the
|
|
* page-level wiring: environment capabilities, the `:section` route param that
|
|
* selects which section page renders, and the save/discard flow that spans
|
|
* several facades.
|
|
*
|
|
* Routed as `/workspace/settings/:section`. Navigating between sections only
|
|
* changes the param — the component instance (and with it the form and its
|
|
* dirty state) survives until the user leaves settings entirely.
|
|
*/
|
|
@Component({
|
|
templateUrl: './settings.component.html',
|
|
styleUrls: ['./settings.component.scss'],
|
|
host: {
|
|
class: 'settings-page-host',
|
|
},
|
|
encapsulation: ViewEncapsulation.None,
|
|
imports: [
|
|
MatButtonModule,
|
|
MatIconModule,
|
|
ReactiveFormsModule,
|
|
TranslateModule,
|
|
SettingsAboutSectionComponent,
|
|
SettingsBackupSectionComponent,
|
|
SettingsDashboardSectionComponent,
|
|
SettingsEpgSectionComponent,
|
|
SettingsGeneralSectionComponent,
|
|
SettingsParentalLockSectionComponent,
|
|
SettingsPlaybackSectionComponent,
|
|
SettingsRemoteControlSectionComponent,
|
|
SettingsSearchResultsComponent,
|
|
SettingsTmdbSectionComponent,
|
|
],
|
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
providers: [
|
|
SettingsAppUpdateFacade,
|
|
SettingsBackupFacade,
|
|
SettingsEmbeddedMpvFacade,
|
|
SettingsEpgFacade,
|
|
SettingsFormFacade,
|
|
SettingsParentalLockFacade,
|
|
SettingsPlaylistResetFacade,
|
|
SettingsRemoteControlFacade,
|
|
SettingsSearchFacade,
|
|
SettingsSnackbarService,
|
|
SettingsUnloadGuardService,
|
|
],
|
|
})
|
|
export class SettingsComponent
|
|
implements OnInit, OnDestroy, SettingsLeaveConfirmation
|
|
{
|
|
readonly appUpdate = inject(SettingsAppUpdateFacade);
|
|
readonly backup = inject(SettingsBackupFacade);
|
|
readonly embeddedMpv = inject(SettingsEmbeddedMpvFacade);
|
|
readonly epg = inject(SettingsEpgFacade);
|
|
readonly form = inject(SettingsFormFacade);
|
|
readonly parentalLock = inject(SettingsParentalLockFacade);
|
|
readonly playlistReset = inject(SettingsPlaylistResetFacade);
|
|
readonly remoteControl = inject(SettingsRemoteControlFacade);
|
|
readonly search = inject(SettingsSearchFacade);
|
|
|
|
private readonly settingsCtx = inject(SettingsContextService);
|
|
private readonly settingsSnackbar = inject(SettingsSnackbarService);
|
|
private readonly unloadGuard = inject(SettingsUnloadGuardService);
|
|
private readonly runtime = inject(RuntimeCapabilitiesService);
|
|
private readonly vodSourceDiscovery = inject(VodSourceDiscoveryService);
|
|
private readonly matDialog = inject(MatDialog);
|
|
private readonly route = inject(ActivatedRoute);
|
|
private readonly router = inject(Router);
|
|
private readonly translate = inject(TranslateService);
|
|
private readonly hostElement = inject(ElementRef<HTMLElement>);
|
|
|
|
/** List with available languages as enum */
|
|
readonly languageEnum = Language;
|
|
|
|
/** List with allowed formats as enum */
|
|
readonly streamFormatEnum = StreamFormat;
|
|
|
|
/** Flag that indicates whether the app runs in electron environment */
|
|
readonly isDesktop = this.runtime.isElectron;
|
|
readonly isPwa = this.runtime.isPwa;
|
|
readonly supportsDesktopFileSave = this.runtime.supportsDesktopFileSave;
|
|
readonly supportsEpg = this.form.supportsEpg;
|
|
readonly supportsManagedExternalPlayers =
|
|
this.runtime.supportsManagedExternalPlayers;
|
|
readonly supportsExternalPlayerPathSettings =
|
|
this.runtime.supportsExternalPlayerPathSettings;
|
|
readonly supportsVodMultiSource = this.vodSourceDiscovery.isAvailable;
|
|
readonly supportsRemoteControl = this.runtime.supportsRemoteControl;
|
|
readonly supportsStartupWindowMode = this.runtime.supportsStartupWindowMode;
|
|
readonly supportsPortalConnectivityGuard =
|
|
this.runtime.supportsPortalConnectivityGuard;
|
|
|
|
/** Settings form object */
|
|
readonly settingsForm = this.form.form;
|
|
|
|
/** Set once the form is hydrated, so form-dependent rows can render. */
|
|
private readonly formReady = signal(false);
|
|
|
|
/** Player options */
|
|
readonly players = computed(() =>
|
|
buildSettingsPlayerOptions({
|
|
supportsEmbeddedMpv: this.embeddedMpv.supported(),
|
|
supportsManagedExternalPlayers: this.supportsManagedExternalPlayers,
|
|
})
|
|
);
|
|
|
|
/** 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 = injectTranslationTick();
|
|
|
|
/** 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;
|
|
readonly startupWindowModeOptions = SETTINGS_STARTUP_WINDOW_MODE_OPTIONS;
|
|
readonly updateChannelOptions = SETTINGS_UPDATE_CHANNEL_OPTIONS;
|
|
readonly epgViewModeOptions = SETTINGS_EPG_VIEW_MODE_OPTIONS;
|
|
|
|
readonly sectionNavItems: SettingsSection[] = buildSettingsSectionNavItems({
|
|
supportsEpg: this.supportsEpg,
|
|
supportsRemoteControl: this.supportsRemoteControl,
|
|
});
|
|
|
|
get sectionNav(): SettingsSection[] {
|
|
return this.sectionNavItems.filter((section) => section.visible);
|
|
}
|
|
|
|
private readonly sectionParam = toSignal(
|
|
this.route.paramMap.pipe(map((params) => params.get('section'))),
|
|
{ initialValue: null }
|
|
);
|
|
|
|
/**
|
|
* Section page currently rendered. Unknown or capability-gated params
|
|
* fall back to the default section while the redirect effect below
|
|
* rewrites the URL to match.
|
|
*/
|
|
readonly activeSection = computed(() => {
|
|
const section = this.sectionParam();
|
|
return section && this.isNavigableSection(section)
|
|
? section
|
|
: SETTINGS_DEFAULT_SECTION;
|
|
});
|
|
|
|
constructor() {
|
|
// A stale or hand-typed URL (`/settings/epg` in a runtime without
|
|
// EPG support, `/settings/nonsense`) must not leave the address bar
|
|
// lying about what is on screen.
|
|
effect(() => {
|
|
const section = this.sectionParam();
|
|
if (section && !this.isNavigableSection(section)) {
|
|
void this.router.navigate(
|
|
['/workspace/settings', SETTINGS_DEFAULT_SECTION],
|
|
{ replaceUrl: true }
|
|
);
|
|
}
|
|
});
|
|
|
|
// Section pages share one scroll container (`main.workspace-content`);
|
|
// without this, opening a long section, scrolling, and switching to a
|
|
// short one strands the viewport past the new page's content. Instant
|
|
// on purpose — this is navigation, not an animated transition.
|
|
effect(() => {
|
|
this.activeSection();
|
|
this.search.isSearching();
|
|
this.hostElement.nativeElement
|
|
.closest('main.workspace-content')
|
|
?.scrollTo({ top: 0 });
|
|
});
|
|
|
|
this.search.bindReveal({
|
|
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();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reads the config object from the browsers
|
|
* storage (indexed db)
|
|
*/
|
|
async ngOnInit(): Promise<void> {
|
|
// The router guard only covers in-app navigation; this protects the
|
|
// same edits against window close, app quit, and page reload.
|
|
this.unloadGuard.activate({
|
|
form: this.settingsForm,
|
|
confirmClose: () => this.confirmLeaveWithUnsavedChanges(),
|
|
});
|
|
|
|
// Wait for settings to load before setting the form
|
|
await this.form.loadSettings();
|
|
this.form.hydrateFromStore();
|
|
this.form.bindDashboardControlsEnabledState();
|
|
this.formReady.set(true);
|
|
void this.embeddedMpv.load();
|
|
this.appUpdate.checkAppVersion();
|
|
this.appUpdate.init();
|
|
void this.remoteControl.fetchLocalIpAddresses();
|
|
|
|
this.settingsCtx.setSections(this.sectionNav);
|
|
}
|
|
|
|
ngOnDestroy(): void {
|
|
this.appUpdate.dispose();
|
|
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();
|
|
|
|
if (folder) {
|
|
this.form.setRecordingFolder(folder);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Triggers on form submit and saves the config object to
|
|
* the indexed db store
|
|
*/
|
|
onSubmit(): void {
|
|
void this.persistSettings();
|
|
}
|
|
|
|
/**
|
|
* Exit gate for `settingsUnsavedChangesGuard`: silently allows leaving
|
|
* while the form is pristine, otherwise lets the user save, discard, or
|
|
* stay. A failed save keeps the user in settings — navigating away on a
|
|
* write that did not land would silently lose the edits the dialog just
|
|
* promised to keep.
|
|
*/
|
|
async confirmLeaveWithUnsavedChanges(): Promise<boolean> {
|
|
if (this.settingsForm.pristine) {
|
|
return true;
|
|
}
|
|
|
|
const choice = await firstValueFrom(
|
|
this.matDialog
|
|
.open<
|
|
SettingsUnsavedChangesDialogComponent,
|
|
{ canSave: boolean },
|
|
SettingsUnsavedChangesChoice
|
|
>(SettingsUnsavedChangesDialogComponent, {
|
|
width: '440px',
|
|
data: { canSave: this.settingsForm.valid },
|
|
})
|
|
.afterClosed()
|
|
);
|
|
|
|
if (choice === 'save') {
|
|
return this.persistSettings();
|
|
}
|
|
|
|
if (choice === 'discard') {
|
|
// Also reverts the live theme preview — leaving must not keep a
|
|
// theme the store never saved.
|
|
this.discardChanges();
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/** @returns whether the write actually landed */
|
|
private async persistSettings(): Promise<boolean> {
|
|
try {
|
|
await this.form.save(() => this.applyChangedSettings());
|
|
return true;
|
|
} catch (error) {
|
|
if (error instanceof EpgSourceReconciliationError) {
|
|
this.settingsSnackbar.open(
|
|
this.translate.instant('SETTINGS.EPG_DATA_CLEAR_FAILED')
|
|
);
|
|
return false;
|
|
}
|
|
// The store already applied the change in memory, so without
|
|
// this the save looks successful until the next restart. The
|
|
// unsaved-changes bar stays visible so it can be retried.
|
|
//
|
|
// The Electron-side pushes in SettingsFormFacade.save() stay in
|
|
// the success branch on purpose: main keeps its own copy of the
|
|
// player paths and remote-control state, and applying half the
|
|
// form while telling the user nothing was saved is worse than
|
|
// applying none of it. Once settings live in the main process
|
|
// (issue #1273) this split disappears.
|
|
this.settingsSnackbar.storageFailure('save');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Throws away every staged form edit and returns to the persisted
|
|
* settings. `applySavedSettings` also reverts the live theme preview
|
|
* (`selectTheme` applies immediately) and marks the form pristine, which
|
|
* hides the unsaved-changes bar.
|
|
*/
|
|
discardChanges(): void {
|
|
this.form.hydrateFromStore();
|
|
this.form.applySavedSettings();
|
|
}
|
|
|
|
/**
|
|
* Applies the changed settings to the app
|
|
*/
|
|
applyChangedSettings(): void {
|
|
this.form.applySavedSettings();
|
|
this.epg.fetchConfiguredEpg();
|
|
this.settingsSnackbar.open(
|
|
this.translate.instant('SETTINGS.SETTINGS_SAVED')
|
|
);
|
|
}
|
|
|
|
async exportData(): Promise<void> {
|
|
await this.backup.exportData(() => this.waitForUiFeedbackFrame());
|
|
}
|
|
|
|
importData(): void {
|
|
this.backup.importData(() => this.form.hydrateFromStore());
|
|
}
|
|
|
|
removeAll(): void {
|
|
this.playlistReset.confirmAndRemoveAll(() =>
|
|
this.waitForUiFeedbackFrame()
|
|
);
|
|
}
|
|
|
|
private isNavigableSection(sectionId: string): boolean {
|
|
return this.sectionNav.some((section) => section.id === sectionId);
|
|
}
|
|
|
|
/**
|
|
* Lets the browser paint the pending busy state before a long running
|
|
* task blocks the main thread.
|
|
*/
|
|
private async waitForUiFeedbackFrame(): Promise<void> {
|
|
if (typeof window.requestAnimationFrame !== 'function') {
|
|
await Promise.resolve();
|
|
return;
|
|
}
|
|
|
|
await new Promise<void>((resolve) => {
|
|
window.requestAnimationFrame(() => resolve());
|
|
});
|
|
}
|
|
}
|