feat(dashboard): add configurable dashboard rails

This commit is contained in:
4gray committed 2026-06-14 10:54:57 +02:00
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,
+21 -2
View File
@@ -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",
+21 -2
View File
@@ -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",
+21 -2
View File
@@ -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",
+21 -2
View File
@@ -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",
+21 -2
View File
@@ -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",
+21 -2
View File
@@ -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",
+21 -2
View File
@@ -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",
+21 -2
View File
@@ -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",
+21 -2
View File
@@ -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",
+21 -2
View File
@@ -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",
+21 -2
View File
@@ -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",
+21 -2
View File
@@ -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",
+21 -2
View File
@@ -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",
+21 -2
View File
@@ -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",
+21 -2
View File
@@ -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",
+21 -2
View File
@@ -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",
+21 -2
View File
@@ -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",
+21 -2
View File
@@ -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')
);
+54 -2
View File
@@ -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);
@@ -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
@@ -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).
@@ -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' },
@@ -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,