mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 03:56:15 -08:00
Merge master, dedupe the kept copy, and retire superseded switches
Master had moved ten commits ahead. Two conflicts, both resolved without losing either side: the `XTREAM_PROBE_URL` handler this PR extracted into `events/stream-probe.ts` stays extracted (master added performance capture nearby but never touched that handler), and the mock-server scenario table takes the union of master's `performance` row and this branch's `multisrc` pair. Two findings fixed alongside it. Pinning the copy the route is already on makes discovery return that very row, so prepending the current source listed one stream twice — a phantom copy in the grouping and a chip that counted it. And handing the "playing" badge back to the route row left an in-flight switch valid, so a slow resolution could arrive afterwards and replace the playback the user had just started with Play, Resume or Restart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
commit
9f0a8b4f19
385 files changed
+58306
-2264
No files matched your search
@@ -0,0 +1,123 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { PlaylistActions } from '@iptvnator/m3u-state';
|
||||
import { SettingsStore } from '@iptvnator/services';
|
||||
import {
|
||||
PLAYLIST_PARSE_BY_URL,
|
||||
type Playlist,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
RENDERER_PERFORMANCE_PHASE_HOOK_KEY,
|
||||
type RendererPerformancePhaseEvent,
|
||||
} from '@iptvnator/shared/logging';
|
||||
import { DialogService } from '@iptvnator/ui/components';
|
||||
import { ElectronService } from './electron.service';
|
||||
|
||||
describe('ElectronService performance phases', () => {
|
||||
const hookSymbol = Symbol.for(RENDERER_PERFORMANCE_PHASE_HOOK_KEY);
|
||||
const electronBridge = {
|
||||
fetchPlaylistByUrl: jest.fn(),
|
||||
onPlayerError: jest.fn(),
|
||||
};
|
||||
const store = { dispatch: jest.fn() };
|
||||
let service: ElectronService;
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, 'electron', {
|
||||
configurable: true,
|
||||
value: electronBridge,
|
||||
});
|
||||
electronBridge.fetchPlaylistByUrl.mockReset();
|
||||
electronBridge.onPlayerError.mockReset();
|
||||
store.dispatch.mockReset();
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
ElectronService,
|
||||
{
|
||||
provide: DialogService,
|
||||
useValue: { openConfirmDialog: jest.fn() },
|
||||
},
|
||||
{
|
||||
provide: MatSnackBar,
|
||||
useValue: { open: jest.fn() },
|
||||
},
|
||||
{
|
||||
provide: SettingsStore,
|
||||
useValue: {
|
||||
getTrustOptions: jest.fn(() => ({
|
||||
trustedInsecureTlsHosts: [],
|
||||
trustedPrivateNetworkEpgUrls: [],
|
||||
})),
|
||||
},
|
||||
},
|
||||
{ provide: Store, useValue: store },
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: { instant: jest.fn((key: string) => key) },
|
||||
},
|
||||
],
|
||||
});
|
||||
service = TestBed.inject(ElectronService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete (globalThis as unknown as Record<symbol, unknown>)[hookSymbol];
|
||||
Object.defineProperty(window, 'electron', {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('marks the initial URL import store dispatch without exposing playlist data', async () => {
|
||||
const events: RendererPerformancePhaseEvent[] = [];
|
||||
(
|
||||
globalThis as unknown as Record<
|
||||
symbol,
|
||||
(event: RendererPerformancePhaseEvent) => void
|
||||
>
|
||||
)[hookSymbol] = (event) => events.push(event);
|
||||
const playlist = {
|
||||
playlist: { items: [{ name: 'Sensitive channel' }] },
|
||||
title: 'Sensitive title',
|
||||
url: 'https://user:secret@example.test/list.m3u',
|
||||
} as Playlist;
|
||||
electronBridge.fetchPlaylistByUrl.mockResolvedValue(playlist);
|
||||
|
||||
await service.sendIpcEvent(PLAYLIST_PARSE_BY_URL, {
|
||||
title: playlist.title,
|
||||
url: playlist.url,
|
||||
});
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(store.dispatch).toHaveBeenCalledWith(
|
||||
PlaylistActions.handleAddingPlaylistByUrl({
|
||||
isTemporary: false,
|
||||
playlist,
|
||||
})
|
||||
);
|
||||
expect(
|
||||
events.map(({ boundary, outcome, phase }) => ({
|
||||
boundary,
|
||||
outcome,
|
||||
phase,
|
||||
}))
|
||||
).toEqual([
|
||||
{
|
||||
boundary: 'start',
|
||||
outcome: undefined,
|
||||
phase: 'store.m3u-import-dispatch',
|
||||
},
|
||||
{
|
||||
boundary: 'end',
|
||||
outcome: 'success',
|
||||
phase: 'store.m3u-import-dispatch',
|
||||
},
|
||||
]);
|
||||
expect(JSON.stringify(events)).not.toContain('Sensitive');
|
||||
expect(JSON.stringify(events)).not.toContain('secret');
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,10 @@ import {
|
||||
XTREAM_RESPONSE,
|
||||
XtreamCodeActions,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
measureRendererPerformancePhase,
|
||||
RENDERER_PERFORMANCE_PHASE,
|
||||
} from '@iptvnator/shared/logging';
|
||||
import { AppConfig } from '../../environments/environment';
|
||||
import { buildAutoUpdatePlaylistsFeedback } from './auto-update-playlists-feedback';
|
||||
import {
|
||||
@@ -316,11 +320,15 @@ export class ElectronService extends DataService {
|
||||
this.settingsStore.getTrustOptions()
|
||||
)
|
||||
.then((result) => {
|
||||
this.store.dispatch(
|
||||
PlaylistActions.handleAddingPlaylistByUrl({
|
||||
isTemporary: !!payload?.isTemporary,
|
||||
playlist: result,
|
||||
})
|
||||
measureRendererPerformancePhase(
|
||||
RENDERER_PERFORMANCE_PHASE.M3U_IMPORT_DISPATCH,
|
||||
() =>
|
||||
this.store.dispatch(
|
||||
PlaylistActions.handleAddingPlaylistByUrl({
|
||||
isTemporary: !!payload?.isTemporary,
|
||||
playlist: result,
|
||||
})
|
||||
)
|
||||
);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Theme,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { SettingsSnackbarService } from './settings-snackbar.service';
|
||||
import { SettingsStore } from '../services/settings-store.service';
|
||||
import { SettingsService } from '../services/settings.service';
|
||||
import {
|
||||
@@ -33,6 +34,7 @@ export class SettingsFormFacade {
|
||||
private readonly formBuilder = inject(FormBuilder);
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
private readonly settingsService = inject(SettingsService);
|
||||
private readonly settingsSnackbar = inject(SettingsSnackbarService);
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
private readonly translate = inject(TranslateService);
|
||||
|
||||
@@ -53,8 +55,14 @@ export class SettingsFormFacade {
|
||||
}
|
||||
|
||||
/** Waits for the persisted settings to be available */
|
||||
loadSettings(): Promise<void> {
|
||||
return this.settingsStore.loadSettings();
|
||||
async loadSettings(): Promise<void> {
|
||||
await this.settingsStore.loadSettings();
|
||||
|
||||
if (this.settingsStore.storageFailure() === 'load') {
|
||||
// The form is about to show defaults that are not the user's saved
|
||||
// values — say so instead of letting them look genuine.
|
||||
this.settingsSnackbar.storageFailure('load');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -97,7 +105,9 @@ export class SettingsFormFacade {
|
||||
}
|
||||
|
||||
this.patchAndMarkDirty({ coverSize }, 'coverSize');
|
||||
void this.settingsStore.updateSettings({ coverSize });
|
||||
this.settingsStore.updateSettings({ coverSize }).catch(() => {
|
||||
this.settingsSnackbar.storageFailure('save');
|
||||
});
|
||||
}
|
||||
|
||||
selectEpgViewMode(epgViewMode: EpgViewMode): void {
|
||||
@@ -106,7 +116,9 @@ export class SettingsFormFacade {
|
||||
}
|
||||
|
||||
this.patchAndMarkDirty({ epgViewMode }, 'epgViewMode');
|
||||
void this.settingsStore.updateSettings({ epgViewMode });
|
||||
this.settingsStore.updateSettings({ epgViewMode }).catch(() => {
|
||||
this.settingsSnackbar.storageFailure('save');
|
||||
});
|
||||
}
|
||||
|
||||
setRecordingFolder(recordingFolder: string): void {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { MatSnackBar, MatSnackBarConfig } from '@angular/material/snack-bar';
|
||||
import { SettingsStorageFailure } from '@iptvnator/services';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
|
||||
@Injectable()
|
||||
export class SettingsSnackbarService {
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly translate = inject(TranslateService);
|
||||
|
||||
open(message: string, config: MatSnackBarConfig = {}): void {
|
||||
this.snackBar.open(message, undefined, {
|
||||
@@ -14,4 +17,33 @@ export class SettingsSnackbarService {
|
||||
...config,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Failures need more reading time than the 2s confirmation toast, and a
|
||||
* dismiss action so the message can stay up until it is acknowledged.
|
||||
*/
|
||||
error(message: string, dismissLabel: string): void {
|
||||
this.snackBar.open(message, dismissLabel, {
|
||||
duration: 10000,
|
||||
horizontalPosition: 'center',
|
||||
verticalPosition: 'bottom',
|
||||
panelClass: ['settings-snackbar', 'settings-snackbar--error'],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports a failed settings read/write. Both mean the same thing to the
|
||||
* user — what is on screen will not survive a restart — so they get a
|
||||
* dismissible warning instead of the silent fallback to defaults.
|
||||
*/
|
||||
storageFailure(failure: SettingsStorageFailure): void {
|
||||
this.error(
|
||||
this.translate.instant(
|
||||
failure === 'save'
|
||||
? 'SETTINGS.SETTINGS_SAVE_FAILED'
|
||||
: 'SETTINGS.SETTINGS_LOAD_FAILED'
|
||||
),
|
||||
this.translate.instant('CLOSE')
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
|
||||
import { SettingsStore } from '../services/settings-store.service';
|
||||
import { SettingsComponent } from './settings.component';
|
||||
import {
|
||||
configureSettingsComponentTestBed,
|
||||
createElectronStub,
|
||||
createEpgBridgeStub,
|
||||
MatSnackBarStub,
|
||||
MockSettingsStore,
|
||||
stubSettingsSideEffects,
|
||||
} from './test-stubs/settings-test-harness.stub';
|
||||
|
||||
/** Matches the snackbar config used by `SettingsSnackbarService.error`. */
|
||||
const ERROR_SNACKBAR_CONFIG = expect.objectContaining({
|
||||
panelClass: ['settings-snackbar', 'settings-snackbar--error'],
|
||||
});
|
||||
|
||||
/**
|
||||
* Assertions target the MatDialog the component actually calls: spying on the
|
||||
* one returned by `TestBed.inject(MatDialog)` does not observe it.
|
||||
*/
|
||||
interface SettingsComponentPrivateTestApi {
|
||||
matDialog: MatDialog;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings live in the renderer's IndexedDB, and both halves of the round trip
|
||||
* can fail while the UI keeps looking healthy: a failed read shows defaults as
|
||||
* if they were saved, and a failed write is applied in memory so it survives
|
||||
* until the next restart. These cover the user-visible reporting of both.
|
||||
*/
|
||||
describe('SettingsComponent storage failures', () => {
|
||||
let component: SettingsComponent;
|
||||
let fixture: ComponentFixture<SettingsComponent>;
|
||||
let settingsStore: MockSettingsStore;
|
||||
let snackBar: MatSnackBarStub;
|
||||
let epgBridge: Partial<EpgRuntimeBridgeService>;
|
||||
const originalElectron = window.electron;
|
||||
const componentMatDialog = (): MatDialog =>
|
||||
(component as unknown as SettingsComponentPrivateTestApi).matDialog;
|
||||
|
||||
beforeEach(waitForAsync(() => {
|
||||
epgBridge = createEpgBridgeStub();
|
||||
configureSettingsComponentTestBed(epgBridge);
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
window.electron = createElectronStub();
|
||||
|
||||
fixture = TestBed.createComponent(SettingsComponent);
|
||||
settingsStore = TestBed.inject(
|
||||
SettingsStore
|
||||
) as unknown as MockSettingsStore;
|
||||
snackBar = TestBed.inject(MatSnackBar) as unknown as MatSnackBarStub;
|
||||
|
||||
component = fixture.componentInstance;
|
||||
stubSettingsSideEffects(component);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.electron = originalElectron;
|
||||
});
|
||||
|
||||
it('warns when the persisted settings could not be read', async () => {
|
||||
settingsStore.storageFailure.set('load');
|
||||
|
||||
await component.ngOnInit();
|
||||
|
||||
expect(snackBar.open).toHaveBeenCalledWith(
|
||||
'SETTINGS.SETTINGS_LOAD_FAILED',
|
||||
'CLOSE',
|
||||
ERROR_SNACKBAR_CONFIG
|
||||
);
|
||||
});
|
||||
|
||||
it('warns and keeps the dialog open when the settings write fails', async () => {
|
||||
settingsStore.updateSettings.mockRejectedValue(
|
||||
new Error('storage unavailable')
|
||||
);
|
||||
const closeAll = jest.spyOn(componentMatDialog(), 'closeAll');
|
||||
component.isDialog = true;
|
||||
|
||||
component.onSubmit();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(snackBar.open).toHaveBeenCalledWith(
|
||||
'SETTINGS.SETTINGS_SAVE_FAILED',
|
||||
'CLOSE',
|
||||
ERROR_SNACKBAR_CONFIG
|
||||
);
|
||||
expect(closeAll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('closes the dialog only after the settings write succeeded', async () => {
|
||||
settingsStore.updateSettings.mockResolvedValue(undefined);
|
||||
const closeAll = jest.spyOn(componentMatDialog(), 'closeAll');
|
||||
jest.spyOn(component.epg, 'fetchConfiguredEpg').mockImplementation();
|
||||
component.isDialog = true;
|
||||
|
||||
component.onSubmit();
|
||||
// save() resolves a tick after the store write: the callback and
|
||||
// the Electron mirror run first, so the close lands on the next
|
||||
// turn of the microtask queue.
|
||||
await fixture.whenStable();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(closeAll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('warns when a section write fails without leaving an unhandled rejection', async () => {
|
||||
settingsStore.updateSettings.mockRejectedValue(
|
||||
new Error('storage unavailable')
|
||||
);
|
||||
|
||||
component.form.selectCoverSize('large');
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(snackBar.open).toHaveBeenCalledWith(
|
||||
'SETTINGS.SETTINGS_SAVE_FAILED',
|
||||
'CLOSE',
|
||||
ERROR_SNACKBAR_CONFIG
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -202,11 +202,26 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
* the indexed db store
|
||||
*/
|
||||
onSubmit(): void {
|
||||
void this.form.save(() => this.applyChangedSettings());
|
||||
|
||||
if (this.isDialog) {
|
||||
this.matDialog.closeAll();
|
||||
}
|
||||
this.form
|
||||
.save(() => this.applyChangedSettings())
|
||||
.then(() => {
|
||||
if (this.isDialog) {
|
||||
this.matDialog.closeAll();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// The store already applied the change in memory, so without
|
||||
// this the save looks successful until the next restart. The
|
||||
// dialog stays open 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');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -49,6 +49,7 @@ import { MockModule, MockProvider } from 'ng-mocks';
|
||||
import { NgxIndexedDBService } from 'ngx-indexed-db';
|
||||
import { from, of } from 'rxjs';
|
||||
import { ElectronServiceStub } from '../../services/electron.service.stub';
|
||||
import { SettingsStorageFailure } from '@iptvnator/services';
|
||||
import { SettingsStore } from '../../services/settings-store.service';
|
||||
import { SettingsService } from '../../services/settings.service';
|
||||
import { SettingsComponent } from '../settings.component';
|
||||
@@ -149,6 +150,8 @@ export class MockSettingsStore {
|
||||
|
||||
updateSettings = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
storageFailure = signal<SettingsStorageFailure | null>(null);
|
||||
|
||||
// Helper method for tests to modify settings
|
||||
_setSettings(newSettings: Partial<typeof DEFAULT_SETTINGS>) {
|
||||
this._settings.set({
|
||||
|
||||
@@ -290,6 +290,8 @@
|
||||
"OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "يتطلب النقر المزدوج على صفوف القنوات قبل بدء التشغيل عبر MPV أو VLC.",
|
||||
"LANGUAGE": "اللغة",
|
||||
"SETTINGS_SAVED": "نجاح! تم حفظ الإعدادات.",
|
||||
"SETTINGS_SAVE_FAILED": "تعذّر حفظ الإعدادات. ستفقد تغييراتك عند إعادة تشغيل التطبيق.",
|
||||
"SETTINGS_LOAD_FAILED": "تعذّر تحميل الإعدادات المحفوظة — يتم عرض القيم الافتراضية. تأكّد من عدم تشغيل نسخة أخرى من التطبيق.",
|
||||
"THEME": "السمة البصرية",
|
||||
"SHOW_CAPTIONS": "عرض الترجمات",
|
||||
"SHOW_DASHBOARD": "عرض لوحة التحكم",
|
||||
|
||||
@@ -290,6 +290,8 @@
|
||||
"OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "خاص دير دوبل كليك على صفوف القنوات قبل ما يبدا التشغيل بـ MPV ولا VLC.",
|
||||
"LANGUAGE": "اللغة",
|
||||
"SETTINGS_SAVED": "نجاح! الإعدادات تحفضو.",
|
||||
"SETTINGS_SAVE_FAILED": "ماتحفضاتش الإعدادات. التبديلات ديالك غادي تضيع منين تعاود تشعل التطبيق.",
|
||||
"SETTINGS_LOAD_FAILED": "ماتحملاتش الإعدادات المحفوظة — كنوريو القيم الافتراضية. تأكد بلي ماكايناش نسخة أخرى من التطبيق خدامة.",
|
||||
"THEME": "المظهر المرئي",
|
||||
"SHOW_CAPTIONS": "وري الترجمة",
|
||||
"SHOW_DASHBOARD": "وري لوحة التحكم",
|
||||
|
||||
@@ -290,6 +290,8 @@
|
||||
"OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "Патрабаваць падвойны націск па радках каналаў перад пачаткам прайгравання праз MPV або VLC.",
|
||||
"LANGUAGE": "Мова",
|
||||
"SETTINGS_SAVED": "Новыя налады былі паспяхова захаваны!",
|
||||
"SETTINGS_SAVE_FAILED": "Не ўдалося захаваць налады. Змены будуць страчаны пасля перазапуску праграмы.",
|
||||
"SETTINGS_LOAD_FAILED": "Не ўдалося загрузіць захаваныя налады — паказаны значэнні па змаўчанні. Пераканайцеся, што праграма не запушчана яшчэ раз.",
|
||||
"THEME": "Тэма афармлення",
|
||||
"SHOW_CAPTIONS": "Адлюстроўваць субтытры",
|
||||
"SHOW_DASHBOARD": "Паказаць панэль",
|
||||
|
||||
@@ -290,6 +290,8 @@
|
||||
"OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "Erfordert einen Doppelklick auf Kanalzeilen, bevor die Wiedergabe mit MPV oder VLC startet.",
|
||||
"LANGUAGE": "Sprache",
|
||||
"SETTINGS_SAVED": "Einstellungen wurden erfolgreich gespeichert!",
|
||||
"SETTINGS_SAVE_FAILED": "Einstellungen konnten nicht gespeichert werden. Die Änderungen gehen beim Neustart der App verloren.",
|
||||
"SETTINGS_LOAD_FAILED": "Gespeicherte Einstellungen konnten nicht geladen werden — es werden Standardwerte angezeigt. Stelle sicher, dass die App nicht bereits läuft.",
|
||||
"THEME": "Farbdesign",
|
||||
"SHOW_CAPTIONS": "Untertitel anzeigen",
|
||||
"SHOW_DASHBOARD": "Dashboard anzeigen",
|
||||
|
||||
@@ -290,6 +290,8 @@
|
||||
"OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "Απαιτεί διπλό κλικ στις γραμμές καναλιών πριν ξεκινήσει η αναπαραγωγή με MPV ή VLC.",
|
||||
"LANGUAGE": "Γλώσσα",
|
||||
"SETTINGS_SAVED": "Επιτυχία! Η διαμόρφωση αποθηκεύτηκε.",
|
||||
"SETTINGS_SAVE_FAILED": "Δεν ήταν δυνατή η αποθήκευση των ρυθμίσεων. Οι αλλαγές σας θα χαθούν μετά την επανεκκίνηση της εφαρμογής.",
|
||||
"SETTINGS_LOAD_FAILED": "Δεν ήταν δυνατή η φόρτωση των αποθηκευμένων ρυθμίσεων — εμφανίζονται οι προεπιλογές. Βεβαιωθείτε ότι δεν εκτελείται άλλο αντίγραφο της εφαρμογής.",
|
||||
"THEME": "Οπτικό θέμα",
|
||||
"SHOW_CAPTIONS": "Εμφάνιση υπότιτλων",
|
||||
"SHOW_DASHBOARD": "Εμφάνιση πίνακα ελέγχου",
|
||||
|
||||
@@ -290,6 +290,8 @@
|
||||
"OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "Require a double-click on channel rows before MPV or VLC playback starts.",
|
||||
"LANGUAGE": "Language",
|
||||
"SETTINGS_SAVED": "Success! Configuration was saved.",
|
||||
"SETTINGS_SAVE_FAILED": "Settings could not be saved. Your changes will be lost when the app is restarted.",
|
||||
"SETTINGS_LOAD_FAILED": "Saved settings could not be loaded — showing defaults. Make sure no other copy of the app is running.",
|
||||
"THEME": "Visual theme",
|
||||
"SHOW_CAPTIONS": "Show subtitles",
|
||||
"SHOW_DASHBOARD": "Show dashboard",
|
||||
|
||||
@@ -290,6 +290,8 @@
|
||||
"OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "Requiere doble clic en las filas de canales antes de iniciar la reproducción con MPV o VLC.",
|
||||
"LANGUAGE": "Idioma",
|
||||
"SETTINGS_SAVED": "¡Completado! La configuración se guardó.",
|
||||
"SETTINGS_SAVE_FAILED": "No se pudo guardar la configuración. Los cambios se perderán al reiniciar la aplicación.",
|
||||
"SETTINGS_LOAD_FAILED": "No se pudo cargar la configuración guardada: se muestran los valores predeterminados. Asegúrate de que no haya otra copia de la aplicación abierta.",
|
||||
"THEME": "Tema visual",
|
||||
"SHOW_CAPTIONS": "Mostrar subtítulos",
|
||||
"SHOW_DASHBOARD": "Mostrar panel",
|
||||
|
||||
@@ -290,6 +290,8 @@
|
||||
"OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "Exiger un double-clic sur les lignes de chaînes avant de lancer la lecture avec MPV ou VLC.",
|
||||
"LANGUAGE": "Langue",
|
||||
"SETTINGS_SAVED": "La configuration a été enregistrée avec succès.",
|
||||
"SETTINGS_SAVE_FAILED": "Les paramètres n'ont pas pu être enregistrés. Vos modifications seront perdues au redémarrage de l'application.",
|
||||
"SETTINGS_LOAD_FAILED": "Les paramètres enregistrés n'ont pas pu être chargés — les valeurs par défaut sont affichées. Vérifiez qu'aucune autre copie de l'application n'est ouverte.",
|
||||
"THEME": "Thème",
|
||||
"SHOW_CAPTIONS": "Afficher les sous-titres",
|
||||
"SHOW_DASHBOARD": "Afficher le tableau de bord",
|
||||
|
||||
@@ -290,6 +290,8 @@
|
||||
"OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "Az MPV- vagy VLC-lejátszás csak a csatornasorra történő dupla kattintással induljon el.",
|
||||
"LANGUAGE": "Nyelv",
|
||||
"SETTINGS_SAVED": "A beállítások mentése sikerült.",
|
||||
"SETTINGS_SAVE_FAILED": "A beállítások mentése nem sikerült. A módosítások az alkalmazás újraindításakor elvesznek.",
|
||||
"SETTINGS_LOAD_FAILED": "A mentett beállítások betöltése nem sikerült — az alapértelmezett értékek láthatók. Ellenőrizd, hogy nem fut-e az alkalmazás másik példánya.",
|
||||
"THEME": "Megjelenési téma",
|
||||
"SHOW_CAPTIONS": "Feliratok megjelenítése",
|
||||
"SHOW_DASHBOARD": "Vezérlőpult megjelenítése",
|
||||
|
||||
@@ -290,6 +290,8 @@
|
||||
"OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "Richiede un doppio clic sulle righe dei canali prima di avviare la riproduzione con MPV o VLC.",
|
||||
"LANGUAGE": "Lingua",
|
||||
"SETTINGS_SAVED": "La configurazione è stata salvata.",
|
||||
"SETTINGS_SAVE_FAILED": "Impossibile salvare le impostazioni. Le modifiche andranno perse al riavvio dell'applicazione.",
|
||||
"SETTINGS_LOAD_FAILED": "Impossibile caricare le impostazioni salvate: vengono mostrati i valori predefiniti. Assicurati che non ci sia un'altra copia dell'app in esecuzione.",
|
||||
"THEME": "Tema visuale",
|
||||
"SHOW_CAPTIONS": "Visualizza sottotitoli",
|
||||
"SHOW_DASHBOARD": "Mostra dashboard",
|
||||
|
||||
@@ -290,6 +290,8 @@
|
||||
"OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "MPV または VLC の再生を開始する前に、チャンネル行をダブルクリックする必要があります。",
|
||||
"LANGUAGE": "言語",
|
||||
"SETTINGS_SAVED": "成功!設定が保存されました。",
|
||||
"SETTINGS_SAVE_FAILED": "設定を保存できませんでした。アプリを再起動すると変更は失われます。",
|
||||
"SETTINGS_LOAD_FAILED": "保存された設定を読み込めませんでした。既定値を表示しています。アプリが二重に起動していないか確認してください。",
|
||||
"THEME": "視覚テーマ",
|
||||
"SHOW_CAPTIONS": "字幕を表示",
|
||||
"SHOW_DASHBOARD": "ダッシュボードを表示",
|
||||
|
||||
@@ -290,6 +290,8 @@
|
||||
"OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "MPV 또는 VLC 재생을 시작하기 전에 채널 행을 두 번 클릭하도록 합니다.",
|
||||
"LANGUAGE": "언어",
|
||||
"SETTINGS_SAVED": "설정이 저장되었습니다.",
|
||||
"SETTINGS_SAVE_FAILED": "설정을 저장하지 못했습니다. 앱을 다시 시작하면 변경 사항이 사라집니다.",
|
||||
"SETTINGS_LOAD_FAILED": "저장된 설정을 불러오지 못했습니다. 기본값을 표시합니다. 앱이 중복 실행되고 있지 않은지 확인하세요.",
|
||||
"THEME": "테마",
|
||||
"SHOW_CAPTIONS": "Show subtitles",
|
||||
"SHOW_DASHBOARD": "대시보드 표시",
|
||||
|
||||
@@ -290,6 +290,8 @@
|
||||
"OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "Vereist een dubbelklik op kanaalrijen voordat MPV- of VLC-weergave start.",
|
||||
"LANGUAGE": "Taal",
|
||||
"SETTINGS_SAVED": "Succes! De instellingen zijn opgeslagen.",
|
||||
"SETTINGS_SAVE_FAILED": "De instellingen konden niet worden opgeslagen. Je wijzigingen gaan verloren bij het herstarten van de app.",
|
||||
"SETTINGS_LOAD_FAILED": "De opgeslagen instellingen konden niet worden geladen — er worden standaardwaarden getoond. Controleer of de app niet al draait.",
|
||||
"THEME": "Visueel thema",
|
||||
"SHOW_CAPTIONS": "Ondertiteling weergeven",
|
||||
"SHOW_DASHBOARD": "Dashboard tonen",
|
||||
|
||||
@@ -290,6 +290,8 @@
|
||||
"OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "Wymagaj podwójnego kliknięcia wiersza kanału przed rozpoczęciem odtwarzania w MPV lub VLC.",
|
||||
"LANGUAGE": "Język",
|
||||
"SETTINGS_SAVED": "Sukces! Konfiguracja została zapisana.",
|
||||
"SETTINGS_SAVE_FAILED": "Nie udało się zapisać ustawień. Zmiany zostaną utracone po ponownym uruchomieniu aplikacji.",
|
||||
"SETTINGS_LOAD_FAILED": "Nie udało się wczytać zapisanych ustawień — wyświetlane są wartości domyślne. Upewnij się, że aplikacja nie jest już uruchomiona.",
|
||||
"THEME": "Motyw wizualny",
|
||||
"SHOW_CAPTIONS": "Pokaż napisy",
|
||||
"SHOW_DASHBOARD": "Pokaż pulpit",
|
||||
|
||||
@@ -290,6 +290,8 @@
|
||||
"OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "Exige um duplo clique nas linhas de canais antes de iniciar a reprodução no MPV ou VLC.",
|
||||
"LANGUAGE": "Idioma",
|
||||
"SETTINGS_SAVED": "Sucesso! A configuração foi salva.",
|
||||
"SETTINGS_SAVE_FAILED": "Não foi possível guardar as definições. As alterações serão perdidas ao reiniciar a aplicação.",
|
||||
"SETTINGS_LOAD_FAILED": "Não foi possível carregar as definições guardadas — a mostrar os valores predefinidos. Certifique-se de que a aplicação não está já em execução.",
|
||||
"THEME": "Tema visual",
|
||||
"SHOW_CAPTIONS": "Mostrar legendas",
|
||||
"SHOW_DASHBOARD": "Mostrar dashboard",
|
||||
|
||||
@@ -290,6 +290,8 @@
|
||||
"OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "Требовать двойной щелчок по строке канала перед запуском воспроизведения через MPV или VLC.",
|
||||
"LANGUAGE": "Язык",
|
||||
"SETTINGS_SAVED": "Новые настройки были успешно сохранены!",
|
||||
"SETTINGS_SAVE_FAILED": "Не удалось сохранить настройки. Изменения будут потеряны после перезапуска приложения.",
|
||||
"SETTINGS_LOAD_FAILED": "Не удалось загрузить сохранённые настройки — показаны значения по умолчанию. Убедитесь, что приложение не запущено ещё раз.",
|
||||
"THEME": "Тема оформления",
|
||||
"SHOW_CAPTIONS": "Отображать субтитры",
|
||||
"SHOW_DASHBOARD": "Показывать панель",
|
||||
|
||||
@@ -290,6 +290,8 @@
|
||||
"OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "MPV veya VLC oynatımı başlamadan önce kanal satırlarına çift tıklanmasını gerektirir.",
|
||||
"LANGUAGE": "Dil",
|
||||
"SETTINGS_SAVED": "Başarılı! Yapılandırma kaydedildi.",
|
||||
"SETTINGS_SAVE_FAILED": "Ayarlar kaydedilemedi. Değişiklikleriniz uygulama yeniden başlatıldığında kaybolacak.",
|
||||
"SETTINGS_LOAD_FAILED": "Kaydedilmiş ayarlar yüklenemedi — varsayılanlar gösteriliyor. Uygulamanın başka bir kopyasının çalışmadığından emin olun.",
|
||||
"THEME": "Görünüm teması",
|
||||
"SHOW_CAPTIONS": "Altyazıları göster",
|
||||
"SHOW_DASHBOARD": "Panoyu göster",
|
||||
|
||||
@@ -290,6 +290,8 @@
|
||||
"OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "在 MPV 或 VLC 开始播放前,要求双击频道行。",
|
||||
"LANGUAGE": "语言",
|
||||
"SETTINGS_SAVED": "配置已成功保存",
|
||||
"SETTINGS_SAVE_FAILED": "无法保存设置。重启应用后所做的更改将会丢失。",
|
||||
"SETTINGS_LOAD_FAILED": "无法加载已保存的设置,正在显示默认值。请确认没有重复运行的应用实例。",
|
||||
"THEME": "主题",
|
||||
"SHOW_CAPTIONS": "显示字幕",
|
||||
"SHOW_DASHBOARD": "显示仪表盘",
|
||||
|
||||
@@ -290,6 +290,8 @@
|
||||
"OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "在 MPV 或 VLC 開始播放前,要求雙擊頻道列。",
|
||||
"LANGUAGE": "語言",
|
||||
"SETTINGS_SAVED": "成功!設定已儲存。",
|
||||
"SETTINGS_SAVE_FAILED": "無法儲存設定。重新啟動應用程式後所做的變更將會遺失。",
|
||||
"SETTINGS_LOAD_FAILED": "無法載入已儲存的設定,正在顯示預設值。請確認沒有重複執行的應用程式。",
|
||||
"THEME": "視覺主題",
|
||||
"SHOW_CAPTIONS": "顯示字幕",
|
||||
"SHOW_DASHBOARD": "顯示儀表板",
|
||||
|
||||
@@ -237,6 +237,14 @@ textarea,
|
||||
margin-bottom: calc(76px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
// Failures (settings could not be saved/loaded) must not read as the usual
|
||||
// neutral confirmation toast.
|
||||
.mat-mdc-snack-bar-container.settings-snackbar--error {
|
||||
--mdc-snackbar-container-color: var(--mat-sys-error-container);
|
||||
--mdc-snackbar-supporting-text-color: var(--mat-sys-on-error-container);
|
||||
--mat-snack-bar-button-color: var(--mat-sys-on-error-container);
|
||||
}
|
||||
|
||||
// WCAG-friendly visually-hidden helper — content stays in the
|
||||
// accessibility tree and DOM query results but occupies no layout box.
|
||||
// Used where a visible heading would duplicate adjacent navigation
|
||||
|
||||
Reference in new issue
Block a user