mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36:15 -08:00
feat(dashboard): add configurable dashboard rails
This commit is contained in:
1 parent
bc639cd36d
commit
101d9ba162
39 files changed
+1685
-248
No files matched your search
@@ -5,6 +5,7 @@ import {
|
||||
clickCategoryByNameExact,
|
||||
clickFirstGridListCard,
|
||||
closeElectronApp,
|
||||
expectVisibleContentCardTitle,
|
||||
defaultXtreamPassword,
|
||||
defaultXtreamUsername,
|
||||
expectPathname,
|
||||
@@ -81,21 +82,63 @@ test.describe('Dashboard Activation', () => {
|
||||
seriesFixture.categoryName
|
||||
);
|
||||
const seriesTitle = await clickFirstGridListCard(app.mainWindow);
|
||||
await addCurrentDetailToFavorites(app.mainWindow);
|
||||
await playFirstSeriesEpisode(app.mainWindow);
|
||||
|
||||
await goToDashboard(app.mainWindow);
|
||||
|
||||
// v0.22 dashboard: the mixed Global Favorites rail was removed.
|
||||
// Favorited live channels are promoted into the favorites-first
|
||||
// Live rail; clicking one still opens the Xtream favorites
|
||||
// Favorited movies/series are shown as cover cards in their own
|
||||
// rail. The rail's "See all" opens Global Favorites directly on
|
||||
// Movies instead of defaulting back to Live TV.
|
||||
await expectDashboardRail(
|
||||
app.mainWindow,
|
||||
'dashboard-favorite-vod-rail'
|
||||
);
|
||||
await app.mainWindow
|
||||
.getByTestId('dashboard-favorite-vod-rail-manage-all')
|
||||
.click();
|
||||
await expectPathname(app.mainWindow, /\/workspace\/global-favorites$/);
|
||||
await expectVisibleContentCardTitle(app.mainWindow, movieTitle);
|
||||
|
||||
await app.mainWindow.goBack();
|
||||
await expectPathname(app.mainWindow, /\/workspace\/dashboard$/);
|
||||
|
||||
await dashboardRailCardByTitle(
|
||||
app.mainWindow,
|
||||
'dashboard-favorite-vod-rail',
|
||||
movieTitle
|
||||
).click();
|
||||
await expectInlineCollectionDetail(app.mainWindow, {
|
||||
pathname: /\/workspace\/global-favorites$/,
|
||||
title: movieTitle,
|
||||
});
|
||||
|
||||
await app.mainWindow.goBack();
|
||||
await expectPathname(app.mainWindow, /\/workspace\/dashboard$/);
|
||||
|
||||
await dashboardRailCardByTitle(
|
||||
app.mainWindow,
|
||||
'dashboard-favorite-vod-rail',
|
||||
seriesTitle
|
||||
).click();
|
||||
await expectInlineCollectionDetail(app.mainWindow, {
|
||||
pathname: /\/workspace\/global-favorites$/,
|
||||
title: seriesTitle,
|
||||
});
|
||||
|
||||
await app.mainWindow.goBack();
|
||||
await expectPathname(app.mainWindow, /\/workspace\/dashboard$/);
|
||||
|
||||
// Live favorites no longer fall back to recent live history.
|
||||
// Clicking a favorited live card still opens the Xtream favorites
|
||||
// collection with the channel playing.
|
||||
await expectDashboardRail(
|
||||
app.mainWindow,
|
||||
'dashboard-live-recent-rail'
|
||||
'dashboard-live-favorites-rail'
|
||||
);
|
||||
await dashboardRailCardByTitle(
|
||||
app.mainWindow,
|
||||
'dashboard-live-recent-rail',
|
||||
'dashboard-live-favorites-rail',
|
||||
liveTitle
|
||||
).click();
|
||||
await app.mainWindow.waitForURL(
|
||||
@@ -115,6 +158,28 @@ test.describe('Dashboard Activation', () => {
|
||||
await app.mainWindow.goBack();
|
||||
await app.mainWindow.waitForURL(/\/workspace\/dashboard$/);
|
||||
|
||||
// Once the live card has been played, the separate recently
|
||||
// watched live rail appears with the same channel layout.
|
||||
await expectDashboardRail(
|
||||
app.mainWindow,
|
||||
'dashboard-recent-live-rail'
|
||||
);
|
||||
await dashboardRailCardByTitle(
|
||||
app.mainWindow,
|
||||
'dashboard-recent-live-rail',
|
||||
liveTitle
|
||||
).click();
|
||||
await expectPathname(
|
||||
app.mainWindow,
|
||||
/\/workspace\/xtreams\/[^/]+\/recent$/
|
||||
);
|
||||
await expect(
|
||||
channelItemByTitle(app.mainWindow, liveTitle).first()
|
||||
).toBeVisible({ timeout: 20000 });
|
||||
|
||||
await app.mainWindow.goBack();
|
||||
await expectPathname(app.mainWindow, /\/workspace\/dashboard$/);
|
||||
|
||||
// Movies and series that were played land in the Continue
|
||||
// Watching rail (formerly "recently-watched"); clicking a card
|
||||
// opens it inline in the global-recent collection detail view.
|
||||
|
||||
@@ -217,6 +217,21 @@ test.describe('Electron Settings', () => {
|
||||
'mat-checkbox[formcontrolname="showDashboard"] input[type="checkbox"]'
|
||||
)
|
||||
.uncheck();
|
||||
for (const toggleId of [
|
||||
'toggle-dashboard-hero',
|
||||
'toggle-dashboard-rail-continue-watching',
|
||||
'toggle-dashboard-rail-live-favorites',
|
||||
'toggle-dashboard-rail-recently-watched-live',
|
||||
'toggle-dashboard-rail-favorite-movies-and-series',
|
||||
'toggle-dashboard-rail-recent-sources',
|
||||
'toggle-dashboard-rail-xtream-recently-added',
|
||||
]) {
|
||||
await expect(
|
||||
firstLaunch.mainWindow
|
||||
.getByTestId(toggleId)
|
||||
.locator('input[type="checkbox"]')
|
||||
).toBeDisabled();
|
||||
}
|
||||
await saveSettings(firstLaunch.mainWindow);
|
||||
} finally {
|
||||
await closeElectronApp(firstLaunch);
|
||||
@@ -241,6 +256,53 @@ test.describe('Electron Settings', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('@settings @dashboard @electron hides an individually disabled dashboard rail while dashboard remains enabled', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await importM3uPlaylistFromNativeDialog(app, m3uFixturePath);
|
||||
await app.mainWindow.waitForURL(/\/workspace\/playlists\/.+/);
|
||||
|
||||
await goToDashboard(app.mainWindow);
|
||||
await expect(
|
||||
app.mainWindow.getByTestId('dashboard-recent-sources-rail')
|
||||
).toBeVisible({ timeout: 20000 });
|
||||
|
||||
await openSettings(app.mainWindow);
|
||||
await app.mainWindow
|
||||
.locator('.settings-section-item')
|
||||
.filter({ hasText: 'Dashboard' })
|
||||
.first()
|
||||
.click();
|
||||
await expect(
|
||||
app.mainWindow
|
||||
.getByTestId('toggle-show-dashboard')
|
||||
.locator('input[type="checkbox"]')
|
||||
).toBeChecked();
|
||||
await app.mainWindow
|
||||
.getByTestId('toggle-dashboard-rail-recent-sources')
|
||||
.locator('input[type="checkbox"]')
|
||||
.uncheck();
|
||||
await saveSettings(app.mainWindow);
|
||||
|
||||
await goToDashboard(app.mainWindow);
|
||||
await app.mainWindow.waitForURL(/\/workspace\/dashboard$/);
|
||||
await expect(
|
||||
app.mainWindow.getByTestId('dashboard-recent-sources-rail')
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
app.mainWindow.getByRole('link', {
|
||||
name: 'Dashboard',
|
||||
exact: true,
|
||||
})
|
||||
).toBeVisible();
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('restores the last section-level view across restart when configured', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
<section
|
||||
class="settings-group"
|
||||
[class.settings-group--active]="activeSection() === 'dashboard'"
|
||||
id="dashboard"
|
||||
[formGroup]="form()"
|
||||
>
|
||||
<header class="settings-group__header">
|
||||
<div class="settings-group__header-icon">
|
||||
<mat-icon>dashboard</mat-icon>
|
||||
</div>
|
||||
<div class="settings-group__header-text">
|
||||
<h3>{{ 'SETTINGS.DASHBOARD' | translate }}</h3>
|
||||
<p>{{ 'SETTINGS.DASHBOARD_SUBTITLE' | translate }}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.SHOW_DASHBOARD' | translate }}</h4>
|
||||
<p>
|
||||
{{ 'SETTINGS.SHOW_DASHBOARD_DESCRIPTION' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox
|
||||
formControlName="showDashboard"
|
||||
data-test-id="toggle-show-dashboard"
|
||||
></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div formGroupName="dashboardRails">
|
||||
<div class="setting-item">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.DASHBOARD_HERO' | translate }}</h4>
|
||||
<p>
|
||||
{{ 'SETTINGS.DASHBOARD_HERO_DESCRIPTION' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox
|
||||
formControlName="hero"
|
||||
data-test-id="toggle-dashboard-hero"
|
||||
></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.DASHBOARD_RAIL_CONTINUE' | translate }}</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.DASHBOARD_RAIL_CONTINUE_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox
|
||||
formControlName="continueWatching"
|
||||
data-test-id="toggle-dashboard-rail-continue-watching"
|
||||
></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{
|
||||
'SETTINGS.DASHBOARD_RAIL_LIVE_FAVORITES' | translate
|
||||
}}</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.DASHBOARD_RAIL_LIVE_FAVORITES_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox
|
||||
formControlName="liveFavorites"
|
||||
data-test-id="toggle-dashboard-rail-live-favorites"
|
||||
></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{
|
||||
'SETTINGS.DASHBOARD_RAIL_RECENT_LIVE' | translate
|
||||
}}</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.DASHBOARD_RAIL_RECENT_LIVE_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox
|
||||
formControlName="recentlyWatchedLive"
|
||||
data-test-id="toggle-dashboard-rail-recently-watched-live"
|
||||
></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{
|
||||
'SETTINGS.DASHBOARD_RAIL_FAVORITE_VOD' | translate
|
||||
}}</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.DASHBOARD_RAIL_FAVORITE_VOD_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox
|
||||
formControlName="favoriteMoviesAndSeries"
|
||||
data-test-id="toggle-dashboard-rail-favorite-movies-and-series"
|
||||
></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.DASHBOARD_RAIL_SOURCES' | translate }}</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.DASHBOARD_RAIL_SOURCES_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox
|
||||
formControlName="recentSources"
|
||||
data-test-id="toggle-dashboard-rail-recent-sources"
|
||||
></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{
|
||||
'SETTINGS.DASHBOARD_RAIL_XTREAM_ADDED' | translate
|
||||
}}</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox
|
||||
formControlName="xtreamRecentlyAdded"
|
||||
data-test-id="toggle-dashboard-rail-xtream-recently-added"
|
||||
></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,24 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, input, ViewEncapsulation } from '@angular/core';
|
||||
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings-dashboard-section',
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatCheckboxModule,
|
||||
MatIconModule,
|
||||
ReactiveFormsModule,
|
||||
TranslateModule,
|
||||
],
|
||||
templateUrl: './settings-dashboard-section.component.html',
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
styles: [':host { display: contents; }'],
|
||||
})
|
||||
export class SettingsDashboardSectionComponent {
|
||||
readonly form = input.required<FormGroup>();
|
||||
readonly activeSection = input.required<string>();
|
||||
}
|
||||
@@ -113,21 +113,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.SHOW_DASHBOARD' | translate }}</h4>
|
||||
<p>
|
||||
{{ 'SETTINGS.SHOW_DASHBOARD_DESCRIPTION' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox
|
||||
formControlName="showDashboard"
|
||||
data-test-id="toggle-show-dashboard"
|
||||
></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.STARTUP_BEHAVIOR' | translate }}</h4>
|
||||
|
||||
@@ -44,6 +44,40 @@ describe('buildSettingsSectionNavItems', () => {
|
||||
return ids;
|
||||
}
|
||||
|
||||
function collectSettingsComponentSectionOrder(): string[] {
|
||||
const html = readFileSync(
|
||||
resolve(settingsDir, 'settings.component.html'),
|
||||
'utf-8'
|
||||
);
|
||||
const idsByComponent = new Map<string, string>();
|
||||
|
||||
for (const fileName of readdirSync(settingsDir)) {
|
||||
const componentMatch =
|
||||
/^settings-(.+)-section\.component\.html$/.exec(fileName);
|
||||
if (!componentMatch) {
|
||||
continue;
|
||||
}
|
||||
const sectionHtml = readFileSync(
|
||||
resolve(settingsDir, fileName),
|
||||
'utf-8'
|
||||
);
|
||||
const rootMatch = /<section[^>]*\sid="([^"]+)"/i.exec(
|
||||
sectionHtml
|
||||
);
|
||||
if (rootMatch) {
|
||||
idsByComponent.set(
|
||||
`app-settings-${componentMatch[1]}-section`,
|
||||
rootMatch[1]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(
|
||||
html.matchAll(/<app-settings-[a-z-]+-section\b/g),
|
||||
(match) => idsByComponent.get(match[0].slice(1))
|
||||
).filter((id): id is string => Boolean(id));
|
||||
}
|
||||
|
||||
it('exposes feature-specific items only when their runtime capabilities are supported', () => {
|
||||
const supportedItems = buildSettingsSectionNavItems({
|
||||
supportsEpg: true,
|
||||
@@ -55,7 +89,7 @@ describe('buildSettingsSectionNavItems', () => {
|
||||
});
|
||||
|
||||
expect(supportedItems.map((item) => item.id)).toEqual(
|
||||
expect.arrayContaining(['epg', 'remote-control'])
|
||||
expect.arrayContaining(['dashboard', 'epg', 'remote-control'])
|
||||
);
|
||||
expect(
|
||||
unsupportedItems.find((item) => item.id === 'epg')?.visible
|
||||
@@ -85,4 +119,25 @@ describe('buildSettingsSectionNavItems', () => {
|
||||
const unreachable = [...templateIds].filter((id) => !navIds.has(id));
|
||||
expect(unreachable).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps Dashboard below EPG in both the settings rail and content order', () => {
|
||||
const expectedOrder = [
|
||||
'general',
|
||||
'playback',
|
||||
'epg',
|
||||
'dashboard',
|
||||
'remote-control',
|
||||
'backup',
|
||||
'reset',
|
||||
'about',
|
||||
];
|
||||
|
||||
expect(
|
||||
buildSettingsSectionNavItems({
|
||||
supportsEpg: true,
|
||||
supportsRemoteControl: true,
|
||||
}).map((item) => item.id)
|
||||
).toEqual(expectedOrder);
|
||||
expect(collectSettingsComponentSectionOrder()).toEqual(expectedOrder);
|
||||
});
|
||||
});
|
||||
@@ -113,6 +113,12 @@ export function buildSettingsSectionNavItems({
|
||||
icon: 'calendar_month',
|
||||
visible: supportsEpg,
|
||||
},
|
||||
{
|
||||
id: 'dashboard',
|
||||
label: 'SETTINGS.NAV_DASHBOARD',
|
||||
icon: 'dashboard',
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
// Must match the section's HTML id (`remote-control`) so the
|
||||
// settings-section-scroll directive can resolve the anchor.
|
||||
|
||||
@@ -67,6 +67,11 @@
|
||||
/>
|
||||
}
|
||||
|
||||
<app-settings-dashboard-section
|
||||
[form]="settingsForm"
|
||||
[activeSection]="activeSection()"
|
||||
/>
|
||||
|
||||
@if (supportsRemoteControl) {
|
||||
<app-settings-remote-control-section
|
||||
[form]="settingsForm"
|
||||
|
||||
@@ -58,6 +58,16 @@ import { SettingsStore } from '../services/settings-store.service';
|
||||
import { SettingsService } from '../services/settings.service';
|
||||
import { SettingsSectionScrollDirective } from './settings-section-scroll.directive';
|
||||
|
||||
const DEFAULT_DASHBOARD_RAILS = {
|
||||
hero: true,
|
||||
continueWatching: true,
|
||||
liveFavorites: true,
|
||||
recentlyWatchedLive: true,
|
||||
favoriteMoviesAndSeries: true,
|
||||
recentSources: true,
|
||||
xtreamRecentlyAdded: true,
|
||||
};
|
||||
|
||||
class MatSnackBarStub {
|
||||
open = jest.fn();
|
||||
}
|
||||
@@ -89,6 +99,7 @@ const DEFAULT_SETTINGS = {
|
||||
epgUrl: [],
|
||||
recordingFolder: '',
|
||||
coverSize: 'medium',
|
||||
dashboardRails: DEFAULT_DASHBOARD_RAILS,
|
||||
preferUploadedEpgOverXtream: false,
|
||||
};
|
||||
|
||||
@@ -1107,7 +1118,7 @@ describe('SettingsComponent', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('renders workspace startup controls with the expected defaults', () => {
|
||||
it('renders dashboard controls with the expected defaults', () => {
|
||||
const nativeElement = fixture.nativeElement as HTMLElement;
|
||||
|
||||
expect(
|
||||
@@ -1115,12 +1126,60 @@ describe('SettingsComponent', () => {
|
||||
'[data-test-id="toggle-show-dashboard"]'
|
||||
)
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
nativeElement.querySelector(
|
||||
'[data-test-id="toggle-dashboard-hero"]'
|
||||
)
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
nativeElement.querySelector(
|
||||
'[data-test-id="toggle-dashboard-rail-live-favorites"]'
|
||||
)
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
nativeElement.querySelector(
|
||||
'[data-test-id="toggle-dashboard-rail-recently-watched-live"]'
|
||||
)
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
nativeElement.querySelector(
|
||||
'[data-test-id="toggle-dashboard-rail-favorite-movies-and-series"]'
|
||||
)
|
||||
).not.toBeNull();
|
||||
expect(component.settingsForm.value.showDashboard).toBe(true);
|
||||
expect(component.settingsForm.value.dashboardRails).toEqual(
|
||||
DEFAULT_DASHBOARD_RAILS
|
||||
);
|
||||
expect(component.settingsForm.value.startupBehavior).toBe(
|
||||
StartupBehavior.FirstView
|
||||
);
|
||||
});
|
||||
|
||||
it('disables dashboard surface controls when the dashboard is off', async () => {
|
||||
const showDashboard = component.settingsForm.get('showDashboard');
|
||||
const dashboardRails = component.settingsForm.get('dashboardRails');
|
||||
|
||||
showDashboard?.setValue(false);
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(dashboardRails?.disabled).toBe(true);
|
||||
expect(
|
||||
component.settingsForm.get('dashboardRails.hero')?.disabled
|
||||
).toBe(true);
|
||||
expect(
|
||||
component.settingsForm.get('dashboardRails.continueWatching')
|
||||
?.disabled
|
||||
).toBe(true);
|
||||
|
||||
showDashboard?.setValue(true);
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(dashboardRails?.enabled).toBe(true);
|
||||
expect(
|
||||
component.settingsForm.get('dashboardRails.hero')?.enabled
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should save settings on submit', async () => {
|
||||
const mockStore = settingsStore as unknown as MockSettingsStore;
|
||||
mockStore.updateSettings.mockResolvedValue(undefined);
|
||||
|
||||
@@ -4,11 +4,13 @@ import {
|
||||
computed,
|
||||
inject,
|
||||
Input,
|
||||
DestroyRef,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
signal,
|
||||
ViewEncapsulation,
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import {
|
||||
FormArray,
|
||||
FormBuilder,
|
||||
@@ -51,8 +53,10 @@ import {
|
||||
import {
|
||||
EmbeddedMpvSupport,
|
||||
CoverSize,
|
||||
DEFAULT_DASHBOARD_RAILS_SETTINGS,
|
||||
Language,
|
||||
normalizeExternalPlayerArguments,
|
||||
normalizeDashboardRailsSettings,
|
||||
Settings,
|
||||
StartupBehavior,
|
||||
StreamFormat,
|
||||
@@ -63,6 +67,7 @@ import { SettingsStore } from '../services/settings-store.service';
|
||||
import { SettingsService } from './../services/settings.service';
|
||||
import { SettingsAboutSectionComponent } from './settings-about-section.component';
|
||||
import { SettingsBackupSectionComponent } from './settings-backup-section.component';
|
||||
import { SettingsDashboardSectionComponent } from './settings-dashboard-section.component';
|
||||
import {
|
||||
SettingsDeleteAllPlaylistsDialogComponent,
|
||||
SettingsDeleteAllPlaylistsDialogData,
|
||||
@@ -103,6 +108,7 @@ import { SettingsSectionScrollDirective } from './settings-section-scroll.direct
|
||||
MatDialogModule,
|
||||
SettingsAboutSectionComponent,
|
||||
SettingsBackupSectionComponent,
|
||||
SettingsDashboardSectionComponent,
|
||||
SettingsEpgSectionComponent,
|
||||
SettingsGeneralSectionComponent,
|
||||
SettingsPlaybackSectionComponent,
|
||||
@@ -116,6 +122,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
public dataService = inject(DataService);
|
||||
private epgService = inject(EpgService);
|
||||
private formBuilder = inject(FormBuilder);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
private playlistsService = inject(PlaylistsService);
|
||||
private router = inject(Router);
|
||||
private settingsService = inject(SettingsService);
|
||||
@@ -204,6 +211,19 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
language: Language.ENGLISH,
|
||||
showCaptions: false,
|
||||
showDashboard: true,
|
||||
dashboardRails: this.formBuilder.group({
|
||||
hero: DEFAULT_DASHBOARD_RAILS_SETTINGS.hero,
|
||||
continueWatching:
|
||||
DEFAULT_DASHBOARD_RAILS_SETTINGS.continueWatching,
|
||||
liveFavorites: DEFAULT_DASHBOARD_RAILS_SETTINGS.liveFavorites,
|
||||
recentlyWatchedLive:
|
||||
DEFAULT_DASHBOARD_RAILS_SETTINGS.recentlyWatchedLive,
|
||||
favoriteMoviesAndSeries:
|
||||
DEFAULT_DASHBOARD_RAILS_SETTINGS.favoriteMoviesAndSeries,
|
||||
recentSources: DEFAULT_DASHBOARD_RAILS_SETTINGS.recentSources,
|
||||
xtreamRecentlyAdded:
|
||||
DEFAULT_DASHBOARD_RAILS_SETTINGS.xtreamRecentlyAdded,
|
||||
}),
|
||||
startupBehavior: StartupBehavior.FirstView,
|
||||
showExternalPlaybackBar: true,
|
||||
theme: Theme.SystemTheme,
|
||||
@@ -303,6 +323,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
// Wait for settings to load before setting the form
|
||||
await this.settingsStore.loadSettings();
|
||||
this.setSettings();
|
||||
this.bindDashboardControlsEnabledState();
|
||||
void this.loadEmbeddedMpvSupport();
|
||||
this.checkAppVersion();
|
||||
void this.fetchLocalIpAddresses();
|
||||
@@ -378,6 +399,9 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
setSettings() {
|
||||
const currentSettings = this.settingsStore.getSettings();
|
||||
this.settingsForm.patchValue(currentSettings);
|
||||
this.syncDashboardControlsEnabledState(
|
||||
currentSettings.showDashboard ?? true
|
||||
);
|
||||
|
||||
if (this.supportsEpg && currentSettings.epgUrl) {
|
||||
this.epgUrl.clear();
|
||||
@@ -385,6 +409,28 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
private bindDashboardControlsEnabledState(): void {
|
||||
this.settingsForm
|
||||
.get('showDashboard')
|
||||
?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe((showDashboard) =>
|
||||
this.syncDashboardControlsEnabledState(showDashboard ?? true)
|
||||
);
|
||||
}
|
||||
|
||||
private syncDashboardControlsEnabledState(showDashboard: boolean): void {
|
||||
const dashboardRails = this.settingsForm.get('dashboardRails');
|
||||
if (!dashboardRails) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (showDashboard) {
|
||||
dashboardRails.enable({ emitEvent: false });
|
||||
} else {
|
||||
dashboardRails.disable({ emitEvent: false });
|
||||
}
|
||||
}
|
||||
|
||||
selectTheme(theme: Theme): void {
|
||||
if (this.settingsForm.value.theme === theme) {
|
||||
return;
|
||||
@@ -519,7 +565,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
|
||||
private createSettingsFromFormValue(): Settings {
|
||||
const currentSettings = this.settingsStore.getSettings();
|
||||
const value = this.settingsForm.value;
|
||||
const value = this.settingsForm.getRawValue();
|
||||
const epgUrl = Array.isArray(value.epgUrl)
|
||||
? value.epgUrl.filter(
|
||||
(url): url is string => typeof url === 'string'
|
||||
@@ -533,6 +579,9 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
language: value.language ?? Language.ENGLISH,
|
||||
showCaptions: value.showCaptions ?? false,
|
||||
showDashboard: value.showDashboard ?? true,
|
||||
dashboardRails: normalizeDashboardRailsSettings(
|
||||
value.dashboardRails
|
||||
),
|
||||
startupBehavior: value.startupBehavior ?? StartupBehavior.FirstView,
|
||||
showExternalPlaybackBar: value.showExternalPlaybackBar ?? true,
|
||||
theme: value.theme ?? Theme.SystemTheme,
|
||||
|
||||
@@ -377,7 +377,24 @@
|
||||
"PORT_PATTERN": "الأرقام فقط مسموح بها",
|
||||
"REMOVE_ALL_IN_PROGRESS": "Deleting playlist data…",
|
||||
"REMOVE_ALL_PROGRESS": "Deleting playlist data… {{current}} / {{total}}",
|
||||
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again."
|
||||
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again.",
|
||||
"NAV_DASHBOARD": "Dashboard",
|
||||
"DASHBOARD": "Dashboard",
|
||||
"DASHBOARD_SUBTITLE": "Choose which dashboard rails are shown in the workspace.",
|
||||
"DASHBOARD_HERO": "Hero banner",
|
||||
"DASHBOARD_HERO_DESCRIPTION": "Show the large top banner for your most recent item.",
|
||||
"DASHBOARD_RAIL_CONTINUE": "Continue Watching",
|
||||
"DASHBOARD_RAIL_CONTINUE_DESCRIPTION": "Show recently watched movies and series as cover cards.",
|
||||
"DASHBOARD_RAIL_LIVE_FAVORITES": "Live now on your favorites",
|
||||
"DASHBOARD_RAIL_LIVE_FAVORITES_DESCRIPTION": "Show favorite live channels with current EPG progress.",
|
||||
"DASHBOARD_RAIL_RECENT_LIVE": "Recently watched live TV",
|
||||
"DASHBOARD_RAIL_RECENT_LIVE_DESCRIPTION": "Show live channels you opened recently with current EPG progress.",
|
||||
"DASHBOARD_RAIL_FAVORITE_VOD": "Favorite movies & series",
|
||||
"DASHBOARD_RAIL_FAVORITE_VOD_DESCRIPTION": "Show favorited movies and series as cover cards.",
|
||||
"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."
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "داكن",
|
||||
@@ -890,7 +907,9 @@
|
||||
"TYPE_MOVIE": "فيلم",
|
||||
"TYPE_SERIES": "مسلسل",
|
||||
"SEASON_EPISODE_BADGE": "م{{season}}·ح{{episode}}",
|
||||
"NOT_YET_SYNCED": "لم تتم المزامنة بعد"
|
||||
"NOT_YET_SYNCED": "لم تتم المزامنة بعد",
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Recently watched live TV",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -377,7 +377,24 @@
|
||||
"PORT_PATTERN": "غير الأرقام مسموحة",
|
||||
"REMOVE_ALL_IN_PROGRESS": "Deleting playlist data…",
|
||||
"REMOVE_ALL_PROGRESS": "Deleting playlist data… {{current}} / {{total}}",
|
||||
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again."
|
||||
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again.",
|
||||
"NAV_DASHBOARD": "Dashboard",
|
||||
"DASHBOARD": "Dashboard",
|
||||
"DASHBOARD_SUBTITLE": "Choose which dashboard rails are shown in the workspace.",
|
||||
"DASHBOARD_HERO": "Hero banner",
|
||||
"DASHBOARD_HERO_DESCRIPTION": "Show the large top banner for your most recent item.",
|
||||
"DASHBOARD_RAIL_CONTINUE": "Continue Watching",
|
||||
"DASHBOARD_RAIL_CONTINUE_DESCRIPTION": "Show recently watched movies and series as cover cards.",
|
||||
"DASHBOARD_RAIL_LIVE_FAVORITES": "Live now on your favorites",
|
||||
"DASHBOARD_RAIL_LIVE_FAVORITES_DESCRIPTION": "Show favorite live channels with current EPG progress.",
|
||||
"DASHBOARD_RAIL_RECENT_LIVE": "Recently watched live TV",
|
||||
"DASHBOARD_RAIL_RECENT_LIVE_DESCRIPTION": "Show live channels you opened recently with current EPG progress.",
|
||||
"DASHBOARD_RAIL_FAVORITE_VOD": "Favorite movies & series",
|
||||
"DASHBOARD_RAIL_FAVORITE_VOD_DESCRIPTION": "Show favorited movies and series as cover cards.",
|
||||
"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."
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "داكن",
|
||||
@@ -890,7 +907,9 @@
|
||||
"TYPE_MOVIE": "فيلم",
|
||||
"TYPE_SERIES": "سلسلة",
|
||||
"SEASON_EPISODE_BADGE": "م{{season}}·ح{{episode}}",
|
||||
"NOT_YET_SYNCED": "مازال ما تزامنش"
|
||||
"NOT_YET_SYNCED": "مازال ما تزامنش",
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Recently watched live TV",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -377,7 +377,24 @@
|
||||
"PORT_PATTERN": "Дапускаюцца толькі лічбы",
|
||||
"REMOVE_ALL_IN_PROGRESS": "Deleting playlist data…",
|
||||
"REMOVE_ALL_PROGRESS": "Deleting playlist data… {{current}} / {{total}}",
|
||||
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again."
|
||||
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again.",
|
||||
"NAV_DASHBOARD": "Dashboard",
|
||||
"DASHBOARD": "Dashboard",
|
||||
"DASHBOARD_SUBTITLE": "Choose which dashboard rails are shown in the workspace.",
|
||||
"DASHBOARD_HERO": "Hero banner",
|
||||
"DASHBOARD_HERO_DESCRIPTION": "Show the large top banner for your most recent item.",
|
||||
"DASHBOARD_RAIL_CONTINUE": "Continue Watching",
|
||||
"DASHBOARD_RAIL_CONTINUE_DESCRIPTION": "Show recently watched movies and series as cover cards.",
|
||||
"DASHBOARD_RAIL_LIVE_FAVORITES": "Live now on your favorites",
|
||||
"DASHBOARD_RAIL_LIVE_FAVORITES_DESCRIPTION": "Show favorite live channels with current EPG progress.",
|
||||
"DASHBOARD_RAIL_RECENT_LIVE": "Recently watched live TV",
|
||||
"DASHBOARD_RAIL_RECENT_LIVE_DESCRIPTION": "Show live channels you opened recently with current EPG progress.",
|
||||
"DASHBOARD_RAIL_FAVORITE_VOD": "Favorite movies & series",
|
||||
"DASHBOARD_RAIL_FAVORITE_VOD_DESCRIPTION": "Show favorited movies and series as cover cards.",
|
||||
"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."
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Цёмная",
|
||||
@@ -890,7 +907,9 @@
|
||||
"TYPE_MOVIE": "Фільм",
|
||||
"TYPE_SERIES": "Серыял",
|
||||
"SEASON_EPISODE_BADGE": "С{{season}}·Э{{episode}}",
|
||||
"NOT_YET_SYNCED": "Яшчэ не сінхранізавана"
|
||||
"NOT_YET_SYNCED": "Яшчэ не сінхранізавана",
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Recently watched live TV",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -377,7 +377,24 @@
|
||||
"PORT_PATTERN": "Nur Ziffern sind erlaubt",
|
||||
"REMOVE_ALL_IN_PROGRESS": "Deleting playlist data…",
|
||||
"REMOVE_ALL_PROGRESS": "Deleting playlist data… {{current}} / {{total}}",
|
||||
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again."
|
||||
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again.",
|
||||
"NAV_DASHBOARD": "Dashboard",
|
||||
"DASHBOARD": "Dashboard",
|
||||
"DASHBOARD_SUBTITLE": "Wählen Sie, welche Dashboard-Rails im Workspace angezeigt werden.",
|
||||
"DASHBOARD_HERO": "Hero-Banner",
|
||||
"DASHBOARD_HERO_DESCRIPTION": "Zeigt den großen oberen Banner für das zuletzt verwendete Element.",
|
||||
"DASHBOARD_RAIL_CONTINUE": "Weiter schauen",
|
||||
"DASHBOARD_RAIL_CONTINUE_DESCRIPTION": "Zeigt zuletzt angesehene Filme und Serien als Cover-Karten.",
|
||||
"DASHBOARD_RAIL_LIVE_FAVORITES": "Jetzt live auf Ihren Favoriten",
|
||||
"DASHBOARD_RAIL_LIVE_FAVORITES_DESCRIPTION": "Zeigt favorisierte Live-Kanäle mit aktuellem EPG-Fortschritt.",
|
||||
"DASHBOARD_RAIL_RECENT_LIVE": "Zuletzt angesehenes Live-TV",
|
||||
"DASHBOARD_RAIL_RECENT_LIVE_DESCRIPTION": "Zeigt Live-Kanäle, die Sie kürzlich geöffnet haben, mit aktuellem EPG-Fortschritt.",
|
||||
"DASHBOARD_RAIL_FAVORITE_VOD": "Favorisierte Filme & Serien",
|
||||
"DASHBOARD_RAIL_FAVORITE_VOD_DESCRIPTION": "Zeigt favorisierte Filme und Serien als Cover-Karten.",
|
||||
"DASHBOARD_RAIL_SOURCES": "Zuletzt verwendete Quellen",
|
||||
"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."
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Dunkel",
|
||||
@@ -890,7 +907,9 @@
|
||||
"TYPE_MOVIE": "Film",
|
||||
"TYPE_SERIES": "Serie",
|
||||
"SEASON_EPISODE_BADGE": "St{{season}} · F{{episode}}",
|
||||
"NOT_YET_SYNCED": "Noch nicht synchronisiert"
|
||||
"NOT_YET_SYNCED": "Noch nicht synchronisiert",
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Zuletzt angesehenes Live-TV",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorisierte Filme & Serien"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -377,7 +377,24 @@
|
||||
"PORT_PATTERN": "Επιτρέπονται μόνο αριθμητικά ψηφία",
|
||||
"REMOVE_ALL_IN_PROGRESS": "Deleting playlist data…",
|
||||
"REMOVE_ALL_PROGRESS": "Deleting playlist data… {{current}} / {{total}}",
|
||||
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again."
|
||||
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again.",
|
||||
"NAV_DASHBOARD": "Dashboard",
|
||||
"DASHBOARD": "Dashboard",
|
||||
"DASHBOARD_SUBTITLE": "Choose which dashboard rails are shown in the workspace.",
|
||||
"DASHBOARD_HERO": "Hero banner",
|
||||
"DASHBOARD_HERO_DESCRIPTION": "Show the large top banner for your most recent item.",
|
||||
"DASHBOARD_RAIL_CONTINUE": "Continue Watching",
|
||||
"DASHBOARD_RAIL_CONTINUE_DESCRIPTION": "Show recently watched movies and series as cover cards.",
|
||||
"DASHBOARD_RAIL_LIVE_FAVORITES": "Live now on your favorites",
|
||||
"DASHBOARD_RAIL_LIVE_FAVORITES_DESCRIPTION": "Show favorite live channels with current EPG progress.",
|
||||
"DASHBOARD_RAIL_RECENT_LIVE": "Recently watched live TV",
|
||||
"DASHBOARD_RAIL_RECENT_LIVE_DESCRIPTION": "Show live channels you opened recently with current EPG progress.",
|
||||
"DASHBOARD_RAIL_FAVORITE_VOD": "Favorite movies & series",
|
||||
"DASHBOARD_RAIL_FAVORITE_VOD_DESCRIPTION": "Show favorited movies and series as cover cards.",
|
||||
"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."
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Σκούρο",
|
||||
@@ -890,7 +907,9 @@
|
||||
"TYPE_MOVIE": "Ταινία",
|
||||
"TYPE_SERIES": "Σειρά",
|
||||
"SEASON_EPISODE_BADGE": "Σ{{season}}·Ε{{episode}}",
|
||||
"NOT_YET_SYNCED": "Δεν έχει συγχρονιστεί ακόμα"
|
||||
"NOT_YET_SYNCED": "Δεν έχει συγχρονιστεί ακόμα",
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Recently watched live TV",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -377,7 +377,24 @@
|
||||
"PORT_PATTERN": "Only digits are allowed",
|
||||
"REMOVE_ALL_IN_PROGRESS": "Deleting playlist data…",
|
||||
"REMOVE_ALL_PROGRESS": "Deleting playlist data… {{current}} / {{total}}",
|
||||
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again."
|
||||
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again.",
|
||||
"NAV_DASHBOARD": "Dashboard",
|
||||
"DASHBOARD": "Dashboard",
|
||||
"DASHBOARD_SUBTITLE": "Choose which dashboard rails are shown in the workspace.",
|
||||
"DASHBOARD_HERO": "Hero banner",
|
||||
"DASHBOARD_HERO_DESCRIPTION": "Show the large top banner for your most recent item.",
|
||||
"DASHBOARD_RAIL_CONTINUE": "Continue Watching",
|
||||
"DASHBOARD_RAIL_CONTINUE_DESCRIPTION": "Show recently watched movies and series as cover cards.",
|
||||
"DASHBOARD_RAIL_LIVE_FAVORITES": "Live now on your favorites",
|
||||
"DASHBOARD_RAIL_LIVE_FAVORITES_DESCRIPTION": "Show favorite live channels with current EPG progress.",
|
||||
"DASHBOARD_RAIL_RECENT_LIVE": "Recently watched live TV",
|
||||
"DASHBOARD_RAIL_RECENT_LIVE_DESCRIPTION": "Show live channels you opened recently with current EPG progress.",
|
||||
"DASHBOARD_RAIL_FAVORITE_VOD": "Favorite movies & series",
|
||||
"DASHBOARD_RAIL_FAVORITE_VOD_DESCRIPTION": "Show favorited movies and series as cover cards.",
|
||||
"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."
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Dark",
|
||||
@@ -890,7 +907,9 @@
|
||||
"TYPE_MOVIE": "Movie",
|
||||
"TYPE_SERIES": "Series",
|
||||
"SEASON_EPISODE_BADGE": "S{{season}}·E{{episode}}",
|
||||
"NOT_YET_SYNCED": "Not yet synced"
|
||||
"NOT_YET_SYNCED": "Not yet synced",
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Recently watched live TV",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -377,7 +377,24 @@
|
||||
"PORT_PATTERN": "Solo se permiten digitos",
|
||||
"REMOVE_ALL_IN_PROGRESS": "Deleting playlist data…",
|
||||
"REMOVE_ALL_PROGRESS": "Deleting playlist data… {{current}} / {{total}}",
|
||||
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again."
|
||||
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again.",
|
||||
"NAV_DASHBOARD": "Dashboard",
|
||||
"DASHBOARD": "Dashboard",
|
||||
"DASHBOARD_SUBTITLE": "Choose which dashboard rails are shown in the workspace.",
|
||||
"DASHBOARD_HERO": "Hero banner",
|
||||
"DASHBOARD_HERO_DESCRIPTION": "Show the large top banner for your most recent item.",
|
||||
"DASHBOARD_RAIL_CONTINUE": "Continue Watching",
|
||||
"DASHBOARD_RAIL_CONTINUE_DESCRIPTION": "Show recently watched movies and series as cover cards.",
|
||||
"DASHBOARD_RAIL_LIVE_FAVORITES": "Live now on your favorites",
|
||||
"DASHBOARD_RAIL_LIVE_FAVORITES_DESCRIPTION": "Show favorite live channels with current EPG progress.",
|
||||
"DASHBOARD_RAIL_RECENT_LIVE": "Recently watched live TV",
|
||||
"DASHBOARD_RAIL_RECENT_LIVE_DESCRIPTION": "Show live channels you opened recently with current EPG progress.",
|
||||
"DASHBOARD_RAIL_FAVORITE_VOD": "Favorite movies & series",
|
||||
"DASHBOARD_RAIL_FAVORITE_VOD_DESCRIPTION": "Show favorited movies and series as cover cards.",
|
||||
"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."
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Oscuro",
|
||||
@@ -890,7 +907,9 @@
|
||||
"TYPE_MOVIE": "Película",
|
||||
"TYPE_SERIES": "Serie",
|
||||
"SEASON_EPISODE_BADGE": "T{{season}}·E{{episode}}",
|
||||
"NOT_YET_SYNCED": "Aún no sincronizado"
|
||||
"NOT_YET_SYNCED": "Aún no sincronizado",
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Recently watched live TV",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -377,7 +377,24 @@
|
||||
"PORT_PATTERN": "Seuls les chiffres sont autorisés",
|
||||
"REMOVE_ALL_IN_PROGRESS": "Deleting playlist data…",
|
||||
"REMOVE_ALL_PROGRESS": "Deleting playlist data… {{current}} / {{total}}",
|
||||
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again."
|
||||
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again.",
|
||||
"NAV_DASHBOARD": "Dashboard",
|
||||
"DASHBOARD": "Dashboard",
|
||||
"DASHBOARD_SUBTITLE": "Choose which dashboard rails are shown in the workspace.",
|
||||
"DASHBOARD_HERO": "Hero banner",
|
||||
"DASHBOARD_HERO_DESCRIPTION": "Show the large top banner for your most recent item.",
|
||||
"DASHBOARD_RAIL_CONTINUE": "Continue Watching",
|
||||
"DASHBOARD_RAIL_CONTINUE_DESCRIPTION": "Show recently watched movies and series as cover cards.",
|
||||
"DASHBOARD_RAIL_LIVE_FAVORITES": "Live now on your favorites",
|
||||
"DASHBOARD_RAIL_LIVE_FAVORITES_DESCRIPTION": "Show favorite live channels with current EPG progress.",
|
||||
"DASHBOARD_RAIL_RECENT_LIVE": "Recently watched live TV",
|
||||
"DASHBOARD_RAIL_RECENT_LIVE_DESCRIPTION": "Show live channels you opened recently with current EPG progress.",
|
||||
"DASHBOARD_RAIL_FAVORITE_VOD": "Favorite movies & series",
|
||||
"DASHBOARD_RAIL_FAVORITE_VOD_DESCRIPTION": "Show favorited movies and series as cover cards.",
|
||||
"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."
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Sombre",
|
||||
@@ -890,7 +907,9 @@
|
||||
"TYPE_MOVIE": "Film",
|
||||
"TYPE_SERIES": "Série",
|
||||
"SEASON_EPISODE_BADGE": "S{{season}}·E{{episode}}",
|
||||
"NOT_YET_SYNCED": "Pas encore synchronisé"
|
||||
"NOT_YET_SYNCED": "Pas encore synchronisé",
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Recently watched live TV",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -377,7 +377,24 @@
|
||||
"PORT_PATTERN": "Sono consentite solo cifre",
|
||||
"REMOVE_ALL_IN_PROGRESS": "Deleting playlist data…",
|
||||
"REMOVE_ALL_PROGRESS": "Deleting playlist data… {{current}} / {{total}}",
|
||||
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again."
|
||||
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again.",
|
||||
"NAV_DASHBOARD": "Dashboard",
|
||||
"DASHBOARD": "Dashboard",
|
||||
"DASHBOARD_SUBTITLE": "Choose which dashboard rails are shown in the workspace.",
|
||||
"DASHBOARD_HERO": "Hero banner",
|
||||
"DASHBOARD_HERO_DESCRIPTION": "Show the large top banner for your most recent item.",
|
||||
"DASHBOARD_RAIL_CONTINUE": "Continue Watching",
|
||||
"DASHBOARD_RAIL_CONTINUE_DESCRIPTION": "Show recently watched movies and series as cover cards.",
|
||||
"DASHBOARD_RAIL_LIVE_FAVORITES": "Live now on your favorites",
|
||||
"DASHBOARD_RAIL_LIVE_FAVORITES_DESCRIPTION": "Show favorite live channels with current EPG progress.",
|
||||
"DASHBOARD_RAIL_RECENT_LIVE": "Recently watched live TV",
|
||||
"DASHBOARD_RAIL_RECENT_LIVE_DESCRIPTION": "Show live channels you opened recently with current EPG progress.",
|
||||
"DASHBOARD_RAIL_FAVORITE_VOD": "Favorite movies & series",
|
||||
"DASHBOARD_RAIL_FAVORITE_VOD_DESCRIPTION": "Show favorited movies and series as cover cards.",
|
||||
"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."
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Scuro",
|
||||
@@ -890,7 +907,9 @@
|
||||
"TYPE_MOVIE": "Film",
|
||||
"TYPE_SERIES": "Serie",
|
||||
"SEASON_EPISODE_BADGE": "S{{season}}·E{{episode}}",
|
||||
"NOT_YET_SYNCED": "Non ancora sincronizzato"
|
||||
"NOT_YET_SYNCED": "Non ancora sincronizzato",
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Recently watched live TV",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -377,7 +377,24 @@
|
||||
"PORT_PATTERN": "数字のみ入力できます",
|
||||
"REMOVE_ALL_IN_PROGRESS": "Deleting playlist data…",
|
||||
"REMOVE_ALL_PROGRESS": "Deleting playlist data… {{current}} / {{total}}",
|
||||
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again."
|
||||
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again.",
|
||||
"NAV_DASHBOARD": "Dashboard",
|
||||
"DASHBOARD": "Dashboard",
|
||||
"DASHBOARD_SUBTITLE": "Choose which dashboard rails are shown in the workspace.",
|
||||
"DASHBOARD_HERO": "Hero banner",
|
||||
"DASHBOARD_HERO_DESCRIPTION": "Show the large top banner for your most recent item.",
|
||||
"DASHBOARD_RAIL_CONTINUE": "Continue Watching",
|
||||
"DASHBOARD_RAIL_CONTINUE_DESCRIPTION": "Show recently watched movies and series as cover cards.",
|
||||
"DASHBOARD_RAIL_LIVE_FAVORITES": "Live now on your favorites",
|
||||
"DASHBOARD_RAIL_LIVE_FAVORITES_DESCRIPTION": "Show favorite live channels with current EPG progress.",
|
||||
"DASHBOARD_RAIL_RECENT_LIVE": "Recently watched live TV",
|
||||
"DASHBOARD_RAIL_RECENT_LIVE_DESCRIPTION": "Show live channels you opened recently with current EPG progress.",
|
||||
"DASHBOARD_RAIL_FAVORITE_VOD": "Favorite movies & series",
|
||||
"DASHBOARD_RAIL_FAVORITE_VOD_DESCRIPTION": "Show favorited movies and series as cover cards.",
|
||||
"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."
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "ダーク",
|
||||
@@ -890,7 +907,9 @@
|
||||
"TYPE_MOVIE": "映画",
|
||||
"TYPE_SERIES": "シリーズ",
|
||||
"SEASON_EPISODE_BADGE": "S{{season}}・E{{episode}}",
|
||||
"NOT_YET_SYNCED": "未同期"
|
||||
"NOT_YET_SYNCED": "未同期",
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Recently watched live TV",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -377,7 +377,24 @@
|
||||
"PORT_PATTERN": "숫자만 입력할 수 있습니다",
|
||||
"REMOVE_ALL_IN_PROGRESS": "Deleting playlist data…",
|
||||
"REMOVE_ALL_PROGRESS": "Deleting playlist data… {{current}} / {{total}}",
|
||||
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again."
|
||||
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again.",
|
||||
"NAV_DASHBOARD": "Dashboard",
|
||||
"DASHBOARD": "Dashboard",
|
||||
"DASHBOARD_SUBTITLE": "Choose which dashboard rails are shown in the workspace.",
|
||||
"DASHBOARD_HERO": "Hero banner",
|
||||
"DASHBOARD_HERO_DESCRIPTION": "Show the large top banner for your most recent item.",
|
||||
"DASHBOARD_RAIL_CONTINUE": "Continue Watching",
|
||||
"DASHBOARD_RAIL_CONTINUE_DESCRIPTION": "Show recently watched movies and series as cover cards.",
|
||||
"DASHBOARD_RAIL_LIVE_FAVORITES": "Live now on your favorites",
|
||||
"DASHBOARD_RAIL_LIVE_FAVORITES_DESCRIPTION": "Show favorite live channels with current EPG progress.",
|
||||
"DASHBOARD_RAIL_RECENT_LIVE": "Recently watched live TV",
|
||||
"DASHBOARD_RAIL_RECENT_LIVE_DESCRIPTION": "Show live channels you opened recently with current EPG progress.",
|
||||
"DASHBOARD_RAIL_FAVORITE_VOD": "Favorite movies & series",
|
||||
"DASHBOARD_RAIL_FAVORITE_VOD_DESCRIPTION": "Show favorited movies and series as cover cards.",
|
||||
"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."
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "어둡게",
|
||||
@@ -890,7 +907,9 @@
|
||||
"TYPE_MOVIE": "영화",
|
||||
"TYPE_SERIES": "시리즈",
|
||||
"SEASON_EPISODE_BADGE": "시즌{{season}} {{episode}}화",
|
||||
"NOT_YET_SYNCED": "아직 동기화되지 않음"
|
||||
"NOT_YET_SYNCED": "아직 동기화되지 않음",
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Recently watched live TV",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -377,7 +377,24 @@
|
||||
"PORT_PATTERN": "Alleen cijfers zijn toegestaan",
|
||||
"REMOVE_ALL_IN_PROGRESS": "Deleting playlist data…",
|
||||
"REMOVE_ALL_PROGRESS": "Deleting playlist data… {{current}} / {{total}}",
|
||||
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again."
|
||||
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again.",
|
||||
"NAV_DASHBOARD": "Dashboard",
|
||||
"DASHBOARD": "Dashboard",
|
||||
"DASHBOARD_SUBTITLE": "Choose which dashboard rails are shown in the workspace.",
|
||||
"DASHBOARD_HERO": "Hero banner",
|
||||
"DASHBOARD_HERO_DESCRIPTION": "Show the large top banner for your most recent item.",
|
||||
"DASHBOARD_RAIL_CONTINUE": "Continue Watching",
|
||||
"DASHBOARD_RAIL_CONTINUE_DESCRIPTION": "Show recently watched movies and series as cover cards.",
|
||||
"DASHBOARD_RAIL_LIVE_FAVORITES": "Live now on your favorites",
|
||||
"DASHBOARD_RAIL_LIVE_FAVORITES_DESCRIPTION": "Show favorite live channels with current EPG progress.",
|
||||
"DASHBOARD_RAIL_RECENT_LIVE": "Recently watched live TV",
|
||||
"DASHBOARD_RAIL_RECENT_LIVE_DESCRIPTION": "Show live channels you opened recently with current EPG progress.",
|
||||
"DASHBOARD_RAIL_FAVORITE_VOD": "Favorite movies & series",
|
||||
"DASHBOARD_RAIL_FAVORITE_VOD_DESCRIPTION": "Show favorited movies and series as cover cards.",
|
||||
"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."
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Donker",
|
||||
@@ -890,7 +907,9 @@
|
||||
"TYPE_MOVIE": "Film",
|
||||
"TYPE_SERIES": "Series",
|
||||
"SEASON_EPISODE_BADGE": "S{{season}}·A{{episode}}",
|
||||
"NOT_YET_SYNCED": "Nog niet gesynchroniseerd"
|
||||
"NOT_YET_SYNCED": "Nog niet gesynchroniseerd",
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Recently watched live TV",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -377,7 +377,24 @@
|
||||
"PORT_PATTERN": "Dozwolone są tylko cyfry",
|
||||
"REMOVE_ALL_IN_PROGRESS": "Deleting playlist data…",
|
||||
"REMOVE_ALL_PROGRESS": "Deleting playlist data… {{current}} / {{total}}",
|
||||
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again."
|
||||
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again.",
|
||||
"NAV_DASHBOARD": "Dashboard",
|
||||
"DASHBOARD": "Dashboard",
|
||||
"DASHBOARD_SUBTITLE": "Choose which dashboard rails are shown in the workspace.",
|
||||
"DASHBOARD_HERO": "Hero banner",
|
||||
"DASHBOARD_HERO_DESCRIPTION": "Show the large top banner for your most recent item.",
|
||||
"DASHBOARD_RAIL_CONTINUE": "Continue Watching",
|
||||
"DASHBOARD_RAIL_CONTINUE_DESCRIPTION": "Show recently watched movies and series as cover cards.",
|
||||
"DASHBOARD_RAIL_LIVE_FAVORITES": "Live now on your favorites",
|
||||
"DASHBOARD_RAIL_LIVE_FAVORITES_DESCRIPTION": "Show favorite live channels with current EPG progress.",
|
||||
"DASHBOARD_RAIL_RECENT_LIVE": "Recently watched live TV",
|
||||
"DASHBOARD_RAIL_RECENT_LIVE_DESCRIPTION": "Show live channels you opened recently with current EPG progress.",
|
||||
"DASHBOARD_RAIL_FAVORITE_VOD": "Favorite movies & series",
|
||||
"DASHBOARD_RAIL_FAVORITE_VOD_DESCRIPTION": "Show favorited movies and series as cover cards.",
|
||||
"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."
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Ciemny",
|
||||
@@ -890,7 +907,9 @@
|
||||
"TYPE_MOVIE": "Film",
|
||||
"TYPE_SERIES": "Serial",
|
||||
"SEASON_EPISODE_BADGE": "S{{season}}·O{{episode}}",
|
||||
"NOT_YET_SYNCED": "Jeszcze niezsynchronizowane"
|
||||
"NOT_YET_SYNCED": "Jeszcze niezsynchronizowane",
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Recently watched live TV",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -377,7 +377,24 @@
|
||||
"PORT_PATTERN": "Apenas dígitos são permitidos",
|
||||
"REMOVE_ALL_IN_PROGRESS": "Deleting playlist data…",
|
||||
"REMOVE_ALL_PROGRESS": "Deleting playlist data… {{current}} / {{total}}",
|
||||
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again."
|
||||
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again.",
|
||||
"NAV_DASHBOARD": "Dashboard",
|
||||
"DASHBOARD": "Dashboard",
|
||||
"DASHBOARD_SUBTITLE": "Choose which dashboard rails are shown in the workspace.",
|
||||
"DASHBOARD_HERO": "Hero banner",
|
||||
"DASHBOARD_HERO_DESCRIPTION": "Show the large top banner for your most recent item.",
|
||||
"DASHBOARD_RAIL_CONTINUE": "Continue Watching",
|
||||
"DASHBOARD_RAIL_CONTINUE_DESCRIPTION": "Show recently watched movies and series as cover cards.",
|
||||
"DASHBOARD_RAIL_LIVE_FAVORITES": "Live now on your favorites",
|
||||
"DASHBOARD_RAIL_LIVE_FAVORITES_DESCRIPTION": "Show favorite live channels with current EPG progress.",
|
||||
"DASHBOARD_RAIL_RECENT_LIVE": "Recently watched live TV",
|
||||
"DASHBOARD_RAIL_RECENT_LIVE_DESCRIPTION": "Show live channels you opened recently with current EPG progress.",
|
||||
"DASHBOARD_RAIL_FAVORITE_VOD": "Favorite movies & series",
|
||||
"DASHBOARD_RAIL_FAVORITE_VOD_DESCRIPTION": "Show favorited movies and series as cover cards.",
|
||||
"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."
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Escuro",
|
||||
@@ -890,7 +907,9 @@
|
||||
"TYPE_MOVIE": "Filme",
|
||||
"TYPE_SERIES": "Série",
|
||||
"SEASON_EPISODE_BADGE": "T{{season}}·E{{episode}}",
|
||||
"NOT_YET_SYNCED": "Ainda não sincronizado"
|
||||
"NOT_YET_SYNCED": "Ainda não sincronizado",
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Recently watched live TV",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -377,7 +377,24 @@
|
||||
"PORT_PATTERN": "Разрешены только цифры",
|
||||
"REMOVE_ALL_IN_PROGRESS": "Deleting playlist data…",
|
||||
"REMOVE_ALL_PROGRESS": "Deleting playlist data… {{current}} / {{total}}",
|
||||
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again."
|
||||
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again.",
|
||||
"NAV_DASHBOARD": "Панель",
|
||||
"DASHBOARD": "Панель",
|
||||
"DASHBOARD_SUBTITLE": "Выберите, какие секции отображаются на панели рабочего пространства.",
|
||||
"DASHBOARD_HERO": "Верхний баннер",
|
||||
"DASHBOARD_HERO_DESCRIPTION": "Показывать большой верхний баннер для последнего элемента.",
|
||||
"DASHBOARD_RAIL_CONTINUE": "Продолжить просмотр",
|
||||
"DASHBOARD_RAIL_CONTINUE_DESCRIPTION": "Показывать недавно просмотренные фильмы и сериалы в виде обложек.",
|
||||
"DASHBOARD_RAIL_LIVE_FAVORITES": "Сейчас в эфире на избранных каналах",
|
||||
"DASHBOARD_RAIL_LIVE_FAVORITES_DESCRIPTION": "Показывать избранные live-каналы с текущим прогрессом EPG.",
|
||||
"DASHBOARD_RAIL_RECENT_LIVE": "Недавно просмотренное прямое ТВ",
|
||||
"DASHBOARD_RAIL_RECENT_LIVE_DESCRIPTION": "Показывать live-каналы, которые вы недавно открывали, с текущим прогрессом EPG.",
|
||||
"DASHBOARD_RAIL_FAVORITE_VOD": "Избранные фильмы и сериалы",
|
||||
"DASHBOARD_RAIL_FAVORITE_VOD_DESCRIPTION": "Показывать избранные фильмы и сериалы в виде обложек.",
|
||||
"DASHBOARD_RAIL_SOURCES": "Недавно использованные источники",
|
||||
"DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Показывать быстрый доступ к недавно использованным плейлистам и порталам.",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED": "Недавно добавленное на Xtream",
|
||||
"DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Показывать новые фильмы, сериалы и каналы Xtream."
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Тёмная",
|
||||
@@ -890,7 +907,9 @@
|
||||
"TYPE_MOVIE": "Фильм",
|
||||
"TYPE_SERIES": "Сериал",
|
||||
"SEASON_EPISODE_BADGE": "С{{season}}·Э{{episode}}",
|
||||
"NOT_YET_SYNCED": "Еще не синхронизировано"
|
||||
"NOT_YET_SYNCED": "Еще не синхронизировано",
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Недавно просмотренное прямое ТВ",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Избранные фильмы и сериалы"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -377,7 +377,24 @@
|
||||
"PORT_PATTERN": "Sadece rakamlara izin verilir",
|
||||
"REMOVE_ALL_IN_PROGRESS": "Deleting playlist data…",
|
||||
"REMOVE_ALL_PROGRESS": "Deleting playlist data… {{current}} / {{total}}",
|
||||
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again."
|
||||
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again.",
|
||||
"NAV_DASHBOARD": "Dashboard",
|
||||
"DASHBOARD": "Dashboard",
|
||||
"DASHBOARD_SUBTITLE": "Choose which dashboard rails are shown in the workspace.",
|
||||
"DASHBOARD_HERO": "Hero banner",
|
||||
"DASHBOARD_HERO_DESCRIPTION": "Show the large top banner for your most recent item.",
|
||||
"DASHBOARD_RAIL_CONTINUE": "Continue Watching",
|
||||
"DASHBOARD_RAIL_CONTINUE_DESCRIPTION": "Show recently watched movies and series as cover cards.",
|
||||
"DASHBOARD_RAIL_LIVE_FAVORITES": "Live now on your favorites",
|
||||
"DASHBOARD_RAIL_LIVE_FAVORITES_DESCRIPTION": "Show favorite live channels with current EPG progress.",
|
||||
"DASHBOARD_RAIL_RECENT_LIVE": "Recently watched live TV",
|
||||
"DASHBOARD_RAIL_RECENT_LIVE_DESCRIPTION": "Show live channels you opened recently with current EPG progress.",
|
||||
"DASHBOARD_RAIL_FAVORITE_VOD": "Favorite movies & series",
|
||||
"DASHBOARD_RAIL_FAVORITE_VOD_DESCRIPTION": "Show favorited movies and series as cover cards.",
|
||||
"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."
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "Koyu",
|
||||
@@ -890,7 +907,9 @@
|
||||
"TYPE_MOVIE": "Film",
|
||||
"TYPE_SERIES": "Dizi",
|
||||
"SEASON_EPISODE_BADGE": "S{{season}}·B{{episode}}",
|
||||
"NOT_YET_SYNCED": "Henüz eşitlenmedi"
|
||||
"NOT_YET_SYNCED": "Henüz eşitlenmedi",
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Recently watched live TV",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -377,7 +377,24 @@
|
||||
"PORT_PATTERN": "仅允许输入数字",
|
||||
"REMOVE_ALL_IN_PROGRESS": "Deleting playlist data…",
|
||||
"REMOVE_ALL_PROGRESS": "Deleting playlist data… {{current}} / {{total}}",
|
||||
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again."
|
||||
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again.",
|
||||
"NAV_DASHBOARD": "Dashboard",
|
||||
"DASHBOARD": "Dashboard",
|
||||
"DASHBOARD_SUBTITLE": "Choose which dashboard rails are shown in the workspace.",
|
||||
"DASHBOARD_HERO": "Hero banner",
|
||||
"DASHBOARD_HERO_DESCRIPTION": "Show the large top banner for your most recent item.",
|
||||
"DASHBOARD_RAIL_CONTINUE": "Continue Watching",
|
||||
"DASHBOARD_RAIL_CONTINUE_DESCRIPTION": "Show recently watched movies and series as cover cards.",
|
||||
"DASHBOARD_RAIL_LIVE_FAVORITES": "Live now on your favorites",
|
||||
"DASHBOARD_RAIL_LIVE_FAVORITES_DESCRIPTION": "Show favorite live channels with current EPG progress.",
|
||||
"DASHBOARD_RAIL_RECENT_LIVE": "Recently watched live TV",
|
||||
"DASHBOARD_RAIL_RECENT_LIVE_DESCRIPTION": "Show live channels you opened recently with current EPG progress.",
|
||||
"DASHBOARD_RAIL_FAVORITE_VOD": "Favorite movies & series",
|
||||
"DASHBOARD_RAIL_FAVORITE_VOD_DESCRIPTION": "Show favorited movies and series as cover cards.",
|
||||
"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."
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "深色",
|
||||
@@ -890,7 +907,9 @@
|
||||
"TYPE_MOVIE": "电影",
|
||||
"TYPE_SERIES": "剧集",
|
||||
"SEASON_EPISODE_BADGE": "S{{season}}·E{{episode}}",
|
||||
"NOT_YET_SYNCED": "尚未同步"
|
||||
"NOT_YET_SYNCED": "尚未同步",
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Recently watched live TV",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -377,7 +377,24 @@
|
||||
"PORT_PATTERN": "僅允許數字",
|
||||
"REMOVE_ALL_IN_PROGRESS": "Deleting playlist data…",
|
||||
"REMOVE_ALL_PROGRESS": "Deleting playlist data… {{current}} / {{total}}",
|
||||
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again."
|
||||
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again.",
|
||||
"NAV_DASHBOARD": "Dashboard",
|
||||
"DASHBOARD": "Dashboard",
|
||||
"DASHBOARD_SUBTITLE": "Choose which dashboard rails are shown in the workspace.",
|
||||
"DASHBOARD_HERO": "Hero banner",
|
||||
"DASHBOARD_HERO_DESCRIPTION": "Show the large top banner for your most recent item.",
|
||||
"DASHBOARD_RAIL_CONTINUE": "Continue Watching",
|
||||
"DASHBOARD_RAIL_CONTINUE_DESCRIPTION": "Show recently watched movies and series as cover cards.",
|
||||
"DASHBOARD_RAIL_LIVE_FAVORITES": "Live now on your favorites",
|
||||
"DASHBOARD_RAIL_LIVE_FAVORITES_DESCRIPTION": "Show favorite live channels with current EPG progress.",
|
||||
"DASHBOARD_RAIL_RECENT_LIVE": "Recently watched live TV",
|
||||
"DASHBOARD_RAIL_RECENT_LIVE_DESCRIPTION": "Show live channels you opened recently with current EPG progress.",
|
||||
"DASHBOARD_RAIL_FAVORITE_VOD": "Favorite movies & series",
|
||||
"DASHBOARD_RAIL_FAVORITE_VOD_DESCRIPTION": "Show favorited movies and series as cover cards.",
|
||||
"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."
|
||||
},
|
||||
"THEMES": {
|
||||
"DARK_THEME": "深色",
|
||||
@@ -890,7 +907,9 @@
|
||||
"TYPE_MOVIE": "電影",
|
||||
"TYPE_SERIES": "影集",
|
||||
"SEASON_EPISODE_BADGE": "S{{season}}·E{{episode}}",
|
||||
"NOT_YET_SYNCED": "尚未同步"
|
||||
"NOT_YET_SYNCED": "尚未同步",
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Recently watched live TV",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { Injector } from '@angular/core';
|
||||
import { StorageMap } from '@ngx-pwa/local-storage';
|
||||
import { of } from 'rxjs';
|
||||
import {
|
||||
DashboardRailsSettings,
|
||||
Language,
|
||||
Settings,
|
||||
StartupBehavior,
|
||||
STORE_KEY,
|
||||
StreamFormat,
|
||||
Theme,
|
||||
VideoPlayer,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { SettingsStore } from './settings-store.service';
|
||||
|
||||
const expectedDashboardRails = (
|
||||
overrides: Partial<DashboardRailsSettings> = {}
|
||||
): DashboardRailsSettings => ({
|
||||
hero: true,
|
||||
continueWatching: true,
|
||||
liveFavorites: true,
|
||||
recentlyWatchedLive: true,
|
||||
favoriteMoviesAndSeries: true,
|
||||
recentSources: true,
|
||||
xtreamRecentlyAdded: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('SettingsStore dashboard rail settings', () => {
|
||||
let storedSettings: Partial<Settings> | null;
|
||||
let injector: Injector;
|
||||
let storage: {
|
||||
get: jest.Mock;
|
||||
set: jest.Mock;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
storedSettings = null;
|
||||
storage = {
|
||||
get: jest.fn(() => of(storedSettings)),
|
||||
set: jest.fn(() => of(undefined)),
|
||||
};
|
||||
|
||||
injector = Injector.create({
|
||||
providers: [
|
||||
SettingsStore,
|
||||
{
|
||||
provide: StorageMap,
|
||||
useValue: storage,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('uses enabled dashboard rail defaults when no settings are stored', async () => {
|
||||
const store = injector.get(SettingsStore);
|
||||
|
||||
await store.loadSettings();
|
||||
|
||||
expect(store.getSettings().dashboardRails).toEqual(
|
||||
expectedDashboardRails()
|
||||
);
|
||||
});
|
||||
|
||||
it('deep-merges partial stored dashboard rail settings with enabled defaults', async () => {
|
||||
storedSettings = {
|
||||
player: VideoPlayer.VideoJs,
|
||||
streamFormat: StreamFormat.M3u8StreamFormat,
|
||||
openStreamOnDoubleClick: false,
|
||||
language: Language.ENGLISH,
|
||||
showCaptions: false,
|
||||
showDashboard: true,
|
||||
startupBehavior: StartupBehavior.FirstView,
|
||||
showExternalPlaybackBar: true,
|
||||
theme: Theme.SystemTheme,
|
||||
mpvPlayerPath: '',
|
||||
mpvPlayerArguments: '',
|
||||
mpvReuseInstance: false,
|
||||
vlcPlayerPath: '',
|
||||
vlcPlayerArguments: '',
|
||||
vlcReuseInstance: false,
|
||||
remoteControl: false,
|
||||
remoteControlPort: 8765,
|
||||
epgUrl: [],
|
||||
dashboardRails: {
|
||||
recentSources: false,
|
||||
},
|
||||
} as unknown as Partial<Settings>;
|
||||
|
||||
const store = injector.get(SettingsStore);
|
||||
|
||||
await store.loadSettings();
|
||||
await store.updateSettings({
|
||||
dashboardRails: {
|
||||
...store.getSettings().dashboardRails,
|
||||
liveFavorites: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(store.getSettings().dashboardRails).toEqual(
|
||||
expectedDashboardRails({
|
||||
liveFavorites: false,
|
||||
recentSources: false,
|
||||
})
|
||||
);
|
||||
expect(storage.set).toHaveBeenCalledWith(
|
||||
STORE_KEY.Settings,
|
||||
expect.objectContaining({
|
||||
dashboardRails: expectedDashboardRails({
|
||||
liveFavorites: false,
|
||||
recentSources: false,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { StorageMap } from '@ngx-pwa/local-storage';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import {
|
||||
DEFAULT_DASHBOARD_RAILS_SETTINGS,
|
||||
ElectronBridgeTrustOptions,
|
||||
Language,
|
||||
Settings,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
StreamFormat,
|
||||
Theme,
|
||||
VideoPlayer,
|
||||
normalizeDashboardRailsSettings,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
const DEFAULT_SETTINGS: Settings = {
|
||||
@@ -41,6 +43,7 @@ const DEFAULT_SETTINGS: Settings = {
|
||||
downloadFolder: '',
|
||||
recordingFolder: '',
|
||||
coverSize: 'medium',
|
||||
dashboardRails: DEFAULT_DASHBOARD_RAILS_SETTINGS,
|
||||
preferUploadedEpgOverXtream: false,
|
||||
trustedPrivateNetworkEpgUrls: [],
|
||||
trustedInsecureTlsHosts: [],
|
||||
@@ -95,9 +98,13 @@ export const SettingsStore = signalStore(
|
||||
storage.get(STORE_KEY.Settings)
|
||||
);
|
||||
if (stored) {
|
||||
const storedSettings = stored as Partial<Settings>;
|
||||
patchState(store, {
|
||||
...DEFAULT_SETTINGS,
|
||||
...(stored as Settings),
|
||||
...storedSettings,
|
||||
dashboardRails: normalizeDashboardRailsSettings(
|
||||
storedSettings.dashboardRails
|
||||
),
|
||||
});
|
||||
void this.sanitizeEmbeddedMpvSelection().catch((error) => {
|
||||
console.warn(
|
||||
@@ -113,7 +120,16 @@ export const SettingsStore = signalStore(
|
||||
},
|
||||
|
||||
async updateSettings(settings: Partial<Settings>) {
|
||||
patchState(store, settings);
|
||||
patchState(store, {
|
||||
...settings,
|
||||
...(settings.dashboardRails !== undefined
|
||||
? {
|
||||
dashboardRails: normalizeDashboardRailsSettings(
|
||||
settings.dashboardRails
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
// Save the complete settings object, not just the partial update
|
||||
const completeSettings = this.getSettings();
|
||||
try {
|
||||
@@ -157,6 +173,9 @@ export const SettingsStore = signalStore(
|
||||
store.recordingFolder?.() ??
|
||||
DEFAULT_SETTINGS.recordingFolder,
|
||||
coverSize: store.coverSize?.() ?? DEFAULT_SETTINGS.coverSize,
|
||||
dashboardRails: normalizeDashboardRailsSettings(
|
||||
store.dashboardRails?.()
|
||||
),
|
||||
preferUploadedEpgOverXtream:
|
||||
store.preferUploadedEpgOverXtream?.() ??
|
||||
DEFAULT_SETTINGS.preferUploadedEpgOverXtream,
|
||||
|
||||
@@ -21,6 +21,51 @@ export enum StartupBehavior {
|
||||
|
||||
export type CoverSize = 'small' | 'medium' | 'large';
|
||||
|
||||
export interface DashboardRailsSettings {
|
||||
hero: boolean;
|
||||
continueWatching: boolean;
|
||||
liveFavorites: boolean;
|
||||
recentlyWatchedLive: boolean;
|
||||
favoriteMoviesAndSeries: boolean;
|
||||
recentSources: boolean;
|
||||
xtreamRecentlyAdded: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_DASHBOARD_RAILS_SETTINGS: DashboardRailsSettings = {
|
||||
hero: true,
|
||||
continueWatching: true,
|
||||
liveFavorites: true,
|
||||
recentlyWatchedLive: true,
|
||||
favoriteMoviesAndSeries: true,
|
||||
recentSources: true,
|
||||
xtreamRecentlyAdded: true,
|
||||
};
|
||||
|
||||
export type DashboardRailsSettingsInput = Partial<
|
||||
Record<keyof DashboardRailsSettings, boolean | null | undefined>
|
||||
>;
|
||||
|
||||
export function normalizeDashboardRailsSettings(
|
||||
settings?: DashboardRailsSettingsInput | null
|
||||
): DashboardRailsSettings {
|
||||
const normalized = { ...DEFAULT_DASHBOARD_RAILS_SETTINGS };
|
||||
|
||||
if (!settings) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const keys = Object.keys(
|
||||
DEFAULT_DASHBOARD_RAILS_SETTINGS
|
||||
) as (keyof DashboardRailsSettings)[];
|
||||
for (const key of keys) {
|
||||
if (typeof settings[key] === 'boolean') {
|
||||
normalized[key] = settings[key];
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes all available settings options of the application
|
||||
*/
|
||||
@@ -58,6 +103,8 @@ export interface Settings {
|
||||
recordingFolder?: string;
|
||||
/** Cover/poster sizing preset applied across grids and rails */
|
||||
coverSize?: CoverSize;
|
||||
/** Per-rail dashboard visibility preferences. Missing keys default on. */
|
||||
dashboardRails?: DashboardRailsSettings;
|
||||
/**
|
||||
* When true, the locally-parsed XMLTV programs (loaded from `epgUrl`)
|
||||
* take precedence over the Xtream provider's EPG for live TV channels.
|
||||
|
||||
@@ -265,10 +265,8 @@ export class DashboardDataService {
|
||||
this.globalRecentItems().filter((item) => item.type === 'live')
|
||||
);
|
||||
|
||||
// Favorited live channels — the dashboard's "Live now" rail prefers this
|
||||
// source so the EPG enrichment lands on the channels users actually care
|
||||
// about. Falls back to globalRecentLiveItems when this is empty so a
|
||||
// fresh-install user still sees something useful.
|
||||
// Favorited live channels — the dashboard's "Live now" rail uses this
|
||||
// source only. Recently watched live channels have their own dashboard rail.
|
||||
readonly globalFavoriteLiveItems = computed<DashboardFavoriteItem[]>(() =>
|
||||
this.globalFavoriteItems().filter((item) => item.type === 'live')
|
||||
);
|
||||
|
||||
@@ -1,3 +1,55 @@
|
||||
# feature
|
||||
# Workspace Dashboard Feature
|
||||
|
||||
This library was generated with [Nx](https://nx.dev).
|
||||
This library owns the workspace dashboard rails UI. The dashboard is a
|
||||
read-only surface over existing playlist, recent, favorites, EPG, and Xtream
|
||||
catalog data; it should not introduce Electron IPC, SQLite schema, or route
|
||||
contracts on its own.
|
||||
|
||||
## Dashboard Surfaces
|
||||
|
||||
The dashboard renders a surface only when the matching setting is enabled. Data
|
||||
rails also require the underlying data slice to have at least one item.
|
||||
|
||||
- `hero` shows the large top banner for the most recent global item. When that
|
||||
item is a live TV channel, the hero looks up the current XMLTV programme and
|
||||
displays the programme title, time range, and EPG progress bar when data is
|
||||
available.
|
||||
- `continueWatching` shows recent movies and series from
|
||||
`DashboardDataService.globalRecentVodItems()` using cover cards with playback
|
||||
progress when a saved resume position is available.
|
||||
- `liveFavorites` shows favorited live TV channels from
|
||||
`DashboardDataService.globalFavoriteLiveItems()` using the channel layout
|
||||
with EPG title, time range, and progress when XMLTV data is available.
|
||||
- `recentlyWatchedLive` shows recently watched live TV channels from
|
||||
`DashboardDataService.globalRecentLiveItems()` using the same channel layout.
|
||||
- `favoriteMoviesAndSeries` shows favorited movies and series from
|
||||
`DashboardDataService.globalFavoriteItems()`, excluding live favorites, using
|
||||
cover cards.
|
||||
- `recentSources` shows recently used playlist/source entries.
|
||||
- `xtreamRecentlyAdded` shows recently added Xtream catalog items.
|
||||
|
||||
## Settings
|
||||
|
||||
Per-surface visibility lives on `Settings.dashboardRails` as a
|
||||
`DashboardRailsSettings` object. Every surface defaults to enabled. Stored
|
||||
settings are deep-merged with `DEFAULT_DASHBOARD_RAILS_SETTINGS` by
|
||||
`SettingsStore`, so existing users and older partial settings keep newly added
|
||||
surfaces visible unless they explicitly turn them off.
|
||||
|
||||
The global `showDashboard` flag remains a top-level `Settings` property because
|
||||
workspace startup and route guards already depend on that contract. The Settings
|
||||
UI groups `showDashboard` and the per-surface checkboxes in the Dashboard
|
||||
section. When `showDashboard` is off, the per-surface checkboxes are disabled
|
||||
because the dashboard route itself is hidden.
|
||||
|
||||
## Navigation
|
||||
|
||||
Rail cards use the navigation state provided by `DashboardDataService` for the
|
||||
underlying item. Rail "See all" links may also pass router state:
|
||||
|
||||
- live rails open the relevant collection on Live TV.
|
||||
- cover rails for movies/series open Global Recent or Global Favorites on
|
||||
Movies when movie items are present, otherwise on Series.
|
||||
|
||||
This keeps the global collection pages from defaulting to Live TV when a
|
||||
dashboard rail is clearly about movies or series.
|
||||
@@ -12,6 +12,7 @@
|
||||
<a
|
||||
class="rail__see-all"
|
||||
[routerLink]="link"
|
||||
[state]="seeAllState() ?? {}"
|
||||
[attr.data-test-id]="testId() ? testId() + '-manage-all' : null"
|
||||
>
|
||||
<span class="rail__see-all-label">
|
||||
|
||||
@@ -96,6 +96,7 @@ export class DashboardRailComponent implements AfterViewInit, OnDestroy {
|
||||
readonly label = input.required<string>();
|
||||
readonly items = input.required<DashboardRailCard[]>();
|
||||
readonly seeAllLink = input<string[] | null>(null);
|
||||
readonly seeAllState = input<Record<string, unknown> | null>(null);
|
||||
readonly aspectRatio = input<string>('2 / 3');
|
||||
readonly layout = input<DashboardRailLayout>('cover');
|
||||
readonly testId = input<string | null>(null);
|
||||
|
||||
+205
-120
@@ -14,150 +14,237 @@
|
||||
loading the first global recent item, nothing if no recent items
|
||||
exist. Each block below renders as soon as ITS data is ready —
|
||||
no longer pinned on the slowest rail's I/O. -->
|
||||
@if (hero(); as item) {
|
||||
<a
|
||||
class="hero"
|
||||
data-test-id="dashboard-hero"
|
||||
[class.hero--with-image]="
|
||||
!!item.posterUrl || !!item.backdropUrl
|
||||
"
|
||||
[class.hero--has-backdrop]="item.hasBackdrop"
|
||||
[class.hero--has-default-backdrop]="
|
||||
item.backdropSource === 'fallback'
|
||||
"
|
||||
[class.hero--live]="item.contentType === 'live'"
|
||||
[routerLink]="item.link"
|
||||
[state]="item.state ?? {}"
|
||||
>
|
||||
<span
|
||||
class="hero__backdrop"
|
||||
aria-hidden="true"
|
||||
[style.backgroundImage]="
|
||||
item.backdropUrl
|
||||
? 'url(' + item.backdropUrl + ')'
|
||||
: item.fallbackBackdropBackground
|
||||
@if (dashboardRails().hero) {
|
||||
@if (hero(); as item) {
|
||||
<a
|
||||
class="hero"
|
||||
data-test-id="dashboard-hero"
|
||||
[class.hero--with-image]="
|
||||
!!item.posterUrl || !!item.backdropUrl
|
||||
"
|
||||
></span>
|
||||
@if (item.backdropUrl; as backdropUrl) {
|
||||
<img
|
||||
class="hero__image-probe"
|
||||
[src]="backdropUrl"
|
||||
alt=""
|
||||
[class.hero--has-backdrop]="item.hasBackdrop"
|
||||
[class.hero--has-default-backdrop]="
|
||||
item.backdropSource === 'fallback'
|
||||
"
|
||||
[class.hero--live]="item.contentType === 'live'"
|
||||
[routerLink]="item.link"
|
||||
[state]="item.state ?? {}"
|
||||
>
|
||||
<span
|
||||
class="hero__backdrop"
|
||||
aria-hidden="true"
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
(error)="markHeroImageFailed(backdropUrl)"
|
||||
/>
|
||||
}
|
||||
<span class="hero__vignette" aria-hidden="true"></span>
|
||||
|
||||
<span class="hero__poster">
|
||||
@if (item.posterUrl; as posterUrl) {
|
||||
[style.backgroundImage]="
|
||||
item.backdropUrl
|
||||
? 'url(' + item.backdropUrl + ')'
|
||||
: item.fallbackBackdropBackground
|
||||
"
|
||||
></span>
|
||||
@if (item.backdropUrl; as backdropUrl) {
|
||||
<img
|
||||
[src]="posterUrl"
|
||||
[alt]="item.title"
|
||||
class="hero__image-probe"
|
||||
[src]="backdropUrl"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
(error)="markHeroImageFailed(posterUrl)"
|
||||
(error)="markHeroImageFailed(backdropUrl)"
|
||||
/>
|
||||
} @else {
|
||||
<span
|
||||
class="hero__poster-fallback"
|
||||
[style.backgroundImage]="
|
||||
item.fallbackPosterBackground
|
||||
"
|
||||
>
|
||||
<mat-icon>{{ item.icon }}</mat-icon>
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
<span class="hero__vignette" aria-hidden="true"></span>
|
||||
|
||||
<span class="hero__body">
|
||||
<h1 class="hero__title">{{ item.title }}</h1>
|
||||
<p class="hero__subtitle">{{ item.subtitle }}</p>
|
||||
|
||||
@if (item.watchProgress !== null && item.watchProgress !== undefined) {
|
||||
<span class="hero__progress" aria-hidden="true">
|
||||
<span class="hero__poster">
|
||||
@if (item.posterUrl; as posterUrl) {
|
||||
<img
|
||||
[src]="posterUrl"
|
||||
[alt]="item.title"
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
(error)="markHeroImageFailed(posterUrl)"
|
||||
/>
|
||||
} @else {
|
||||
<span
|
||||
class="hero__progress-bar"
|
||||
[style.width.%]="item.watchProgress"
|
||||
></span>
|
||||
</span>
|
||||
<span class="hero__progress-meta">
|
||||
@if (item.remainingLabel) {
|
||||
<span>{{
|
||||
item.remainingLabel.key
|
||||
| translate: item.remainingLabel.params
|
||||
}}</span>
|
||||
<span class="hero__progress-meta-sep"
|
||||
>·</span
|
||||
class="hero__poster-fallback"
|
||||
[style.backgroundImage]="
|
||||
item.fallbackPosterBackground
|
||||
"
|
||||
>
|
||||
<mat-icon>{{ item.icon }}</mat-icon>
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
|
||||
<span class="hero__body">
|
||||
<h1 class="hero__title">{{ item.title }}</h1>
|
||||
<p class="hero__subtitle">{{ item.subtitle }}</p>
|
||||
|
||||
@if (
|
||||
item.contentType === 'live' &&
|
||||
(item.nowPlayingTitle ||
|
||||
item.nowPlayingTimeRange ||
|
||||
(item.nowPlayingProgress !== null &&
|
||||
item.nowPlayingProgress !== undefined))
|
||||
) {
|
||||
<span
|
||||
class="hero__live-epg"
|
||||
data-test-id="dashboard-hero-live-epg"
|
||||
>
|
||||
<span class="hero__live-epg-topline">
|
||||
<span class="hero__live-epg-chip"
|
||||
>LIVE</span
|
||||
>
|
||||
@if (item.nowPlayingTimeRange) {
|
||||
<span class="hero__live-epg-time">{{
|
||||
item.nowPlayingTimeRange
|
||||
}}</span>
|
||||
}
|
||||
</span>
|
||||
@if (item.nowPlayingTitle) {
|
||||
<span class="hero__live-epg-title">{{
|
||||
item.nowPlayingTitle
|
||||
}}</span>
|
||||
}
|
||||
<span
|
||||
class="hero__live-epg-progress"
|
||||
[class.hero__live-epg-progress--idle]="
|
||||
item.nowPlayingProgress === null ||
|
||||
item.nowPlayingProgress === undefined
|
||||
"
|
||||
aria-hidden="true"
|
||||
>
|
||||
}
|
||||
<i
|
||||
[style.width.%]="
|
||||
item.nowPlayingProgress ?? 0
|
||||
"
|
||||
></i>
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
|
||||
@if (item.watchProgress !== null && item.watchProgress !== undefined) {
|
||||
<span class="hero__progress" aria-hidden="true">
|
||||
<span
|
||||
class="hero__progress-bar"
|
||||
[style.width.%]="item.watchProgress"
|
||||
></span>
|
||||
</span>
|
||||
<span class="hero__progress-meta">
|
||||
@if (item.remainingLabel) {
|
||||
<span>{{
|
||||
item.remainingLabel.key
|
||||
| translate
|
||||
: item.remainingLabel.params
|
||||
}}</span>
|
||||
<span class="hero__progress-meta-sep"
|
||||
>·</span
|
||||
>
|
||||
}
|
||||
<span>{{
|
||||
'WORKSPACE.DASHBOARD.PERCENT_WATCHED'
|
||||
| translate
|
||||
: { value: item.watchProgress }
|
||||
}}</span>
|
||||
</span>
|
||||
}
|
||||
|
||||
<span class="hero__cta">
|
||||
<mat-icon>play_arrow</mat-icon>
|
||||
<span>{{
|
||||
'WORKSPACE.DASHBOARD.PERCENT_WATCHED'
|
||||
| translate: { value: item.watchProgress }
|
||||
'WORKSPACE.DASHBOARD.CONTINUE_WATCHING'
|
||||
| translate
|
||||
}}</span>
|
||||
</span>
|
||||
}
|
||||
|
||||
<span class="hero__cta">
|
||||
<mat-icon>play_arrow</mat-icon>
|
||||
<span>{{
|
||||
'WORKSPACE.DASHBOARD.CONTINUE_WATCHING' | translate
|
||||
}}</span>
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
} @else if (data.globalRecentLoading()) {
|
||||
<section
|
||||
class="rails-page__skeleton-hero"
|
||||
aria-hidden="true"
|
||||
aria-busy="true"
|
||||
>
|
||||
<div class="rails-page__skeleton-hero-poster"></div>
|
||||
<div class="rails-page__skeleton-hero-body">
|
||||
<div class="rails-page__skeleton-hero-title"></div>
|
||||
<div
|
||||
class="rails-page__skeleton-hero-title rails-page__skeleton-hero-title--short"
|
||||
></div>
|
||||
<div class="rails-page__skeleton-hero-subtitle"></div>
|
||||
<div class="rails-page__skeleton-hero-cta"></div>
|
||||
</div>
|
||||
</section>
|
||||
</a>
|
||||
} @else if (data.globalRecentLoading()) {
|
||||
<section
|
||||
class="rails-page__skeleton-hero"
|
||||
aria-hidden="true"
|
||||
aria-busy="true"
|
||||
>
|
||||
<div class="rails-page__skeleton-hero-poster"></div>
|
||||
<div class="rails-page__skeleton-hero-body">
|
||||
<div class="rails-page__skeleton-hero-title"></div>
|
||||
<div
|
||||
class="rails-page__skeleton-hero-title rails-page__skeleton-hero-title--short"
|
||||
></div>
|
||||
<div class="rails-page__skeleton-hero-subtitle"></div>
|
||||
<div class="rails-page__skeleton-hero-cta"></div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- Continue watching (movies + series) -->
|
||||
@if (continueWatchingCards().length > 0) {
|
||||
@if (
|
||||
dashboardRails().continueWatching &&
|
||||
continueWatchingCards().length > 0
|
||||
) {
|
||||
<lib-dashboard-rail
|
||||
[label]="'WORKSPACE.DASHBOARD.CONTINUE_WATCHING' | translate"
|
||||
[items]="continueWatchingCards()"
|
||||
[totalCount]="data.globalRecentVodItems().length"
|
||||
[seeAllLink]="['/workspace/global-recent']"
|
||||
[seeAllState]="continueWatchingSeeAllState()"
|
||||
[testId]="'dashboard-continue-watching-rail'"
|
||||
/>
|
||||
}
|
||||
|
||||
<!-- Live rail — favorited live channels first, falls back to
|
||||
recently-watched live. Title flips based on source so the label
|
||||
is always honest about what's rendered. -->
|
||||
@if (liveOnFavoritesCardsEnriched().length > 0) {
|
||||
<!-- Live favorites: no fallback. Recently watched live gets its own rail. -->
|
||||
@if (
|
||||
dashboardRails().liveFavorites &&
|
||||
liveFavoriteCardsEnriched().length > 0
|
||||
) {
|
||||
<lib-dashboard-rail
|
||||
[label]="liveRailTitleKey() | translate"
|
||||
[items]="liveOnFavoritesCardsEnriched()"
|
||||
[totalCount]="liveRailTotalCount()"
|
||||
[seeAllLink]="
|
||||
liveRailSource() === 'favorites'
|
||||
? ['/workspace/global-favorites']
|
||||
: ['/workspace/global-recent']
|
||||
"
|
||||
[label]="liveRailTitleKeyForSource('favorites') | translate"
|
||||
[items]="liveFavoriteCardsEnriched()"
|
||||
[totalCount]="data.globalFavoriteLiveItems().length"
|
||||
[seeAllLink]="['/workspace/global-favorites']"
|
||||
[seeAllState]="liveSeeAllState"
|
||||
layout="channel"
|
||||
[testId]="'dashboard-live-recent-rail'"
|
||||
[testId]="'dashboard-live-favorites-rail'"
|
||||
/>
|
||||
}
|
||||
|
||||
@if (
|
||||
dashboardRails().recentlyWatchedLive &&
|
||||
recentLiveCardsEnriched().length > 0
|
||||
) {
|
||||
<lib-dashboard-rail
|
||||
[label]="liveRailTitleKeyForSource('recent') | translate"
|
||||
[items]="recentLiveCardsEnriched()"
|
||||
[totalCount]="data.globalRecentLiveItems().length"
|
||||
[seeAllLink]="['/workspace/global-recent']"
|
||||
[seeAllState]="liveSeeAllState"
|
||||
layout="channel"
|
||||
[testId]="'dashboard-recent-live-rail'"
|
||||
/>
|
||||
}
|
||||
|
||||
@if (
|
||||
dashboardRails().favoriteMoviesAndSeries &&
|
||||
favoriteMoviesAndSeriesCards().length > 0
|
||||
) {
|
||||
<lib-dashboard-rail
|
||||
[label]="
|
||||
'WORKSPACE.DASHBOARD.FAVORITE_MOVIES_AND_SERIES'
|
||||
| translate
|
||||
"
|
||||
[items]="favoriteMoviesAndSeriesCards()"
|
||||
[totalCount]="
|
||||
data.globalFavoriteItems().length -
|
||||
data.globalFavoriteLiveItems().length
|
||||
"
|
||||
[seeAllLink]="['/workspace/global-favorites']"
|
||||
[seeAllState]="favoriteMoviesAndSeriesSeeAllState()"
|
||||
[testId]="'dashboard-favorite-vod-rail'"
|
||||
/>
|
||||
}
|
||||
|
||||
@if (
|
||||
dashboardRails().continueWatching &&
|
||||
dashboardRails().recentlyWatchedLive &&
|
||||
continueWatchingCards().length === 0 &&
|
||||
liveOnFavoritesCards().length === 0 &&
|
||||
recentLiveCards().length === 0 &&
|
||||
data.globalRecentLoading()
|
||||
) {
|
||||
<section
|
||||
@@ -174,14 +261,8 @@
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- Mixed Global Favorites rail intentionally NOT rendered on the
|
||||
dashboard. Live favorites are promoted into the live rail above
|
||||
(with current EPG), and the full mixed catalogue lives on the
|
||||
dedicated /workspace/global-favorites page where the collection
|
||||
UI can give it the per-type filters it needs. -->
|
||||
|
||||
<!-- Sources rail -->
|
||||
@if (sourceCards().length > 0) {
|
||||
@if (dashboardRails().recentSources && sourceCards().length > 0) {
|
||||
<lib-dashboard-rail
|
||||
[label]="'WORKSPACE.DASHBOARD.RECENT_SOURCES' | translate"
|
||||
[items]="sourceCards()"
|
||||
@@ -191,7 +272,7 @@
|
||||
[testId]="'dashboard-recent-sources-rail'"
|
||||
(actionSelected)="onSourceActionSelected($event)"
|
||||
/>
|
||||
} @else if (!data.playlistsLoaded()) {
|
||||
} @else if (dashboardRails().recentSources && !data.playlistsLoaded()) {
|
||||
<section
|
||||
class="rails-page__skeleton-rail"
|
||||
aria-hidden="true"
|
||||
@@ -207,7 +288,10 @@
|
||||
}
|
||||
|
||||
<!-- Xtream recently-added rail (only relevant if there are Xtream playlists) -->
|
||||
@if (xtreamRecentlyAddedCards().length > 0) {
|
||||
@if (
|
||||
dashboardRails().xtreamRecentlyAdded &&
|
||||
xtreamRecentlyAddedCards().length > 0
|
||||
) {
|
||||
<lib-dashboard-rail
|
||||
[label]="
|
||||
'WORKSPACE.DASHBOARD.XTREAM_RECENTLY_ADDED' | translate
|
||||
@@ -217,6 +301,7 @@
|
||||
[testId]="'dashboard-xtream-recently-added-rail'"
|
||||
/>
|
||||
} @else if (
|
||||
dashboardRails().xtreamRecentlyAdded &&
|
||||
xtreamPlaylistCount() > 0 && data.xtreamRecentlyAddedLoading()
|
||||
) {
|
||||
<section
|
||||
|
||||
+113
@@ -295,6 +295,119 @@
|
||||
}
|
||||
}
|
||||
|
||||
.hero__live-epg {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
width: min(440px, 100%);
|
||||
margin-top: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hero__live-epg-topline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hero__live-epg-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
flex: 0 0 auto;
|
||||
font-family: var(
|
||||
--font-mono,
|
||||
ui-monospace,
|
||||
'SF Mono',
|
||||
'JetBrains Mono',
|
||||
Menlo,
|
||||
monospace
|
||||
);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
letter-spacing: 0;
|
||||
color: var(--app-live-color, #ff8f8f);
|
||||
text-transform: uppercase;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--app-live-color, #ff8f8f);
|
||||
box-shadow: 0 0 7px var(--app-live-color, #ff8f8f);
|
||||
}
|
||||
}
|
||||
|
||||
.hero__live-epg-time {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--app-muted-color, #8891a4);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
.hero--with-image & {
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
}
|
||||
|
||||
.hero__live-epg-title {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--app-heading-color, #d8dce8);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 650;
|
||||
line-height: 1.25;
|
||||
|
||||
.hero--with-image & {
|
||||
color: rgba(255, 255, 255, 0.96);
|
||||
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
.hero__live-epg-progress {
|
||||
position: relative;
|
||||
display: block;
|
||||
height: 4px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface, #e6e1e5) 14%,
|
||||
transparent
|
||||
);
|
||||
|
||||
.hero--with-image & {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--app-live-color, #ff8f8f);
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
&--idle i {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface, #e6e1e5) 22%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Watch-progress visualisation. Appears between the subtitle and the CTA
|
||||
// only when a resume position is known — VOD/series only (M3U and live
|
||||
// channels have no playback positions in the schema).
|
||||
|
||||
+74
-1
@@ -1,5 +1,7 @@
|
||||
import type { EpgProgram, PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
buildDashboardCollectionViewState,
|
||||
buildDashboardLiveEpgDetails,
|
||||
buildLiveEpgLookupKeys,
|
||||
buildPlaybackPositionReloadKey,
|
||||
buildDashboardSourceActions,
|
||||
@@ -7,11 +9,14 @@ import {
|
||||
formatEpgTimeRange,
|
||||
formatRemainingLabel,
|
||||
getLiveEpgProgramForCard,
|
||||
isContinueWatchingRecentItem,
|
||||
liveRailTitleKeyForSource,
|
||||
playbackProgressPercent,
|
||||
resolveDashboardHeroArtwork,
|
||||
buildDashboardRailSeeAllState,
|
||||
} from './workspace-dashboard-rails.component';
|
||||
import type { DashboardRailCard } from './dashboard-rail.component';
|
||||
import { COLLECTION_VIEW_STATE_KEY } from '@iptvnator/portal/shared/util';
|
||||
|
||||
describe('buildDashboardSourceActions', () => {
|
||||
const basePlaylist = {
|
||||
@@ -204,6 +209,31 @@ describe('EPG enrichment helpers', () => {
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildDashboardLiveEpgDetails', () => {
|
||||
it('builds the current programme details used by the live hero banner', () => {
|
||||
const program = baseProgram({ title: 'Market Open' });
|
||||
const start = program.startTimestamp as number;
|
||||
const stop = program.stopTimestamp as number;
|
||||
const now = start + (stop - start) / 4;
|
||||
|
||||
expect(buildDashboardLiveEpgDetails(program, now)).toMatchObject({
|
||||
nowPlayingTitle: 'Market Open',
|
||||
nowPlayingTimeRange: expect.stringMatching(
|
||||
/^\d{2}:\d{2} – \d{2}:\d{2}$/
|
||||
),
|
||||
nowPlayingProgress: 25,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Continue watching helpers', () => {
|
||||
it('keeps live TV out of the Continue Watching rail', () => {
|
||||
expect(isContinueWatchingRecentItem({ type: 'movie' })).toBe(true);
|
||||
expect(isContinueWatchingRecentItem({ type: 'series' })).toBe(true);
|
||||
expect(isContinueWatchingRecentItem({ type: 'live' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Live rail helpers', () => {
|
||||
@@ -263,10 +293,53 @@ describe('Live rail helpers', () => {
|
||||
'WORKSPACE.DASHBOARD.LIVE_FAVORITES'
|
||||
);
|
||||
expect(liveRailTitleKeyForSource('recent')).toBe(
|
||||
'WORKSPACE.DASHBOARD.LIVE_RECENT'
|
||||
'WORKSPACE.DASHBOARD.RECENTLY_WATCHED_LIVE_TV'
|
||||
);
|
||||
});
|
||||
|
||||
it('builds collection view state for dashboard rail see-all links', () => {
|
||||
expect(buildDashboardCollectionViewState('movie')).toEqual({
|
||||
[COLLECTION_VIEW_STATE_KEY]: {
|
||||
selectedContentType: 'movie',
|
||||
},
|
||||
});
|
||||
expect(buildDashboardCollectionViewState('series')).toEqual({
|
||||
[COLLECTION_VIEW_STATE_KEY]: {
|
||||
selectedContentType: 'series',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('builds see-all state from the first card content type in mixed rails', () => {
|
||||
expect(
|
||||
buildDashboardRailSeeAllState([
|
||||
channelCard({ contentType: 'live' }),
|
||||
channelCard({
|
||||
id: 'movie-card',
|
||||
title: 'Movie',
|
||||
contentType: 'movie',
|
||||
}),
|
||||
])
|
||||
).toEqual({
|
||||
[COLLECTION_VIEW_STATE_KEY]: {
|
||||
selectedContentType: 'live',
|
||||
},
|
||||
});
|
||||
expect(
|
||||
buildDashboardRailSeeAllState([
|
||||
channelCard({
|
||||
id: 'series-card',
|
||||
title: 'Series',
|
||||
contentType: 'series',
|
||||
}),
|
||||
])
|
||||
).toEqual({
|
||||
[COLLECTION_VIEW_STATE_KEY]: {
|
||||
selectedContentType: 'series',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('builds a stable playback-position reload key from VOD and series items only', () => {
|
||||
const first = buildPlaybackPositionReloadKey([
|
||||
{ playlist_id: 'b', xtream_id: 20, type: 'series' },
|
||||
|
||||
+176
-60
@@ -10,7 +10,10 @@ import {
|
||||
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
|
||||
import { interval, of, startWith, switchMap } from 'rxjs';
|
||||
import { EpgService } from '@iptvnator/epg/data-access';
|
||||
import { EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
EpgProgram,
|
||||
normalizeDashboardRailsSettings,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
@@ -32,6 +35,7 @@ import { PlaylistActions } from '@iptvnator/m3u-state';
|
||||
import {
|
||||
PlaylistDeleteActionService,
|
||||
RuntimeCapabilitiesService,
|
||||
SettingsStore,
|
||||
} from '@iptvnator/services';
|
||||
import {
|
||||
DashboardDataService,
|
||||
@@ -46,6 +50,11 @@ import type {
|
||||
DashboardRailActionSelection,
|
||||
} from './dashboard-rail.component';
|
||||
import type { PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
buildCollectionViewState,
|
||||
COLLECTION_VIEW_STATE_KEY,
|
||||
CollectionContentType,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
|
||||
// Cap dashboard rails at 20 items. Users get ~3× what's visible at once,
|
||||
// the DOM stays cheap, and the "Manage all" link is one click away for the
|
||||
@@ -80,6 +89,9 @@ interface DashboardHeroModel {
|
||||
/** 0–100 watched, when a resume position is known. */
|
||||
readonly watchProgress?: number | null;
|
||||
readonly remainingLabel?: DashboardRemainingLabel | null;
|
||||
readonly nowPlayingTitle?: string | null;
|
||||
readonly nowPlayingTimeRange?: string | null;
|
||||
readonly nowPlayingProgress?: number | null;
|
||||
}
|
||||
|
||||
export interface DashboardRemainingLabel {
|
||||
@@ -266,6 +278,33 @@ export function calcEpgProgress(
|
||||
return Math.max(0, Math.min(100, ratio * 100));
|
||||
}
|
||||
|
||||
export interface DashboardLiveEpgDetails {
|
||||
readonly nowPlayingTitle: string | null;
|
||||
readonly nowPlayingTimeRange: string | null;
|
||||
readonly nowPlayingProgress: number | null;
|
||||
}
|
||||
|
||||
export function buildDashboardLiveEpgDetails(
|
||||
program: EpgProgram | null,
|
||||
nowMs: number
|
||||
): DashboardLiveEpgDetails | null {
|
||||
if (!program) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const details: DashboardLiveEpgDetails = {
|
||||
nowPlayingTitle: program.title?.trim() || null,
|
||||
nowPlayingTimeRange: formatEpgTimeRange(program),
|
||||
nowPlayingProgress: calcEpgProgress(program, nowMs),
|
||||
};
|
||||
|
||||
return details.nowPlayingTitle ||
|
||||
details.nowPlayingTimeRange ||
|
||||
details.nowPlayingProgress !== null
|
||||
? details
|
||||
: null;
|
||||
}
|
||||
|
||||
function liveEpgLookupKeyForCard(card: DashboardRailCard): string {
|
||||
return card.epgLookupKey?.trim() || card.title.trim();
|
||||
}
|
||||
@@ -303,7 +342,36 @@ export function liveRailTitleKeyForSource(
|
||||
): string {
|
||||
return source === 'favorites'
|
||||
? 'WORKSPACE.DASHBOARD.LIVE_FAVORITES'
|
||||
: 'WORKSPACE.DASHBOARD.LIVE_RECENT';
|
||||
: 'WORKSPACE.DASHBOARD.RECENTLY_WATCHED_LIVE_TV';
|
||||
}
|
||||
|
||||
export function buildDashboardCollectionViewState(
|
||||
selectedContentType: CollectionContentType
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
[COLLECTION_VIEW_STATE_KEY]: buildCollectionViewState({
|
||||
selectedContentType,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDashboardRailSeeAllState(
|
||||
cards: readonly Pick<DashboardRailCard, 'contentType'>[],
|
||||
fallbackContentType: CollectionContentType = 'movie'
|
||||
): Record<string, unknown> {
|
||||
const firstContentType =
|
||||
cards.find(
|
||||
(
|
||||
card
|
||||
): card is Pick<DashboardRailCard, 'contentType'> & {
|
||||
contentType: CollectionContentType;
|
||||
} =>
|
||||
card.contentType === 'live' ||
|
||||
card.contentType === 'movie' ||
|
||||
card.contentType === 'series'
|
||||
)?.contentType ?? fallbackContentType;
|
||||
|
||||
return buildDashboardCollectionViewState(firstContentType);
|
||||
}
|
||||
|
||||
export function buildPlaybackPositionReloadKey(
|
||||
@@ -319,6 +387,12 @@ export function buildPlaybackPositionReloadKey(
|
||||
.join('|');
|
||||
}
|
||||
|
||||
export function isContinueWatchingRecentItem(
|
||||
item: Pick<GlobalRecentItem, 'type'>
|
||||
): boolean {
|
||||
return item.type === 'movie' || item.type === 'series';
|
||||
}
|
||||
|
||||
// ── Playback-position helpers (used by both hero + Continue Watching cards)
|
||||
// — kept as plain functions so they're easy to unit-test without mounting the
|
||||
// component or the data service.
|
||||
@@ -430,6 +504,7 @@ export class WorkspaceDashboardRailsComponent {
|
||||
private readonly shellActions = inject(WORKSPACE_SHELL_ACTIONS);
|
||||
private readonly epgService = inject(EpgService);
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
|
||||
readonly hasPlaylists = computed(() => this.data.playlists().length > 0);
|
||||
readonly ready = this.data.dashboardReady;
|
||||
@@ -438,10 +513,23 @@ export class WorkspaceDashboardRailsComponent {
|
||||
|
||||
readonly skeletonSlots = SKELETON_CARDS_PER_RAIL;
|
||||
readonly skeletonRails = SKELETON_RAILS;
|
||||
readonly liveRailTitleKeyForSource = liveRailTitleKeyForSource;
|
||||
readonly failedHeroImages = signal<Record<string, true>>({});
|
||||
readonly dashboardRails = computed(() =>
|
||||
normalizeDashboardRailsSettings(this.settingsStore.dashboardRails?.())
|
||||
);
|
||||
|
||||
private readonly heroRecentItem = computed(
|
||||
() => this.data.globalRecentItems()[0] ?? null
|
||||
);
|
||||
|
||||
private readonly heroLiveCard = computed<DashboardRailCard | null>(() => {
|
||||
const item = this.heroRecentItem();
|
||||
return item?.type === 'live' ? this.toRecentCard(item) : null;
|
||||
});
|
||||
|
||||
readonly hero = computed<DashboardHeroModel | null>(() => {
|
||||
const item = this.data.globalRecentItems()[0] ?? null;
|
||||
const item = this.heroRecentItem();
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
@@ -456,6 +544,10 @@ export class WorkspaceDashboardRailsComponent {
|
||||
);
|
||||
|
||||
const position = this.data.getPlaybackPositionForItem(item);
|
||||
const liveEpgDetails =
|
||||
item.type === 'live'
|
||||
? this.getLiveEpgDetailsForCard(this.heroLiveCard())
|
||||
: null;
|
||||
|
||||
return {
|
||||
...artwork,
|
||||
@@ -467,63 +559,55 @@ export class WorkspaceDashboardRailsComponent {
|
||||
title: item.title,
|
||||
watchProgress: playbackProgressPercent(position),
|
||||
remainingLabel: formatRemainingLabel(position),
|
||||
nowPlayingTitle: liveEpgDetails?.nowPlayingTitle ?? null,
|
||||
nowPlayingTimeRange: liveEpgDetails?.nowPlayingTimeRange ?? null,
|
||||
nowPlayingProgress: liveEpgDetails?.nowPlayingProgress ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
// Split the mixed "recently watched" history into two rails. VOD items
|
||||
// (movies/series) get one rail; live channels get their own. Two card
|
||||
// formats — one rail each — so users can scan each surface at a glance
|
||||
// instead of decoding a mixed grid of fallback posters and channel logos.
|
||||
readonly continueWatchingCards = computed<DashboardRailCard[]>(() => {
|
||||
readonly continueWatchingBaseCards = computed<DashboardRailCard[]>(() => {
|
||||
this.languageTick();
|
||||
return this.data
|
||||
.globalRecentVodItems()
|
||||
.filter(isContinueWatchingRecentItem)
|
||||
.slice(0, RAIL_ITEM_LIMIT)
|
||||
.map((item) => this.toRecentCard(item));
|
||||
});
|
||||
|
||||
// Live rail source — favorited live channels take precedence so the EPG
|
||||
// enrichment lands on the channels the user actually cares about. When
|
||||
// no favorites exist (fresh install, or a user who hasn't starred any
|
||||
// channel yet), fall back to recently-watched live so the rail still
|
||||
// has something to show. `liveRailSource` drives the title swap.
|
||||
readonly liveRailSource = computed<'favorites' | 'recent'>(() =>
|
||||
this.data.globalFavoriteLiveItems().length > 0 ? 'favorites' : 'recent'
|
||||
readonly continueWatchingCards = computed<DashboardRailCard[]>(() =>
|
||||
this.continueWatchingBaseCards()
|
||||
);
|
||||
|
||||
readonly liveOnFavoritesCards = computed<DashboardRailCard[]>(() => {
|
||||
if (this.liveRailSource() === 'favorites') {
|
||||
return this.data
|
||||
.globalFavoriteLiveItems()
|
||||
.slice(0, RAIL_ITEM_LIMIT)
|
||||
.map((item) => this.toFavoriteCard(item));
|
||||
}
|
||||
return this.data
|
||||
readonly liveFavoriteCards = computed<DashboardRailCard[]>(() =>
|
||||
this.data
|
||||
.globalFavoriteLiveItems()
|
||||
.slice(0, RAIL_ITEM_LIMIT)
|
||||
.map((item) => this.toFavoriteCard(item))
|
||||
);
|
||||
|
||||
readonly recentLiveCards = computed<DashboardRailCard[]>(() =>
|
||||
this.data
|
||||
.globalRecentLiveItems()
|
||||
.slice(0, RAIL_ITEM_LIMIT)
|
||||
.map((item) => this.toRecentCard(item));
|
||||
});
|
||||
|
||||
// Title key flips with the source — "Live now on your favorites" when
|
||||
// we're rendering favorites, "Continue with live TV" when we're falling
|
||||
// back to recently-watched. Always honest about what the user is seeing.
|
||||
readonly liveRailTitleKey = computed(() =>
|
||||
liveRailTitleKeyForSource(this.liveRailSource())
|
||||
);
|
||||
|
||||
readonly liveRailTotalCount = computed(() =>
|
||||
this.liveRailSource() === 'favorites'
|
||||
? this.data.globalFavoriteLiveItems().length
|
||||
: this.data.globalRecentLiveItems().length
|
||||
.map((item) => this.toRecentCard(item))
|
||||
);
|
||||
|
||||
// Best-effort EPG lookup keyed by the app-wide M3U XMLTV chain
|
||||
// (tvg-id -> tvg-name -> name), with the card title as a final fallback.
|
||||
// Xtream/Stalker live items often have no XMLTV side-channel and will
|
||||
// simply return null — the card renders without the program row.
|
||||
private readonly liveChannelLookupKeys = computed(() =>
|
||||
buildLiveEpgLookupKeys(this.liveOnFavoritesCards())
|
||||
);
|
||||
private readonly liveChannelLookupKeys = computed(() => {
|
||||
const heroLiveCard = this.heroLiveCard();
|
||||
return buildLiveEpgLookupKeys([
|
||||
...(heroLiveCard ? [heroLiveCard] : []),
|
||||
...(this.dashboardRails().liveFavorites
|
||||
? this.liveFavoriteCards()
|
||||
: []),
|
||||
...(this.dashboardRails().recentlyWatchedLive
|
||||
? this.recentLiveCards()
|
||||
: []),
|
||||
]);
|
||||
});
|
||||
|
||||
private readonly playbackPositionReloadKey = computed(() =>
|
||||
buildPlaybackPositionReloadKey(this.data.globalRecentVodItems())
|
||||
@@ -549,34 +633,32 @@ export class WorkspaceDashboardRailsComponent {
|
||||
{ initialValue: new Map<string, EpgProgram | null>() }
|
||||
);
|
||||
|
||||
readonly liveOnFavoritesCardsEnriched = computed<DashboardRailCard[]>(
|
||||
() => {
|
||||
const epgMap = this.liveEpgPrograms();
|
||||
// Recompute the now-window each tick so progress moves between
|
||||
// 30s ticks even if the program identity is unchanged.
|
||||
const now = Date.now();
|
||||
return this.liveOnFavoritesCards().map((card) => {
|
||||
const program = getLiveEpgProgramForCard(card, epgMap);
|
||||
if (!program) {
|
||||
return card;
|
||||
}
|
||||
return {
|
||||
...card,
|
||||
nowPlayingTitle: program.title || null,
|
||||
nowPlayingTimeRange: formatEpgTimeRange(program),
|
||||
nowPlayingProgress: calcEpgProgress(program, now),
|
||||
};
|
||||
});
|
||||
}
|
||||
readonly liveFavoriteCardsEnriched = computed<DashboardRailCard[]>(() =>
|
||||
this.enrichLiveCards(this.liveFavoriteCards())
|
||||
);
|
||||
|
||||
readonly favoriteCards = computed<DashboardRailCard[]>(() =>
|
||||
readonly recentLiveCardsEnriched = computed<DashboardRailCard[]>(() =>
|
||||
this.enrichLiveCards(this.recentLiveCards())
|
||||
);
|
||||
|
||||
readonly favoriteMoviesAndSeriesCards = computed<DashboardRailCard[]>(() =>
|
||||
this.data
|
||||
.globalFavoriteItems()
|
||||
.filter((item) => item.type === 'movie' || item.type === 'series')
|
||||
.slice(0, RAIL_ITEM_LIMIT)
|
||||
.map((item) => this.toFavoriteCard(item))
|
||||
);
|
||||
|
||||
readonly continueWatchingSeeAllState = computed(() =>
|
||||
buildDashboardRailSeeAllState(this.continueWatchingBaseCards())
|
||||
);
|
||||
|
||||
readonly favoriteMoviesAndSeriesSeeAllState = computed(() =>
|
||||
this.buildNonLiveSeeAllState(this.favoriteMoviesAndSeriesCards())
|
||||
);
|
||||
|
||||
readonly liveSeeAllState = buildDashboardCollectionViewState('live');
|
||||
|
||||
readonly xtreamRecentlyAddedCards = computed<DashboardRailCard[]>(() =>
|
||||
this.data
|
||||
.xtreamRecentlyAddedItems()
|
||||
@@ -665,6 +747,40 @@ export class WorkspaceDashboardRailsComponent {
|
||||
}
|
||||
}
|
||||
|
||||
private enrichLiveCards(
|
||||
cards: readonly DashboardRailCard[]
|
||||
): DashboardRailCard[] {
|
||||
return cards.map((card) => {
|
||||
const details = this.getLiveEpgDetailsForCard(card);
|
||||
if (!details) {
|
||||
return card;
|
||||
}
|
||||
return { ...card, ...details };
|
||||
});
|
||||
}
|
||||
|
||||
private getLiveEpgDetailsForCard(
|
||||
card: DashboardRailCard | null
|
||||
): DashboardLiveEpgDetails | null {
|
||||
if (!card) {
|
||||
return null;
|
||||
}
|
||||
const program = getLiveEpgProgramForCard(card, this.liveEpgPrograms());
|
||||
// Recompute the now-window each tick so progress moves between
|
||||
// 30s ticks even if the program identity is unchanged.
|
||||
return buildDashboardLiveEpgDetails(program, Date.now());
|
||||
}
|
||||
|
||||
private buildNonLiveSeeAllState(
|
||||
cards: readonly DashboardRailCard[]
|
||||
): Record<string, unknown> {
|
||||
return buildDashboardCollectionViewState(
|
||||
cards.some((card) => card.contentType === 'movie')
|
||||
? 'movie'
|
||||
: 'series'
|
||||
);
|
||||
}
|
||||
|
||||
private buildHeroSubtitle(item: GlobalRecentItem): string {
|
||||
const parts = [
|
||||
item.playlist_name,
|
||||
|
||||
Reference in new issue
Block a user