mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 20:56:19 -08:00
feat(updater): add GitHub releases desktop updater
This commit is contained in:
1 parent
a84090026b
commit
b64e187a5e
49 files changed
+2995
-114
No files matched your search
@@ -0,0 +1,106 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import {
|
||||
ELECTRON_BRIDGE_APP_UPDATE_STATUSES,
|
||||
ElectronBridgeAppUpdateStatus,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { AppUpdateNotificationPanelComponent } from './app-update-notification-panel.component';
|
||||
import { AppUpdateReleaseNotesDialogComponent } from './settings/app-update-release-notes-dialog.component';
|
||||
|
||||
const availableStatus: ElectronBridgeAppUpdateStatus = {
|
||||
currentVersion: '0.22.0',
|
||||
latestVersion: '0.23.0',
|
||||
manualDownloadUrl: 'https://github.com/4gray/iptvnator/releases/latest',
|
||||
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available,
|
||||
supportedSelfUpdate: true,
|
||||
};
|
||||
|
||||
describe('AppUpdateNotificationPanelComponent', () => {
|
||||
let fixture: ComponentFixture<AppUpdateNotificationPanelComponent>;
|
||||
const originalElectron = window.electron;
|
||||
let statusHandler: ((status: ElectronBridgeAppUpdateStatus) => void) | null;
|
||||
|
||||
beforeEach(async () => {
|
||||
statusHandler = null;
|
||||
window.electron = {
|
||||
downloadAppUpdate: jest.fn().mockResolvedValue({
|
||||
...availableStatus,
|
||||
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloading,
|
||||
}),
|
||||
getAppUpdateStatus: jest.fn().mockResolvedValue({
|
||||
...availableStatus,
|
||||
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Idle,
|
||||
}),
|
||||
onAppUpdateStatusChange: jest.fn((handler) => {
|
||||
statusHandler = handler;
|
||||
return jest.fn();
|
||||
}),
|
||||
} as unknown as typeof window.electron;
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
AppUpdateNotificationPanelComponent,
|
||||
TranslateModule.forRoot(),
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: MatDialog,
|
||||
useValue: { open: jest.fn() },
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(AppUpdateNotificationPanelComponent);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.electron = originalElectron;
|
||||
});
|
||||
|
||||
it('shows a startup update notification when the main process reports an available update', () => {
|
||||
statusHandler?.(availableStatus);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('[data-test-id="app-update-notification"]')
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('opens release notes without dismissing the notification', () => {
|
||||
statusHandler?.(availableStatus);
|
||||
fixture.detectChanges();
|
||||
|
||||
(
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-test-id="app-update-notification-release-notes"]'
|
||||
) as HTMLButtonElement
|
||||
).click();
|
||||
|
||||
expect(TestBed.inject(MatDialog).open).toHaveBeenCalledWith(
|
||||
AppUpdateReleaseNotesDialogComponent,
|
||||
expect.objectContaining({
|
||||
data: { initialVersion: '0.23.0' },
|
||||
})
|
||||
);
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('[data-test-id="app-update-notification"]')
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('starts downloading the update from the notification action', async () => {
|
||||
statusHandler?.(availableStatus);
|
||||
fixture.detectChanges();
|
||||
|
||||
(
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-test-id="app-update-notification-download"]'
|
||||
) as HTMLButtonElement
|
||||
).click();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(window.electron.downloadAppUpdate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
import { Component, computed, OnDestroy, OnInit, signal, inject } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import {
|
||||
ELECTRON_BRIDGE_APP_UPDATE_STATUSES,
|
||||
ElectronBridgeAppUpdateStatus,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { AppUpdateReleaseNotesDialogComponent } from './settings/app-update-release-notes-dialog.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-update-notification-panel',
|
||||
imports: [MatButtonModule, MatIconModule, TranslatePipe],
|
||||
template: `
|
||||
@if (isVisible(); as visible) {
|
||||
<section
|
||||
class="app-update-notification"
|
||||
data-test-id="app-update-notification"
|
||||
>
|
||||
<header class="app-update-notification__header">
|
||||
<span>
|
||||
<mat-icon>system_update</mat-icon>
|
||||
{{ 'SETTINGS.APP_UPDATE_TITLE' | translate }}
|
||||
</span>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
(click)="dismiss()"
|
||||
[attr.aria-label]="'CLOSE' | translate"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="app-update-notification__body">
|
||||
<strong>
|
||||
{{
|
||||
labelKey()
|
||||
| translate
|
||||
: {
|
||||
version:
|
||||
status()?.latestVersion ||
|
||||
status()?.currentVersion,
|
||||
}
|
||||
}}
|
||||
</strong>
|
||||
@if (status()?.progress; as progress) {
|
||||
<progress [value]="progress.percent" max="100"></progress>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="app-update-notification__actions">
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
(click)="openReleaseNotes()"
|
||||
data-test-id="app-update-notification-release-notes"
|
||||
>
|
||||
<mat-icon>article</mat-icon>
|
||||
{{ 'SETTINGS.APP_UPDATE_RELEASE_NOTES' | translate }}
|
||||
</button>
|
||||
|
||||
@if (status()?.status === appUpdateStatuses.Downloaded) {
|
||||
<button
|
||||
mat-flat-button
|
||||
type="button"
|
||||
(click)="installUpdate()"
|
||||
data-test-id="app-update-notification-install"
|
||||
>
|
||||
<mat-icon>restart_alt</mat-icon>
|
||||
{{ 'SETTINGS.APP_UPDATE_INSTALL' | translate }}
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
mat-flat-button
|
||||
type="button"
|
||||
[disabled]="
|
||||
status()?.status === appUpdateStatuses.Downloading
|
||||
"
|
||||
(click)="downloadUpdate()"
|
||||
data-test-id="app-update-notification-download"
|
||||
>
|
||||
<mat-icon>download</mat-icon>
|
||||
{{ 'SETTINGS.APP_UPDATE_DOWNLOAD' | translate }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.app-update-notification {
|
||||
position: fixed;
|
||||
right: 20px;
|
||||
bottom: 20px;
|
||||
width: 320px;
|
||||
max-width: calc(100vw - 40px);
|
||||
z-index: 901;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--app-separator);
|
||||
border-radius: 12px;
|
||||
background: var(--app-widget-bg);
|
||||
box-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 0.08),
|
||||
0 8px 24px rgba(0, 0, 0, 0.18),
|
||||
0 16px 48px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.app-update-notification__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 8px 8px 12px;
|
||||
border-bottom: 1px solid var(--app-separator);
|
||||
}
|
||||
|
||||
.app-update-notification__header span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--app-heading-color);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.app-update-notification__header mat-icon {
|
||||
color: var(--app-selection-color);
|
||||
}
|
||||
|
||||
.app-update-notification__body {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
color: var(--app-body-color);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.app-update-notification__body progress {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-update-notification__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
padding: 0 12px 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.app-update-notification {
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
left: 12px;
|
||||
width: auto;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class AppUpdateNotificationPanelComponent implements OnInit, OnDestroy {
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private unsubscribeStatus: (() => void) | null = null;
|
||||
|
||||
readonly appUpdateStatuses = ELECTRON_BRIDGE_APP_UPDATE_STATUSES;
|
||||
readonly status = signal<ElectronBridgeAppUpdateStatus | null>(null);
|
||||
readonly dismissedVersion = signal<string | null>(null);
|
||||
readonly labelKey = computed(() =>
|
||||
this.status()?.status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded
|
||||
? 'SETTINGS.APP_UPDATE_DOWNLOADED'
|
||||
: 'SETTINGS.APP_UPDATE_AVAILABLE'
|
||||
);
|
||||
readonly isVisible = computed(() => {
|
||||
const status = this.status();
|
||||
|
||||
if (!status?.latestVersion) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.dismissedVersion() === status.latestVersion) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
status.status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available ||
|
||||
status.status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloading ||
|
||||
status.status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded
|
||||
);
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
if (!window.electron?.getAppUpdateStatus) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.unsubscribeStatus =
|
||||
window.electron.onAppUpdateStatusChange?.((status) => {
|
||||
this.status.set(status);
|
||||
}) ?? null;
|
||||
|
||||
void window.electron.getAppUpdateStatus().then((status) => {
|
||||
this.status.set(status);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.unsubscribeStatus?.();
|
||||
this.unsubscribeStatus = null;
|
||||
}
|
||||
|
||||
openReleaseNotes(): void {
|
||||
const latestVersion = this.status()?.latestVersion;
|
||||
|
||||
this.dialog.open(AppUpdateReleaseNotesDialogComponent, {
|
||||
autoFocus: false,
|
||||
data: {
|
||||
initialVersion: latestVersion,
|
||||
},
|
||||
maxWidth: 'calc(100vw - 32px)',
|
||||
restoreFocus: true,
|
||||
width: '720px',
|
||||
});
|
||||
}
|
||||
|
||||
async downloadUpdate(): Promise<void> {
|
||||
const status = this.status();
|
||||
|
||||
if (!status) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!status.supportedSelfUpdate) {
|
||||
window.open(status.manualDownloadUrl, '_blank', 'noreferrer');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.electron?.downloadAppUpdate) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.status.set(await window.electron.downloadAppUpdate());
|
||||
}
|
||||
|
||||
async installUpdate(): Promise<void> {
|
||||
if (!window.electron?.installAppUpdate) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.status.set(await window.electron.installAppUpdate());
|
||||
}
|
||||
|
||||
dismiss(): void {
|
||||
this.dismissedVersion.set(this.status()?.latestVersion ?? null);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
<app-epg-progress-panel />
|
||||
|
||||
<app-update-notification-panel />
|
||||
|
||||
@if (usesCustomWindowControls) {
|
||||
<app-window-controls />
|
||||
}
|
||||
@@ -28,13 +28,19 @@ import {
|
||||
createDevLogger,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { SettingsService } from './services/settings.service';
|
||||
import { AppUpdateNotificationPanelComponent } from './app-update-notification-panel.component';
|
||||
|
||||
const debugAppComponent = createDevLogger('AppComponent');
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
templateUrl: './app.component.html',
|
||||
imports: [EpgProgressPanelComponent, RouterOutlet, WindowControlsComponent],
|
||||
imports: [
|
||||
AppUpdateNotificationPanelComponent,
|
||||
EpgProgressPanelComponent,
|
||||
RouterOutlet,
|
||||
WindowControlsComponent,
|
||||
],
|
||||
})
|
||||
export class AppComponent implements OnInit {
|
||||
@HostBinding('class.macos-platform') get isMacOS() {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { AppUpdateReleaseNotesDialogComponent } from './app-update-release-notes-dialog.component';
|
||||
|
||||
const releaseNotes = {
|
||||
bodyMarkdown: '## v0.23.0\n\n- Added **desktop updater**',
|
||||
hasNext: false,
|
||||
hasPrevious: true,
|
||||
htmlUrl: 'https://github.com/4gray/iptvnator/releases/tag/v0.23.0',
|
||||
publishedAt: '2026-06-28T00:00:00.000Z',
|
||||
releaseName: 'v0.23.0',
|
||||
tagName: 'v0.23.0',
|
||||
version: '0.23.0',
|
||||
};
|
||||
|
||||
describe('AppUpdateReleaseNotesDialogComponent', () => {
|
||||
let fixture: ComponentFixture<AppUpdateReleaseNotesDialogComponent>;
|
||||
const originalElectron = window.electron;
|
||||
|
||||
beforeEach(async () => {
|
||||
window.electron = {
|
||||
getAppUpdateReleaseNotes: jest.fn().mockResolvedValue(releaseNotes),
|
||||
} as unknown as typeof window.electron;
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
AppUpdateReleaseNotesDialogComponent,
|
||||
TranslateModule.forRoot(),
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: MAT_DIALOG_DATA,
|
||||
useValue: { initialVersion: '0.23.0' },
|
||||
},
|
||||
{
|
||||
provide: MatDialogRef,
|
||||
useValue: { close: jest.fn() },
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(AppUpdateReleaseNotesDialogComponent);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.electron = originalElectron;
|
||||
});
|
||||
|
||||
it('loads and renders markdown release notes for the initial version', async () => {
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(window.electron.getAppUpdateReleaseNotes).toHaveBeenCalledWith({
|
||||
version: '0.23.0',
|
||||
});
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('[data-test-id="release-notes-body"] h2')
|
||||
?.textContent
|
||||
).toContain('v0.23.0');
|
||||
expect(
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-test-id="release-notes-body"] strong'
|
||||
)?.textContent
|
||||
).toContain('desktop updater');
|
||||
});
|
||||
|
||||
it('loads previous notes lazily without closing the dialog', async () => {
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
(
|
||||
window.electron.getAppUpdateReleaseNotes as jest.Mock
|
||||
).mockResolvedValueOnce({
|
||||
...releaseNotes,
|
||||
bodyMarkdown: '## v0.22.0\n\nOlder release',
|
||||
hasNext: true,
|
||||
hasPrevious: false,
|
||||
tagName: 'v0.22.0',
|
||||
version: '0.22.0',
|
||||
});
|
||||
|
||||
(
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-test-id="release-notes-previous"]'
|
||||
) as HTMLButtonElement
|
||||
).click();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(window.electron.getAppUpdateReleaseNotes).toHaveBeenLastCalledWith({
|
||||
direction: 'previous',
|
||||
version: 'v0.23.0',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
OnInit,
|
||||
SecurityContext,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import {
|
||||
MAT_DIALOG_DATA,
|
||||
MatDialogModule,
|
||||
MatDialogRef,
|
||||
} from '@angular/material/dialog';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { DomSanitizer } from '@angular/platform-browser';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { marked } from 'marked';
|
||||
import {
|
||||
ElectronBridgeAppUpdateReleaseNotes,
|
||||
ElectronBridgeAppUpdateReleaseNotesDirection,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
export interface AppUpdateReleaseNotesDialogData {
|
||||
initialVersion?: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-update-release-notes-dialog',
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
MatDialogModule,
|
||||
MatIconModule,
|
||||
MatProgressSpinnerModule,
|
||||
DatePipe,
|
||||
TranslatePipe,
|
||||
],
|
||||
template: `
|
||||
<h2 mat-dialog-title>
|
||||
{{ 'SETTINGS.APP_UPDATE_RELEASE_NOTES' | translate }}
|
||||
</h2>
|
||||
|
||||
<mat-dialog-content class="release-notes-dialog mat-typography">
|
||||
<div class="release-notes-dialog__toolbar">
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
[disabled]="loading() || !notes()?.hasPrevious"
|
||||
(click)="load('previous')"
|
||||
data-test-id="release-notes-previous"
|
||||
>
|
||||
<mat-icon>chevron_left</mat-icon>
|
||||
</button>
|
||||
|
||||
<div class="release-notes-dialog__version">
|
||||
<strong>{{ notes()?.releaseName || notes()?.tagName }}</strong>
|
||||
@if (notes()?.publishedAt; as publishedAt) {
|
||||
<span>{{ publishedAt | date: 'mediumDate' }}</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
[disabled]="loading() || !notes()?.hasNext"
|
||||
(click)="load('next')"
|
||||
data-test-id="release-notes-next"
|
||||
>
|
||||
<mat-icon>chevron_right</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="release-notes-dialog__loading">
|
||||
<mat-spinner diameter="28" />
|
||||
</div>
|
||||
} @else if (error()) {
|
||||
<p class="release-notes-dialog__error">{{ error() }}</p>
|
||||
} @else {
|
||||
<article
|
||||
class="release-notes-dialog__body"
|
||||
data-test-id="release-notes-body"
|
||||
[innerHTML]="renderedMarkdown()"
|
||||
></article>
|
||||
}
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
@if (notes()?.htmlUrl; as htmlUrl) {
|
||||
<button mat-button type="button" (click)="openRelease(htmlUrl)">
|
||||
<mat-icon>open_in_new</mat-icon>
|
||||
{{ 'SETTINGS.APP_UPDATE_OPEN_RELEASE' | translate }}
|
||||
</button>
|
||||
}
|
||||
<button mat-flat-button type="button" (click)="close()">
|
||||
{{ 'CLOSE' | translate }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.release-notes-dialog {
|
||||
min-width: min(640px, calc(100vw - 64px));
|
||||
max-width: min(760px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
.release-notes-dialog__toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: 40px minmax(0, 1fr) 40px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 0 12px;
|
||||
border-bottom: 1px solid var(--app-separator);
|
||||
}
|
||||
|
||||
.release-notes-dialog__version {
|
||||
min-width: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.release-notes-dialog__version strong,
|
||||
.release-notes-dialog__version span {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.release-notes-dialog__version span {
|
||||
margin-top: 2px;
|
||||
color: var(--app-muted-color);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.release-notes-dialog__loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.release-notes-dialog__error {
|
||||
color: var(--app-error-color, #ef4444);
|
||||
}
|
||||
|
||||
.release-notes-dialog__body {
|
||||
max-height: min(58vh, 560px);
|
||||
overflow: auto;
|
||||
padding: 12px 2px 0;
|
||||
color: var(--app-body-color);
|
||||
}
|
||||
|
||||
.release-notes-dialog__body h1,
|
||||
.release-notes-dialog__body h2,
|
||||
.release-notes-dialog__body h3 {
|
||||
color: var(--app-heading-color);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.release-notes-dialog__body h1 {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.release-notes-dialog__body h2 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.release-notes-dialog__body h3 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.release-notes-dialog__body code {
|
||||
border-radius: 4px;
|
||||
padding: 1px 4px;
|
||||
background: var(--app-hover-overlay);
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class AppUpdateReleaseNotesDialogComponent implements OnInit {
|
||||
private readonly data = inject<AppUpdateReleaseNotesDialogData>(
|
||||
MAT_DIALOG_DATA
|
||||
);
|
||||
private readonly dialogRef = inject(
|
||||
MatDialogRef<AppUpdateReleaseNotesDialogComponent>
|
||||
);
|
||||
private readonly sanitizer = inject(DomSanitizer);
|
||||
|
||||
readonly loading = signal(false);
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly notes = signal<ElectronBridgeAppUpdateReleaseNotes | null>(null);
|
||||
readonly renderedMarkdown = computed(() => {
|
||||
const markdown = this.notes()?.bodyMarkdown ?? '';
|
||||
const html = marked.parse(markdown, { async: false }) as string;
|
||||
|
||||
return this.sanitizer.sanitize(SecurityContext.HTML, html) ?? '';
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
void this.load();
|
||||
}
|
||||
|
||||
async load(
|
||||
direction?: ElectronBridgeAppUpdateReleaseNotesDirection
|
||||
): Promise<void> {
|
||||
if (!window.electron?.getAppUpdateReleaseNotes) {
|
||||
this.error.set('Release notes are not available in this build.');
|
||||
return;
|
||||
}
|
||||
|
||||
const version = direction
|
||||
? this.notes()?.tagName
|
||||
: this.data.initialVersion;
|
||||
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
|
||||
try {
|
||||
this.notes.set(
|
||||
await window.electron.getAppUpdateReleaseNotes({
|
||||
direction,
|
||||
version,
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
this.error.set(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
openRelease(url: string): void {
|
||||
window.open(url, '_blank', 'noreferrer');
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,99 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (isDesktop() && appUpdateStatus(); as updateStatus) {
|
||||
<div class="setting-item setting-item--full-control app-update-panel">
|
||||
<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" data-test-id="app-update-status">
|
||||
<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>
|
||||
}
|
||||
</div>
|
||||
<div class="app-update-actions">
|
||||
<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>{{
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import {
|
||||
ELECTRON_BRIDGE_APP_UPDATE_STATUSES,
|
||||
ElectronBridgeAppUpdateStatus,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { SettingsAboutSectionComponent } from './settings-about-section.component';
|
||||
|
||||
function getButton(fixture: ComponentFixture<SettingsAboutSectionComponent>, id: string) {
|
||||
return fixture.nativeElement.querySelector(
|
||||
`[data-test-id="${id}"]`
|
||||
) as HTMLButtonElement | null;
|
||||
}
|
||||
|
||||
function configureComponent(
|
||||
fixture: ComponentFixture<SettingsAboutSectionComponent>,
|
||||
status: ElectronBridgeAppUpdateStatus
|
||||
) {
|
||||
fixture.componentRef.setInput('activeSection', 'about');
|
||||
fixture.componentRef.setInput('isDesktop', true);
|
||||
fixture.componentRef.setInput('version', '0.22.0');
|
||||
fixture.componentRef.setInput('updateMessage', '');
|
||||
fixture.componentRef.setInput('appUpdateStatus', status);
|
||||
fixture.detectChanges();
|
||||
}
|
||||
|
||||
describe('SettingsAboutSectionComponent app updates', () => {
|
||||
let fixture: ComponentFixture<SettingsAboutSectionComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
SettingsAboutSectionComponent,
|
||||
TranslateModule.forRoot(),
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(SettingsAboutSectionComponent);
|
||||
});
|
||||
|
||||
it('emits a download request when a supported update is available', () => {
|
||||
const download = jest.fn();
|
||||
fixture.componentInstance.downloadAppUpdate.subscribe(download);
|
||||
const openNotes = jest.fn();
|
||||
fixture.componentInstance.openAppUpdateReleaseNotes.subscribe(openNotes);
|
||||
configureComponent(fixture, {
|
||||
currentVersion: '0.22.0',
|
||||
latestVersion: '0.23.0',
|
||||
manualDownloadUrl:
|
||||
'https://github.com/4gray/iptvnator/releases/latest',
|
||||
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available,
|
||||
supportedSelfUpdate: true,
|
||||
});
|
||||
|
||||
getButton(fixture, 'app-update-download')?.click();
|
||||
|
||||
expect(getButton(fixture, 'app-update-download')).toBeTruthy();
|
||||
expect(download).toHaveBeenCalledTimes(1);
|
||||
|
||||
getButton(fixture, 'app-update-release-notes')?.click();
|
||||
|
||||
expect(getButton(fixture, 'app-update-release-notes')).toBeTruthy();
|
||||
expect(openNotes).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('emits an install request after the update has downloaded', () => {
|
||||
const install = jest.fn();
|
||||
fixture.componentInstance.installAppUpdate.subscribe(install);
|
||||
configureComponent(fixture, {
|
||||
currentVersion: '0.22.0',
|
||||
latestVersion: '0.23.0',
|
||||
manualDownloadUrl:
|
||||
'https://github.com/4gray/iptvnator/releases/latest',
|
||||
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded,
|
||||
supportedSelfUpdate: true,
|
||||
});
|
||||
|
||||
getButton(fixture, 'app-update-install')?.click();
|
||||
|
||||
expect(getButton(fixture, 'app-update-install')).toBeTruthy();
|
||||
expect(install).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('emits a manual release request for unsupported Linux packages', () => {
|
||||
const openManual = jest.fn();
|
||||
fixture.componentInstance.openManualAppUpdate.subscribe(openManual);
|
||||
configureComponent(fixture, {
|
||||
currentVersion: '0.22.0',
|
||||
latestVersion: '0.23.0',
|
||||
manualDownloadUrl:
|
||||
'https://github.com/4gray/iptvnator/releases/latest',
|
||||
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Unsupported,
|
||||
supportedSelfUpdate: false,
|
||||
});
|
||||
|
||||
getButton(fixture, 'app-update-open-release')?.click();
|
||||
|
||||
expect(getButton(fixture, 'app-update-open-release')).toBeTruthy();
|
||||
expect(openManual).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,21 @@
|
||||
import { Component, input, ViewEncapsulation } from '@angular/core';
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
output,
|
||||
ViewEncapsulation,
|
||||
} from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import {
|
||||
ELECTRON_BRIDGE_APP_UPDATE_STATUSES,
|
||||
ElectronBridgeAppUpdateStatus,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings-about-section',
|
||||
imports: [MatIconModule, TranslateModule],
|
||||
imports: [MatButtonModule, MatIconModule, TranslateModule],
|
||||
templateUrl: './settings-about-section.component.html',
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
styles: [':host { display: contents; }'],
|
||||
@@ -14,4 +25,83 @@ export class SettingsAboutSectionComponent {
|
||||
readonly isDesktop = input(false);
|
||||
readonly version = input<string | undefined>();
|
||||
readonly updateMessage = input<string | undefined>();
|
||||
readonly appUpdateStatus = input<ElectronBridgeAppUpdateStatus | null>(null);
|
||||
|
||||
readonly checkForAppUpdate = output<void>();
|
||||
readonly downloadAppUpdate = output<void>();
|
||||
readonly installAppUpdate = output<void>();
|
||||
readonly openManualAppUpdate = output<void>();
|
||||
readonly openAppUpdateReleaseNotes = output<void>();
|
||||
|
||||
readonly isAppUpdateBusy = computed(() => {
|
||||
const status = this.appUpdateStatus()?.status;
|
||||
|
||||
return (
|
||||
status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Checking ||
|
||||
status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloading
|
||||
);
|
||||
});
|
||||
|
||||
readonly canDownloadAppUpdate = computed(() => {
|
||||
const status = this.appUpdateStatus();
|
||||
|
||||
return (
|
||||
status?.supportedSelfUpdate === true &&
|
||||
status.status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available
|
||||
);
|
||||
});
|
||||
|
||||
readonly canInstallAppUpdate = computed(() => {
|
||||
const status = this.appUpdateStatus();
|
||||
|
||||
return (
|
||||
status?.supportedSelfUpdate === true &&
|
||||
status.status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded
|
||||
);
|
||||
});
|
||||
|
||||
readonly canOpenAppUpdateReleaseNotes = computed(() => {
|
||||
const status = this.appUpdateStatus();
|
||||
|
||||
return Boolean(
|
||||
status?.latestVersion &&
|
||||
status.status !== ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Idle &&
|
||||
status.status !==
|
||||
ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Checking &&
|
||||
status.status !==
|
||||
ELECTRON_BRIDGE_APP_UPDATE_STATUSES.NotAvailable &&
|
||||
status.status !== ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Error
|
||||
);
|
||||
});
|
||||
|
||||
readonly canOpenManualAppUpdate = computed(() => {
|
||||
const status = this.appUpdateStatus();
|
||||
|
||||
return Boolean(status && !status.supportedSelfUpdate);
|
||||
});
|
||||
|
||||
readonly appUpdateStatusLabelKey = computed(() => {
|
||||
const status =
|
||||
this.appUpdateStatus()?.status ??
|
||||
ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Idle;
|
||||
|
||||
switch (status) {
|
||||
case ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Checking:
|
||||
return 'SETTINGS.APP_UPDATE_CHECKING';
|
||||
case ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available:
|
||||
return 'SETTINGS.APP_UPDATE_AVAILABLE';
|
||||
case ELECTRON_BRIDGE_APP_UPDATE_STATUSES.NotAvailable:
|
||||
return 'SETTINGS.APP_UPDATE_NOT_AVAILABLE';
|
||||
case ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloading:
|
||||
return 'SETTINGS.APP_UPDATE_DOWNLOADING';
|
||||
case ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded:
|
||||
return 'SETTINGS.APP_UPDATE_DOWNLOADED';
|
||||
case ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Unsupported:
|
||||
return 'SETTINGS.APP_UPDATE_MANUAL_AVAILABLE';
|
||||
case ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Error:
|
||||
return 'SETTINGS.APP_UPDATE_ERROR';
|
||||
default:
|
||||
return 'SETTINGS.APP_UPDATE_IDLE';
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -105,6 +105,12 @@
|
||||
[isDesktop]="isDesktop"
|
||||
[version]="version"
|
||||
[updateMessage]="updateMessage"
|
||||
[appUpdateStatus]="appUpdateStatus()"
|
||||
(checkForAppUpdate)="checkForAppUpdate()"
|
||||
(downloadAppUpdate)="downloadAppUpdate()"
|
||||
(installAppUpdate)="installAppUpdate()"
|
||||
(openManualAppUpdate)="openManualAppUpdate()"
|
||||
(openAppUpdateReleaseNotes)="openAppUpdateReleaseNotes()"
|
||||
/>
|
||||
|
||||
<footer class="action-bar" data-test-id="settings-action-bar">
|
||||
|
||||
@@ -203,6 +203,47 @@
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.app-update-panel__control {
|
||||
align-items: stretch;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.app-update-status {
|
||||
min-width: min(100%, 260px);
|
||||
display: flex;
|
||||
flex: 1 1 280px;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
color: var(--settings-muted);
|
||||
font-size: 0.84rem;
|
||||
line-height: 1.45;
|
||||
|
||||
strong {
|
||||
color: var(--app-body-color);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
progress {
|
||||
width: min(100%, 320px);
|
||||
height: 8px;
|
||||
accent-color: var(--app-selection-color);
|
||||
}
|
||||
}
|
||||
|
||||
.app-update-actions {
|
||||
display: flex;
|
||||
flex: 0 1 auto;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
|
||||
button {
|
||||
app-region: no-drag;
|
||||
}
|
||||
}
|
||||
|
||||
.setting-item--external-player-arguments {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
|
||||
@@ -35,6 +35,8 @@ import {
|
||||
} from '@iptvnator/services';
|
||||
import {
|
||||
EmbeddedMpvSupport,
|
||||
ELECTRON_BRIDGE_APP_UPDATE_STATUSES,
|
||||
ElectronBridgeAppUpdateStatus,
|
||||
Language,
|
||||
PlaylistMeta,
|
||||
StartupBehavior,
|
||||
@@ -43,6 +45,7 @@ import {
|
||||
VideoPlayer,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { SettingsComponent } from './settings.component';
|
||||
import { AppUpdateReleaseNotesDialogComponent } from './app-update-release-notes-dialog.component';
|
||||
|
||||
import { signal } from '@angular/core';
|
||||
import { SettingsContextService } from '@iptvnator/workspace/shell/util';
|
||||
@@ -103,6 +106,13 @@ const DEFAULT_SETTINGS = {
|
||||
preferUploadedEpgOverXtream: false,
|
||||
};
|
||||
|
||||
const DEFAULT_APP_UPDATE_STATUS: ElectronBridgeAppUpdateStatus = {
|
||||
currentVersion: '0.22.0',
|
||||
manualDownloadUrl: 'https://github.com/4gray/iptvnator/releases/latest',
|
||||
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Idle,
|
||||
supportedSelfUpdate: true,
|
||||
};
|
||||
|
||||
class MockSettingsStore {
|
||||
private _settings = signal(DEFAULT_SETTINGS);
|
||||
|
||||
@@ -300,9 +310,22 @@ describe('SettingsComponent', () => {
|
||||
fetchEpg: jest.fn().mockResolvedValue({ success: true }),
|
||||
forceFetchEpg: jest.fn().mockResolvedValue({ success: true }),
|
||||
getAppVersion: jest.fn().mockResolvedValue('1.0.0'),
|
||||
getAppUpdateStatus: jest
|
||||
.fn()
|
||||
.mockResolvedValue(DEFAULT_APP_UPDATE_STATUS),
|
||||
getChannelPrograms: jest.fn().mockResolvedValue([]),
|
||||
getEpgChannelsByRange: jest.fn().mockResolvedValue([]),
|
||||
getLocalIpAddresses: jest.fn().mockResolvedValue([]),
|
||||
checkForAppUpdate: jest
|
||||
.fn()
|
||||
.mockResolvedValue(DEFAULT_APP_UPDATE_STATUS),
|
||||
downloadAppUpdate: jest
|
||||
.fn()
|
||||
.mockResolvedValue(DEFAULT_APP_UPDATE_STATUS),
|
||||
installAppUpdate: jest
|
||||
.fn()
|
||||
.mockResolvedValue(DEFAULT_APP_UPDATE_STATUS),
|
||||
onAppUpdateStatusChange: jest.fn(() => jest.fn()),
|
||||
openInMpv: jest.fn(),
|
||||
openInVlc: jest.fn(),
|
||||
platform: 'linux',
|
||||
@@ -355,6 +378,73 @@ describe('SettingsComponent', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('loads the desktop app update status and subscribes to status pushes', async () => {
|
||||
const pushedStatus: ElectronBridgeAppUpdateStatus = {
|
||||
...DEFAULT_APP_UPDATE_STATUS,
|
||||
latestVersion: '0.23.0',
|
||||
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available,
|
||||
};
|
||||
|
||||
await fixture.whenStable();
|
||||
const statusHandler = (
|
||||
window.electron.onAppUpdateStatusChange as jest.Mock
|
||||
).mock.calls[0][0] as (status: ElectronBridgeAppUpdateStatus) => void;
|
||||
statusHandler(pushedStatus);
|
||||
|
||||
expect(window.electron.getAppUpdateStatus).toHaveBeenCalledTimes(1);
|
||||
expect(window.electron.onAppUpdateStatusChange).toHaveBeenCalledTimes(1);
|
||||
expect(component.appUpdateStatus()).toEqual(pushedStatus);
|
||||
});
|
||||
|
||||
it('forwards app update actions to the desktop bridge', async () => {
|
||||
await component.checkForAppUpdate();
|
||||
await component.downloadAppUpdate();
|
||||
await component.installAppUpdate();
|
||||
|
||||
expect(window.electron.checkForAppUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(window.electron.downloadAppUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(window.electron.installAppUpdate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('opens the manual release URL from unsupported update status', () => {
|
||||
const openSpy = jest.spyOn(window, 'open').mockReturnValue(null);
|
||||
component.appUpdateStatus.set({
|
||||
...DEFAULT_APP_UPDATE_STATUS,
|
||||
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Unsupported,
|
||||
supportedSelfUpdate: false,
|
||||
});
|
||||
|
||||
component.openManualAppUpdate();
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
DEFAULT_APP_UPDATE_STATUS.manualDownloadUrl,
|
||||
'_blank',
|
||||
'noreferrer'
|
||||
);
|
||||
});
|
||||
|
||||
it('opens release notes dialog for the latest update version', () => {
|
||||
const openSpy = jest
|
||||
.spyOn(privateApi(component).matDialog, 'open')
|
||||
.mockReturnValue(createDialogRef(false));
|
||||
component.appUpdateStatus.set({
|
||||
...DEFAULT_APP_UPDATE_STATUS,
|
||||
latestVersion: '0.23.0',
|
||||
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available,
|
||||
});
|
||||
|
||||
component.openAppUpdateReleaseNotes();
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
AppUpdateReleaseNotesDialogComponent,
|
||||
expect.objectContaining({
|
||||
data: {
|
||||
initialVersion: '0.23.0',
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should render a compact page header outside dialog mode', () => {
|
||||
const nativeElement = fixture.nativeElement as HTMLElement;
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ import { DataService, RuntimeCapabilitiesService } from '@iptvnator/services';
|
||||
import {
|
||||
EmbeddedMpvSupport,
|
||||
CoverSize,
|
||||
ElectronBridgeAppUpdateStatus,
|
||||
Language,
|
||||
StreamFormat,
|
||||
Theme,
|
||||
@@ -82,6 +83,7 @@ import {
|
||||
buildSettingsPlaylistDeleteSummary,
|
||||
} from './settings-playlist-summary.utils';
|
||||
import { SettingsSnackbarService } from './settings-snackbar.service';
|
||||
import { AppUpdateReleaseNotesDialogComponent } from './app-update-release-notes-dialog.component';
|
||||
|
||||
@Component({
|
||||
templateUrl: './settings.component.html',
|
||||
@@ -186,6 +188,9 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
|
||||
/** Update message to show */
|
||||
updateMessage = '';
|
||||
readonly appUpdateStatus = signal<ElectronBridgeAppUpdateStatus | null>(
|
||||
null
|
||||
);
|
||||
|
||||
/** EPG availability flag */
|
||||
epgAvailable$ = this.store.select(selectIsEpgAvailable);
|
||||
@@ -228,6 +233,8 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
this.playlistDeleteSummary().total > 0
|
||||
);
|
||||
|
||||
private unsubscribeAppUpdateStatus: (() => void) | null = null;
|
||||
|
||||
readonly removeAllProgressLabel = computed(() => {
|
||||
return buildRemoveAllProgressLabel({
|
||||
isRemovingAllPlaylists: this.isRemovingAllPlaylists(),
|
||||
@@ -251,6 +258,8 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
this.bindDashboardControlsEnabledState();
|
||||
void this.loadEmbeddedMpvSupport();
|
||||
this.checkAppVersion();
|
||||
this.bindAppUpdateStatusEvents();
|
||||
void this.loadAppUpdateStatus();
|
||||
void this.fetchLocalIpAddresses();
|
||||
|
||||
if (!this.isDialog) {
|
||||
@@ -291,9 +300,78 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.unsubscribeAppUpdateStatus?.();
|
||||
this.unsubscribeAppUpdateStatus = null;
|
||||
this.settingsCtx.reset();
|
||||
}
|
||||
|
||||
private bindAppUpdateStatusEvents(): void {
|
||||
if (!this.isDesktop || !window.electron?.onAppUpdateStatusChange) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.unsubscribeAppUpdateStatus =
|
||||
window.electron.onAppUpdateStatusChange((status) => {
|
||||
this.appUpdateStatus.set(status);
|
||||
});
|
||||
}
|
||||
|
||||
private async loadAppUpdateStatus(): Promise<void> {
|
||||
if (!this.isDesktop || !window.electron?.getAppUpdateStatus) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.appUpdateStatus.set(await window.electron.getAppUpdateStatus());
|
||||
}
|
||||
|
||||
async checkForAppUpdate(): Promise<void> {
|
||||
if (!this.isDesktop || !window.electron?.checkForAppUpdate) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.appUpdateStatus.set(await window.electron.checkForAppUpdate());
|
||||
}
|
||||
|
||||
async downloadAppUpdate(): Promise<void> {
|
||||
if (!this.isDesktop || !window.electron?.downloadAppUpdate) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.appUpdateStatus.set(await window.electron.downloadAppUpdate());
|
||||
}
|
||||
|
||||
async installAppUpdate(): Promise<void> {
|
||||
if (!this.isDesktop || !window.electron?.installAppUpdate) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.appUpdateStatus.set(await window.electron.installAppUpdate());
|
||||
}
|
||||
|
||||
openManualAppUpdate(): void {
|
||||
const manualDownloadUrl = this.appUpdateStatus()?.manualDownloadUrl;
|
||||
|
||||
if (!manualDownloadUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.open(manualDownloadUrl, '_blank', 'noreferrer');
|
||||
}
|
||||
|
||||
openAppUpdateReleaseNotes(): void {
|
||||
this.matDialog.open(AppUpdateReleaseNotesDialogComponent, {
|
||||
autoFocus: false,
|
||||
data: {
|
||||
initialVersion:
|
||||
this.appUpdateStatus()?.latestVersion ??
|
||||
this.appUpdateStatus()?.release?.version,
|
||||
},
|
||||
maxWidth: 'calc(100vw - 32px)',
|
||||
restoreFocus: true,
|
||||
width: '720px',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches local IP addresses for remote control URL display
|
||||
*/
|
||||
|
||||
@@ -395,7 +395,22 @@
|
||||
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
|
||||
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
|
||||
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.",
|
||||
"APP_UPDATE_TITLE": "Application update",
|
||||
"APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.",
|
||||
"APP_UPDATE_IDLE": "Ready to check for updates",
|
||||
"APP_UPDATE_CHECKING": "Checking for updates…",
|
||||
"APP_UPDATE_AVAILABLE": "Version {{version}} is available",
|
||||
"APP_UPDATE_NOT_AVAILABLE": "You are using the latest version",
|
||||
"APP_UPDATE_DOWNLOADING": "Downloading version {{version}}…",
|
||||
"APP_UPDATE_DOWNLOADED": "Version {{version}} is ready to install",
|
||||
"APP_UPDATE_MANUAL_AVAILABLE": "Version {{version}} is available for manual installation",
|
||||
"APP_UPDATE_ERROR": "Update check failed",
|
||||
"APP_UPDATE_CHECK": "Check again",
|
||||
"APP_UPDATE_DOWNLOAD": "Update",
|
||||
"APP_UPDATE_INSTALL": "Restart application",
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "Release notes"
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "داكن",
|
||||
|
||||
@@ -395,7 +395,22 @@
|
||||
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
|
||||
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
|
||||
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.",
|
||||
"APP_UPDATE_TITLE": "Application update",
|
||||
"APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.",
|
||||
"APP_UPDATE_IDLE": "Ready to check for updates",
|
||||
"APP_UPDATE_CHECKING": "Checking for updates…",
|
||||
"APP_UPDATE_AVAILABLE": "Version {{version}} is available",
|
||||
"APP_UPDATE_NOT_AVAILABLE": "You are using the latest version",
|
||||
"APP_UPDATE_DOWNLOADING": "Downloading version {{version}}…",
|
||||
"APP_UPDATE_DOWNLOADED": "Version {{version}} is ready to install",
|
||||
"APP_UPDATE_MANUAL_AVAILABLE": "Version {{version}} is available for manual installation",
|
||||
"APP_UPDATE_ERROR": "Update check failed",
|
||||
"APP_UPDATE_CHECK": "Check again",
|
||||
"APP_UPDATE_DOWNLOAD": "Update",
|
||||
"APP_UPDATE_INSTALL": "Restart application",
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "Release notes"
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "داكن",
|
||||
|
||||
@@ -395,7 +395,22 @@
|
||||
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
|
||||
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
|
||||
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.",
|
||||
"APP_UPDATE_TITLE": "Application update",
|
||||
"APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.",
|
||||
"APP_UPDATE_IDLE": "Ready to check for updates",
|
||||
"APP_UPDATE_CHECKING": "Checking for updates…",
|
||||
"APP_UPDATE_AVAILABLE": "Version {{version}} is available",
|
||||
"APP_UPDATE_NOT_AVAILABLE": "You are using the latest version",
|
||||
"APP_UPDATE_DOWNLOADING": "Downloading version {{version}}…",
|
||||
"APP_UPDATE_DOWNLOADED": "Version {{version}} is ready to install",
|
||||
"APP_UPDATE_MANUAL_AVAILABLE": "Version {{version}} is available for manual installation",
|
||||
"APP_UPDATE_ERROR": "Update check failed",
|
||||
"APP_UPDATE_CHECK": "Check again",
|
||||
"APP_UPDATE_DOWNLOAD": "Update",
|
||||
"APP_UPDATE_INSTALL": "Restart application",
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "Release notes"
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Цёмная",
|
||||
|
||||
@@ -395,7 +395,22 @@
|
||||
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Zeigt Schnellzugriffe auf zuletzt verwendete Playlists und Portale.",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED": "Neu auf Xtream",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Zeigt die neuesten Xtream-Filme, Serien und Kanäle.",
|
||||
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
|
||||
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.",
|
||||
"APP_UPDATE_TITLE": "Application update",
|
||||
"APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.",
|
||||
"APP_UPDATE_IDLE": "Ready to check for updates",
|
||||
"APP_UPDATE_CHECKING": "Checking for updates…",
|
||||
"APP_UPDATE_AVAILABLE": "Version {{version}} is available",
|
||||
"APP_UPDATE_NOT_AVAILABLE": "You are using the latest version",
|
||||
"APP_UPDATE_DOWNLOADING": "Downloading version {{version}}…",
|
||||
"APP_UPDATE_DOWNLOADED": "Version {{version}} is ready to install",
|
||||
"APP_UPDATE_MANUAL_AVAILABLE": "Version {{version}} is available for manual installation",
|
||||
"APP_UPDATE_ERROR": "Update check failed",
|
||||
"APP_UPDATE_CHECK": "Check again",
|
||||
"APP_UPDATE_DOWNLOAD": "Update",
|
||||
"APP_UPDATE_INSTALL": "Restart application",
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "Release notes"
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Dunkel",
|
||||
|
||||
@@ -395,7 +395,22 @@
|
||||
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
|
||||
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
|
||||
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.",
|
||||
"APP_UPDATE_TITLE": "Application update",
|
||||
"APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.",
|
||||
"APP_UPDATE_IDLE": "Ready to check for updates",
|
||||
"APP_UPDATE_CHECKING": "Checking for updates…",
|
||||
"APP_UPDATE_AVAILABLE": "Version {{version}} is available",
|
||||
"APP_UPDATE_NOT_AVAILABLE": "You are using the latest version",
|
||||
"APP_UPDATE_DOWNLOADING": "Downloading version {{version}}…",
|
||||
"APP_UPDATE_DOWNLOADED": "Version {{version}} is ready to install",
|
||||
"APP_UPDATE_MANUAL_AVAILABLE": "Version {{version}} is available for manual installation",
|
||||
"APP_UPDATE_ERROR": "Update check failed",
|
||||
"APP_UPDATE_CHECK": "Check again",
|
||||
"APP_UPDATE_DOWNLOAD": "Update",
|
||||
"APP_UPDATE_INSTALL": "Restart application",
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "Release notes"
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Σκούρο",
|
||||
|
||||
@@ -395,7 +395,22 @@
|
||||
"DASHBOARD_RAIL_SOURCES": "Recently used sources",
|
||||
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels."
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
|
||||
"APP_UPDATE_TITLE": "Application update",
|
||||
"APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.",
|
||||
"APP_UPDATE_IDLE": "Ready to check for updates",
|
||||
"APP_UPDATE_CHECKING": "Checking for updates…",
|
||||
"APP_UPDATE_AVAILABLE": "Version {{version}} is available",
|
||||
"APP_UPDATE_NOT_AVAILABLE": "You are using the latest version",
|
||||
"APP_UPDATE_DOWNLOADING": "Downloading version {{version}}…",
|
||||
"APP_UPDATE_DOWNLOADED": "Version {{version}} is ready to install",
|
||||
"APP_UPDATE_MANUAL_AVAILABLE": "Version {{version}} is available for manual installation",
|
||||
"APP_UPDATE_ERROR": "Update check failed",
|
||||
"APP_UPDATE_CHECK": "Check again",
|
||||
"APP_UPDATE_DOWNLOAD": "Update",
|
||||
"APP_UPDATE_INSTALL": "Restart application",
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "Release notes"
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Dark",
|
||||
|
||||
@@ -395,7 +395,22 @@
|
||||
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
|
||||
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
|
||||
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.",
|
||||
"APP_UPDATE_TITLE": "Application update",
|
||||
"APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.",
|
||||
"APP_UPDATE_IDLE": "Ready to check for updates",
|
||||
"APP_UPDATE_CHECKING": "Checking for updates…",
|
||||
"APP_UPDATE_AVAILABLE": "Version {{version}} is available",
|
||||
"APP_UPDATE_NOT_AVAILABLE": "You are using the latest version",
|
||||
"APP_UPDATE_DOWNLOADING": "Downloading version {{version}}…",
|
||||
"APP_UPDATE_DOWNLOADED": "Version {{version}} is ready to install",
|
||||
"APP_UPDATE_MANUAL_AVAILABLE": "Version {{version}} is available for manual installation",
|
||||
"APP_UPDATE_ERROR": "Update check failed",
|
||||
"APP_UPDATE_CHECK": "Check again",
|
||||
"APP_UPDATE_DOWNLOAD": "Update",
|
||||
"APP_UPDATE_INSTALL": "Restart application",
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "Release notes"
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Oscuro",
|
||||
|
||||
@@ -395,7 +395,22 @@
|
||||
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
|
||||
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
|
||||
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.",
|
||||
"APP_UPDATE_TITLE": "Application update",
|
||||
"APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.",
|
||||
"APP_UPDATE_IDLE": "Ready to check for updates",
|
||||
"APP_UPDATE_CHECKING": "Checking for updates…",
|
||||
"APP_UPDATE_AVAILABLE": "Version {{version}} is available",
|
||||
"APP_UPDATE_NOT_AVAILABLE": "You are using the latest version",
|
||||
"APP_UPDATE_DOWNLOADING": "Downloading version {{version}}…",
|
||||
"APP_UPDATE_DOWNLOADED": "Version {{version}} is ready to install",
|
||||
"APP_UPDATE_MANUAL_AVAILABLE": "Version {{version}} is available for manual installation",
|
||||
"APP_UPDATE_ERROR": "Update check failed",
|
||||
"APP_UPDATE_CHECK": "Check again",
|
||||
"APP_UPDATE_DOWNLOAD": "Update",
|
||||
"APP_UPDATE_INSTALL": "Restart application",
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "Release notes"
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Sombre",
|
||||
|
||||
@@ -395,7 +395,22 @@
|
||||
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
|
||||
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
|
||||
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.",
|
||||
"APP_UPDATE_TITLE": "Application update",
|
||||
"APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.",
|
||||
"APP_UPDATE_IDLE": "Ready to check for updates",
|
||||
"APP_UPDATE_CHECKING": "Checking for updates…",
|
||||
"APP_UPDATE_AVAILABLE": "Version {{version}} is available",
|
||||
"APP_UPDATE_NOT_AVAILABLE": "You are using the latest version",
|
||||
"APP_UPDATE_DOWNLOADING": "Downloading version {{version}}…",
|
||||
"APP_UPDATE_DOWNLOADED": "Version {{version}} is ready to install",
|
||||
"APP_UPDATE_MANUAL_AVAILABLE": "Version {{version}} is available for manual installation",
|
||||
"APP_UPDATE_ERROR": "Update check failed",
|
||||
"APP_UPDATE_CHECK": "Check again",
|
||||
"APP_UPDATE_DOWNLOAD": "Update",
|
||||
"APP_UPDATE_INSTALL": "Restart application",
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "Release notes"
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Scuro",
|
||||
|
||||
@@ -395,7 +395,22 @@
|
||||
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
|
||||
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
|
||||
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.",
|
||||
"APP_UPDATE_TITLE": "Application update",
|
||||
"APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.",
|
||||
"APP_UPDATE_IDLE": "Ready to check for updates",
|
||||
"APP_UPDATE_CHECKING": "Checking for updates…",
|
||||
"APP_UPDATE_AVAILABLE": "Version {{version}} is available",
|
||||
"APP_UPDATE_NOT_AVAILABLE": "You are using the latest version",
|
||||
"APP_UPDATE_DOWNLOADING": "Downloading version {{version}}…",
|
||||
"APP_UPDATE_DOWNLOADED": "Version {{version}} is ready to install",
|
||||
"APP_UPDATE_MANUAL_AVAILABLE": "Version {{version}} is available for manual installation",
|
||||
"APP_UPDATE_ERROR": "Update check failed",
|
||||
"APP_UPDATE_CHECK": "Check again",
|
||||
"APP_UPDATE_DOWNLOAD": "Update",
|
||||
"APP_UPDATE_INSTALL": "Restart application",
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "Release notes"
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "ダーク",
|
||||
|
||||
@@ -395,7 +395,22 @@
|
||||
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
|
||||
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
|
||||
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.",
|
||||
"APP_UPDATE_TITLE": "Application update",
|
||||
"APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.",
|
||||
"APP_UPDATE_IDLE": "Ready to check for updates",
|
||||
"APP_UPDATE_CHECKING": "Checking for updates…",
|
||||
"APP_UPDATE_AVAILABLE": "Version {{version}} is available",
|
||||
"APP_UPDATE_NOT_AVAILABLE": "You are using the latest version",
|
||||
"APP_UPDATE_DOWNLOADING": "Downloading version {{version}}…",
|
||||
"APP_UPDATE_DOWNLOADED": "Version {{version}} is ready to install",
|
||||
"APP_UPDATE_MANUAL_AVAILABLE": "Version {{version}} is available for manual installation",
|
||||
"APP_UPDATE_ERROR": "Update check failed",
|
||||
"APP_UPDATE_CHECK": "Check again",
|
||||
"APP_UPDATE_DOWNLOAD": "Update",
|
||||
"APP_UPDATE_INSTALL": "Restart application",
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "Release notes"
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "어둡게",
|
||||
|
||||
@@ -395,7 +395,22 @@
|
||||
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
|
||||
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
|
||||
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.",
|
||||
"APP_UPDATE_TITLE": "Application update",
|
||||
"APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.",
|
||||
"APP_UPDATE_IDLE": "Ready to check for updates",
|
||||
"APP_UPDATE_CHECKING": "Checking for updates…",
|
||||
"APP_UPDATE_AVAILABLE": "Version {{version}} is available",
|
||||
"APP_UPDATE_NOT_AVAILABLE": "You are using the latest version",
|
||||
"APP_UPDATE_DOWNLOADING": "Downloading version {{version}}…",
|
||||
"APP_UPDATE_DOWNLOADED": "Version {{version}} is ready to install",
|
||||
"APP_UPDATE_MANUAL_AVAILABLE": "Version {{version}} is available for manual installation",
|
||||
"APP_UPDATE_ERROR": "Update check failed",
|
||||
"APP_UPDATE_CHECK": "Check again",
|
||||
"APP_UPDATE_DOWNLOAD": "Update",
|
||||
"APP_UPDATE_INSTALL": "Restart application",
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "Release notes"
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Donker",
|
||||
|
||||
@@ -395,7 +395,22 @@
|
||||
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
|
||||
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
|
||||
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.",
|
||||
"APP_UPDATE_TITLE": "Application update",
|
||||
"APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.",
|
||||
"APP_UPDATE_IDLE": "Ready to check for updates",
|
||||
"APP_UPDATE_CHECKING": "Checking for updates…",
|
||||
"APP_UPDATE_AVAILABLE": "Version {{version}} is available",
|
||||
"APP_UPDATE_NOT_AVAILABLE": "You are using the latest version",
|
||||
"APP_UPDATE_DOWNLOADING": "Downloading version {{version}}…",
|
||||
"APP_UPDATE_DOWNLOADED": "Version {{version}} is ready to install",
|
||||
"APP_UPDATE_MANUAL_AVAILABLE": "Version {{version}} is available for manual installation",
|
||||
"APP_UPDATE_ERROR": "Update check failed",
|
||||
"APP_UPDATE_CHECK": "Check again",
|
||||
"APP_UPDATE_DOWNLOAD": "Update",
|
||||
"APP_UPDATE_INSTALL": "Restart application",
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "Release notes"
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Ciemny",
|
||||
|
||||
@@ -395,7 +395,22 @@
|
||||
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
|
||||
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
|
||||
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.",
|
||||
"APP_UPDATE_TITLE": "Application update",
|
||||
"APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.",
|
||||
"APP_UPDATE_IDLE": "Ready to check for updates",
|
||||
"APP_UPDATE_CHECKING": "Checking for updates…",
|
||||
"APP_UPDATE_AVAILABLE": "Version {{version}} is available",
|
||||
"APP_UPDATE_NOT_AVAILABLE": "You are using the latest version",
|
||||
"APP_UPDATE_DOWNLOADING": "Downloading version {{version}}…",
|
||||
"APP_UPDATE_DOWNLOADED": "Version {{version}} is ready to install",
|
||||
"APP_UPDATE_MANUAL_AVAILABLE": "Version {{version}} is available for manual installation",
|
||||
"APP_UPDATE_ERROR": "Update check failed",
|
||||
"APP_UPDATE_CHECK": "Check again",
|
||||
"APP_UPDATE_DOWNLOAD": "Update",
|
||||
"APP_UPDATE_INSTALL": "Restart application",
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "Release notes"
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Escuro",
|
||||
|
||||
@@ -395,7 +395,22 @@
|
||||
"DASHBOARD_RAIL_SOURCES": "Недавно использованные источники",
|
||||
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Показывать быстрый доступ к недавно использованным плейлистам и порталам.",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED": "Недавно добавленное на Xtream",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Показывать новые фильмы, сериалы и каналы Xtream."
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Показывать новые фильмы, сериалы и каналы Xtream.",
|
||||
"APP_UPDATE_TITLE": "Обновление приложения",
|
||||
"APP_UPDATE_DESCRIPTION": "Проверка обновлений desktop-приложения через GitHub Releases.",
|
||||
"APP_UPDATE_IDLE": "Можно проверить обновления",
|
||||
"APP_UPDATE_CHECKING": "Проверяем обновления…",
|
||||
"APP_UPDATE_AVAILABLE": "Доступна версия {{version}}",
|
||||
"APP_UPDATE_NOT_AVAILABLE": "Вы используете последнюю версию",
|
||||
"APP_UPDATE_DOWNLOADING": "Загружается версия {{version}}…",
|
||||
"APP_UPDATE_DOWNLOADED": "Версия {{version}} готова к установке",
|
||||
"APP_UPDATE_MANUAL_AVAILABLE": "Версия {{version}} доступна для ручной установки",
|
||||
"APP_UPDATE_ERROR": "Не удалось проверить обновление",
|
||||
"APP_UPDATE_CHECK": "Проверить снова",
|
||||
"APP_UPDATE_DOWNLOAD": "Обновить",
|
||||
"APP_UPDATE_INSTALL": "Перезагрузить приложение",
|
||||
"APP_UPDATE_OPEN_RELEASE": "Открыть GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "Release notes"
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Тёмная",
|
||||
|
||||
@@ -395,7 +395,22 @@
|
||||
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
|
||||
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
|
||||
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.",
|
||||
"APP_UPDATE_TITLE": "Application update",
|
||||
"APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.",
|
||||
"APP_UPDATE_IDLE": "Ready to check for updates",
|
||||
"APP_UPDATE_CHECKING": "Checking for updates…",
|
||||
"APP_UPDATE_AVAILABLE": "Version {{version}} is available",
|
||||
"APP_UPDATE_NOT_AVAILABLE": "You are using the latest version",
|
||||
"APP_UPDATE_DOWNLOADING": "Downloading version {{version}}…",
|
||||
"APP_UPDATE_DOWNLOADED": "Version {{version}} is ready to install",
|
||||
"APP_UPDATE_MANUAL_AVAILABLE": "Version {{version}} is available for manual installation",
|
||||
"APP_UPDATE_ERROR": "Update check failed",
|
||||
"APP_UPDATE_CHECK": "Check again",
|
||||
"APP_UPDATE_DOWNLOAD": "Update",
|
||||
"APP_UPDATE_INSTALL": "Restart application",
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "Release notes"
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Koyu",
|
||||
|
||||
@@ -395,7 +395,22 @@
|
||||
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
|
||||
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
|
||||
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.",
|
||||
"APP_UPDATE_TITLE": "Application update",
|
||||
"APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.",
|
||||
"APP_UPDATE_IDLE": "Ready to check for updates",
|
||||
"APP_UPDATE_CHECKING": "Checking for updates…",
|
||||
"APP_UPDATE_AVAILABLE": "Version {{version}} is available",
|
||||
"APP_UPDATE_NOT_AVAILABLE": "You are using the latest version",
|
||||
"APP_UPDATE_DOWNLOADING": "Downloading version {{version}}…",
|
||||
"APP_UPDATE_DOWNLOADED": "Version {{version}} is ready to install",
|
||||
"APP_UPDATE_MANUAL_AVAILABLE": "Version {{version}} is available for manual installation",
|
||||
"APP_UPDATE_ERROR": "Update check failed",
|
||||
"APP_UPDATE_CHECK": "Check again",
|
||||
"APP_UPDATE_DOWNLOAD": "Update",
|
||||
"APP_UPDATE_INSTALL": "Restart application",
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "Release notes"
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "深色",
|
||||
|
||||
@@ -395,7 +395,22 @@
|
||||
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.",
|
||||
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import."
|
||||
"PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.",
|
||||
"APP_UPDATE_TITLE": "Application update",
|
||||
"APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.",
|
||||
"APP_UPDATE_IDLE": "Ready to check for updates",
|
||||
"APP_UPDATE_CHECKING": "Checking for updates…",
|
||||
"APP_UPDATE_AVAILABLE": "Version {{version}} is available",
|
||||
"APP_UPDATE_NOT_AVAILABLE": "You are using the latest version",
|
||||
"APP_UPDATE_DOWNLOADING": "Downloading version {{version}}…",
|
||||
"APP_UPDATE_DOWNLOADED": "Version {{version}} is ready to install",
|
||||
"APP_UPDATE_MANUAL_AVAILABLE": "Version {{version}} is available for manual installation",
|
||||
"APP_UPDATE_ERROR": "Update check failed",
|
||||
"APP_UPDATE_CHECK": "Check again",
|
||||
"APP_UPDATE_DOWNLOAD": "Update",
|
||||
"APP_UPDATE_INSTALL": "Restart application",
|
||||
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
|
||||
"APP_UPDATE_RELEASE_NOTES": "Release notes"
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "深色",
|
||||
|
||||
Reference in new issue
Block a user