mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36:15 -08:00
Merge remote-tracking branch 'origin/master' into claude/reverent-thompson-163619
# Conflicts: # CLAUDE.md # package.json
This commit is contained in:
commit
0285cf6c40
127 files changed
+3651
-4869
No files matched your search
@@ -10,6 +10,33 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Lint all projects
|
||||
run: pnpm nx run-many --target=lint --all --parallel=3 --output-style=static
|
||||
env:
|
||||
CI: true
|
||||
NX_TASKS_RUNNER_DYNAMIC_OUTPUT: false
|
||||
|
||||
unit-and-typecheck:
|
||||
name: Unit Tests and Typechecks
|
||||
runs-on: ubuntu-latest
|
||||
@@ -43,6 +70,12 @@ jobs:
|
||||
CI: true
|
||||
NX_TASKS_RUNNER_DYNAMIC_OUTPUT: false
|
||||
|
||||
- name: Run Tier B/C validation commands
|
||||
run: node tools/coverage/check-coverage-policy.mjs --run-non-tier-a
|
||||
env:
|
||||
CI: true
|
||||
NX_TASKS_RUNNER_DYNAMIC_OUTPUT: false
|
||||
|
||||
- name: Upload unit coverage artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -195,13 +195,21 @@ Before finishing behavior changes or bug fixes, follow `Regression Prevention An
|
||||
### Linting
|
||||
|
||||
```bash
|
||||
# Lint frontend
|
||||
nx lint web
|
||||
# Lint all projects (what CI enforces on every PR)
|
||||
pnpm run lint
|
||||
|
||||
# Lint backend
|
||||
# Lint a single project
|
||||
nx lint web
|
||||
nx lint electron-backend
|
||||
```
|
||||
|
||||
CI runs lint for every project (`.github/workflows/ci.yml`). This enforces the
|
||||
Nx module-boundary tags, the legacy bare-alias ban, and a `max-lines` ESLint
|
||||
rule (hard maximum 400 lines per TypeScript file). Pre-existing oversized files
|
||||
are baselined in `tools/eslint/max-lines-baseline.mjs`; regenerate the baseline
|
||||
with `node tools/eslint/generate-max-lines-baseline.mjs` after splitting a file.
|
||||
Never add new files to the baseline.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Monorepo Structure (Nx Workspace)
|
||||
@@ -238,7 +246,7 @@ This is an Nx monorepo with the following structure:
|
||||
- **ui/playback** - Player UI (video/audio players)
|
||||
- **ui/pipes** - Angular pipes
|
||||
- **ui/remote-control** - Remote-control UI pieces
|
||||
- **ui/shared-portals** - Portal-related UI components
|
||||
- **ui/shared-portals** - Shared portal types (`LiveEpgPanelSummary`)
|
||||
- **ui/styles** - Shared styles/theme
|
||||
- **workspace/{shell,dashboard}** - Workspace shell (layout/navigation) and dashboard
|
||||
|
||||
@@ -736,7 +744,6 @@ No formal migration system yet. Schema changes are applied via raw SQL in the `c
|
||||
- Use NgRx for global application state (M3U playlists, `libs/m3u-state`)
|
||||
- Use NgRx Signal Store with `signalStoreFeature()` composition for portal/feature state (XtreamStore, StalkerStore)
|
||||
- Use NgRx signals for reactive data streams
|
||||
- `@ngrx/component-store` is listed in `package.json` but unused in the codebase — do not introduce new usages
|
||||
|
||||
<!-- nx configuration start-->
|
||||
<!-- Leave the start & end comments to automatically receive updates. -->
|
||||
|
||||
@@ -231,6 +231,38 @@ test.describe('Electron Settings', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('@settings @persistence @electron persists the EPG view mode across app restart', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const firstLaunch = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await openSettings(firstLaunch.mainWindow);
|
||||
const listToggle = firstLaunch.mainWindow.locator(
|
||||
'[data-test-id="epg-view-mode-list"]'
|
||||
);
|
||||
await expect(listToggle).toBeVisible();
|
||||
await listToggle.click();
|
||||
await expect(listToggle).toHaveAttribute('aria-checked', 'true');
|
||||
await saveSettings(firstLaunch.mainWindow);
|
||||
} finally {
|
||||
await closeElectronApp(firstLaunch);
|
||||
}
|
||||
|
||||
const secondLaunch = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await openSettings(secondLaunch.mainWindow);
|
||||
await expect(
|
||||
secondLaunch.mainWindow.locator(
|
||||
'[data-test-id="epg-view-mode-list"]'
|
||||
)
|
||||
).toHaveAttribute('aria-checked', 'true');
|
||||
} finally {
|
||||
await closeElectronApp(secondLaunch);
|
||||
}
|
||||
});
|
||||
|
||||
test('@settings @electron starts on sources when dashboard is disabled', async ({ dataDir }) => {
|
||||
const firstLaunch = await launchElectronApp(dataDir);
|
||||
|
||||
|
||||
@@ -4,9 +4,12 @@ import {
|
||||
clickCategoryByNameExact,
|
||||
closeElectronApp,
|
||||
expect,
|
||||
goToDashboard,
|
||||
launchElectronApp,
|
||||
openSettings,
|
||||
openWorkspaceSection,
|
||||
resetMockServers,
|
||||
saveSettings,
|
||||
test,
|
||||
waitForXtreamWorkspaceReady,
|
||||
} from './electron-test-fixtures';
|
||||
@@ -121,6 +124,64 @@ for (const timeZone of ['UTC', 'Europe/Berlin'] as const) {
|
||||
});
|
||||
}
|
||||
|
||||
test('@epg @xtream @electron renders the vertical list view when the setting is "list"', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const fixture = await fetchXtreamEpgFixture(request, epgCredentials);
|
||||
const currentProgram = fixture.shortEpg[0];
|
||||
if (!currentProgram) {
|
||||
throw new Error(
|
||||
'Expected the Xtream EPG fixture to include a current program.'
|
||||
);
|
||||
}
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
// Opt into the list view first (from the fresh workspace) so the portal
|
||||
// → Live TV → channel flow afterwards mirrors the timeline test exactly.
|
||||
await openSettings(app.mainWindow);
|
||||
await app.mainWindow
|
||||
.locator('[data-test-id="epg-view-mode-list"]')
|
||||
.click();
|
||||
await saveSettings(app.mainWindow);
|
||||
await goToDashboard(app.mainWindow);
|
||||
|
||||
await addXtreamPortal(app.mainWindow, {
|
||||
name: `${epgPortalName} List`,
|
||||
username: epgCredentials.username,
|
||||
password: epgCredentials.password,
|
||||
});
|
||||
await waitForXtreamWorkspaceReady(app.mainWindow);
|
||||
await openWorkspaceSection(app.mainWindow, 'Live TV');
|
||||
await clickCategoryByNameExact(app.mainWindow, fixture.categoryName);
|
||||
const channelRow = channelItemByTitle(
|
||||
app.mainWindow,
|
||||
fixture.stream.name ?? ''
|
||||
).first();
|
||||
await expect(channelRow).toBeVisible({ timeout: 20000 });
|
||||
await channelRow.click();
|
||||
|
||||
// The list view renders instead of the timeline.
|
||||
await expect(app.mainWindow.locator('app-epg-list-view')).toBeVisible({
|
||||
timeout: 20000,
|
||||
});
|
||||
await expect(
|
||||
app.mainWindow.locator('app-epg-timeline')
|
||||
).toHaveCount(0);
|
||||
|
||||
// The on-air programme is the highlighted "now" row.
|
||||
await expect(
|
||||
app.mainWindow
|
||||
.locator('app-epg-list-view .g-row[data-when="now"] .title')
|
||||
.first()
|
||||
).toHaveText(currentProgram.title);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
function formatTimeInZone(timestampSeconds: number, timeZone: string): string {
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
timeZone,
|
||||
|
||||
@@ -14,6 +14,40 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="setting-item setting-item--full-control">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.EPG_VIEW_MODE' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.EPG_VIEW_MODE_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<div
|
||||
class="theme-switcher"
|
||||
role="radiogroup"
|
||||
[attr.aria-label]="'SETTINGS.EPG_VIEW_MODE' | translate"
|
||||
data-test-id="select-epg-view-mode"
|
||||
>
|
||||
@for (option of epgViewModeOptions(); track option.value) {
|
||||
<button
|
||||
type="button"
|
||||
class="theme-switcher__option"
|
||||
[class.theme-switcher__option--selected]="
|
||||
form().value.epgViewMode === option.value
|
||||
"
|
||||
[attr.aria-checked]="
|
||||
form().value.epgViewMode === option.value
|
||||
"
|
||||
[attr.data-test-id]="'epg-view-mode-' + option.value"
|
||||
role="radio"
|
||||
(click)="selectEpgViewMode.emit(option.value)"
|
||||
>
|
||||
<mat-icon>{{ option.icon }}</mat-icon>
|
||||
<span>{{ option.labelKey | translate }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item setting-item--full-control">
|
||||
<div class="setting-item__control">
|
||||
<ng-container formArrayName="epgUrl">
|
||||
|
||||
@@ -7,8 +7,10 @@ import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { EpgViewMode } from '@iptvnator/shared/interfaces';
|
||||
import { EpgSourceStatusComponent } from '@iptvnator/ui/epg';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { EpgViewModeOption } from './settings.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings-epg-section',
|
||||
@@ -33,10 +35,12 @@ export class SettingsEpgSectionComponent {
|
||||
readonly activeSection = input.required<string>();
|
||||
readonly epgUrl = input.required<FormArray>();
|
||||
readonly isClearingEpgData = input(false);
|
||||
readonly epgViewModeOptions = input.required<EpgViewModeOption[]>();
|
||||
|
||||
readonly refreshEpg = output<string>();
|
||||
readonly removeEpgSource = output<number>();
|
||||
readonly addEpgSource = output<void>();
|
||||
readonly refreshAllEpg = output<void>();
|
||||
readonly clearEpgData = output<void>();
|
||||
readonly selectEpgViewMode = output<EpgViewMode>();
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import {
|
||||
CoverSize,
|
||||
DEFAULT_DASHBOARD_RAILS_SETTINGS,
|
||||
EpgViewMode,
|
||||
Language,
|
||||
normalizeDashboardRailsSettings,
|
||||
normalizeExternalPlayerArguments,
|
||||
@@ -70,7 +71,12 @@ export function createSettingsForm(
|
||||
],
|
||||
recordingFolder: '',
|
||||
coverSize: 'medium' as CoverSize,
|
||||
...(supportsEpg ? { preferUploadedEpgOverXtream: false } : {}),
|
||||
...(supportsEpg
|
||||
? {
|
||||
preferUploadedEpgOverXtream: false,
|
||||
epgViewMode: 'timeline' as EpgViewMode,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -129,6 +135,8 @@ export function createSettingsFromFormValue(
|
||||
value.preferUploadedEpgOverXtream ??
|
||||
currentSettings.preferUploadedEpgOverXtream ??
|
||||
false,
|
||||
epgViewMode:
|
||||
value.epgViewMode ?? currentSettings.epgViewMode ?? 'timeline',
|
||||
trustedPrivateNetworkEpgUrls:
|
||||
currentSettings.trustedPrivateNetworkEpgUrls ?? [],
|
||||
trustedInsecureTlsHosts: currentSettings.trustedInsecureTlsHosts ?? [],
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import {
|
||||
CoverSize,
|
||||
EpgViewMode,
|
||||
StartupBehavior,
|
||||
Theme,
|
||||
VideoPlayer,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
CoverSizeOption,
|
||||
EpgViewModeOption,
|
||||
SettingsPlayerOption,
|
||||
SettingsSection,
|
||||
StartupBehaviorOption,
|
||||
@@ -48,6 +50,19 @@ export const SETTINGS_COVER_SIZE_OPTIONS: CoverSizeOption[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const SETTINGS_EPG_VIEW_MODE_OPTIONS: EpgViewModeOption[] = [
|
||||
{
|
||||
value: 'timeline' satisfies EpgViewMode,
|
||||
icon: 'view_timeline',
|
||||
labelKey: 'SETTINGS.EPG_VIEW_MODE_TIMELINE',
|
||||
},
|
||||
{
|
||||
value: 'list' satisfies EpgViewMode,
|
||||
icon: 'view_list',
|
||||
labelKey: 'SETTINGS.EPG_VIEW_MODE_LIST',
|
||||
},
|
||||
];
|
||||
|
||||
export const SETTINGS_STARTUP_BEHAVIOR_OPTIONS: StartupBehaviorOption[] = [
|
||||
{
|
||||
value: StartupBehavior.FirstView,
|
||||
|
||||
@@ -59,11 +59,13 @@
|
||||
[activeSection]="activeSection()"
|
||||
[epgUrl]="epgUrl"
|
||||
[isClearingEpgData]="isClearingEpgData()"
|
||||
[epgViewModeOptions]="epgViewModeOptions"
|
||||
(refreshEpg)="refreshEpg($event)"
|
||||
(removeEpgSource)="removeEpgSource($event)"
|
||||
(addEpgSource)="addEpgSource()"
|
||||
(refreshAllEpg)="refreshAllEpg()"
|
||||
(clearEpgData)="clearEpgData()"
|
||||
(selectEpgViewMode)="selectEpgViewMode($event)"
|
||||
/>
|
||||
}
|
||||
|
||||
|
||||
@@ -104,6 +104,7 @@ const DEFAULT_SETTINGS = {
|
||||
coverSize: 'medium',
|
||||
dashboardRails: DEFAULT_DASHBOARD_RAILS,
|
||||
preferUploadedEpgOverXtream: false,
|
||||
epgViewMode: 'timeline',
|
||||
};
|
||||
|
||||
const DEFAULT_APP_UPDATE_STATUS: ElectronBridgeAppUpdateStatus = {
|
||||
@@ -1295,6 +1296,23 @@ describe('SettingsComponent', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('updates the EPG view mode through the epg section output', () => {
|
||||
const mockStore = settingsStore as unknown as MockSettingsStore;
|
||||
const listButton = (
|
||||
fixture.nativeElement as HTMLElement
|
||||
).querySelector(
|
||||
'[data-test-id="epg-view-mode-list"]'
|
||||
) as HTMLButtonElement;
|
||||
|
||||
listButton.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.settingsForm.value.epgViewMode).toBe('list');
|
||||
expect(mockStore.updateSettings).toHaveBeenCalledWith({
|
||||
epgViewMode: 'list',
|
||||
});
|
||||
});
|
||||
|
||||
it('renders dashboard controls with the expected defaults', () => {
|
||||
const nativeElement = fixture.nativeElement as HTMLElement;
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
CoverSize,
|
||||
ELECTRON_BRIDGE_APP_UPDATE_STATUSES,
|
||||
ElectronBridgeAppUpdateStatus,
|
||||
EpgViewMode,
|
||||
Language,
|
||||
StreamFormat,
|
||||
Theme,
|
||||
@@ -69,6 +70,7 @@ import {
|
||||
buildSettingsSectionNavItems,
|
||||
SETTINGS_COVER_SIZE_OPTIONS,
|
||||
SETTINGS_EMBEDDED_PLAYER_OPTIONS,
|
||||
SETTINGS_EPG_VIEW_MODE_OPTIONS,
|
||||
SETTINGS_OS_PLAYER_OPTIONS,
|
||||
SETTINGS_STARTUP_BEHAVIOR_OPTIONS,
|
||||
SETTINGS_THEME_OPTIONS,
|
||||
@@ -203,6 +205,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
readonly themeOptions = SETTINGS_THEME_OPTIONS;
|
||||
readonly coverSizeOptions = SETTINGS_COVER_SIZE_OPTIONS;
|
||||
readonly startupBehaviorOptions = SETTINGS_STARTUP_BEHAVIOR_OPTIONS;
|
||||
readonly epgViewModeOptions = SETTINGS_EPG_VIEW_MODE_OPTIONS;
|
||||
|
||||
/** Settings form object */
|
||||
settingsForm = createSettingsForm(this.formBuilder, this.supportsEpg);
|
||||
@@ -507,6 +510,17 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
this.settingsStore.updateSettings({ coverSize: size });
|
||||
}
|
||||
|
||||
selectEpgViewMode(mode: EpgViewMode): void {
|
||||
if (this.settingsForm.value.epgViewMode === mode) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.settingsForm.patchValue({ epgViewMode: mode });
|
||||
this.settingsForm.get('epgViewMode')?.markAsDirty();
|
||||
this.settingsForm.markAsDirty();
|
||||
this.settingsStore.updateSettings({ epgViewMode: mode });
|
||||
}
|
||||
|
||||
async selectRecordingFolder(): Promise<void> {
|
||||
if (
|
||||
!this.isDesktop ||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
CoverSize,
|
||||
EpgViewMode,
|
||||
StartupBehavior,
|
||||
Theme,
|
||||
VideoPlayer,
|
||||
@@ -34,6 +35,12 @@ export interface CoverSizeOption {
|
||||
labelKey: string;
|
||||
}
|
||||
|
||||
export interface EpgViewModeOption {
|
||||
value: EpgViewMode;
|
||||
icon: string;
|
||||
labelKey: string;
|
||||
}
|
||||
|
||||
export interface SettingsPlayerOption {
|
||||
id: VideoPlayer;
|
||||
labelKey: string;
|
||||
|
||||
@@ -192,10 +192,8 @@
|
||||
"VALIDATING": "جارٍ التحقق من البوابة..."
|
||||
},
|
||||
"FILTER_BY_NAME": "التصفية حسب الاسم",
|
||||
"FILTER_BY_TYPE": "التصفية حسب النوع",
|
||||
"FILTER_AND_SORT": "تصفية وفرز",
|
||||
"SORT_PLAYLISTS": "فرز القوائم",
|
||||
"SORT_BY": "ترتيب حسب",
|
||||
"SORT_OPTIONS": {
|
||||
"NAME_ASC": "الاسم (أ-ي)",
|
||||
"NAME_DESC": "الاسم (ي-أ)",
|
||||
@@ -292,6 +290,10 @@
|
||||
"COVER_SIZE_SMALL": "صغير",
|
||||
"COVER_SIZE_MEDIUM": "متوسط",
|
||||
"COVER_SIZE_LARGE": "كبير",
|
||||
"EPG_VIEW_MODE": "عرض الدليل",
|
||||
"EPG_VIEW_MODE_DESCRIPTION": "اختر طريقة عرض دليل البرامج أسفل المشغل: شريط جدول زمني أفقي، أو قائمة رأسية ليوم واحد.",
|
||||
"EPG_VIEW_MODE_TIMELINE": "الجدول الزمني",
|
||||
"EPG_VIEW_MODE_LIST": "قائمة",
|
||||
"STARTUP_BEHAVIOR": "عرض البدء",
|
||||
"STARTUP_BEHAVIOR_DESCRIPTION": "اختر ما إذا كان IPTVnator يفتح أول عرض متاح في مساحة العمل أو يستعيد آخر عرض على مستوى القسم. العرض الأول يعني لوحة التحكم عند تفعيلها، وإلا فالمصادر.",
|
||||
"STARTUP_BEHAVIOR_FIRST_VIEW": "أول عرض متاح",
|
||||
@@ -483,7 +485,6 @@
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "تبديل قائمة القنوات (⌘/Ctrl+B)"
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SEARCH_CHANNEL": "البحث عن قناة",
|
||||
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "تحميل أو اختيار قائمة أخرى",
|
||||
"ALL_CHANNELS": "جميع القنوات",
|
||||
"GROUPS": "المجموعات",
|
||||
@@ -557,14 +558,9 @@
|
||||
}
|
||||
},
|
||||
"TOP_MENU": {
|
||||
"OPEN_CHANNELS_LIST": "فتح قائمة القنوات",
|
||||
"TOGGLE_FAVORITE_FLAG": "تبديل حالة المفضلة",
|
||||
"OPEN_EPG_LIST": "فتح قائمة EPG",
|
||||
"OPEN_MULTI_EPG": "فتح عرض Multi-EPG",
|
||||
"SHOW_INFO": "عرض معلومات البرنامج (اضغط I)"
|
||||
"OPEN_MULTI_EPG": "فتح عرض Multi-EPG"
|
||||
},
|
||||
"EPG": {
|
||||
"TITLE": "الدليل الإلكتروني للبرامج",
|
||||
"NEXT_DAY": "اليوم التالي",
|
||||
"PREVIOUS_DAY": "اليوم السابق",
|
||||
"ZOOM_IN": "تكبير",
|
||||
@@ -573,14 +569,9 @@
|
||||
"CLOSE_FILTER": "إغلاق التصفية",
|
||||
"SEARCH_PROGRAMS": "البحث في البرامج",
|
||||
"CLOSE_PROGRAM_SEARCH": "إغلاق بحث البرامج",
|
||||
"LIVE_NOW": "يُبث الآن",
|
||||
"LIVE_STREAM": "بث مباشر",
|
||||
"TIMESHIFT_AVAILABLE": "التحريك الزمني متاح، انقر للتشغيل",
|
||||
"ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "تشغيل الأرشيف غير متاح",
|
||||
"ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "يكشف هذا المزود عن سجل الجدول لهذه القناة، لكنه لا يوفر تشغيلًا قابلًا للأرشيف أو Catchup.",
|
||||
"EPG_NOT_AVAILABLE_DATE": "عذراً، الدليل غير متاح لهذا التاريخ",
|
||||
"NO_GUIDE_HINT": "جرب تاريخاً آخر أو حدّث قائمة التشغيل.",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "عذراً، الدليل غير متاح لهذه القناة",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "يرجى إضافة/تعديل رابط EPG في إعدادات التطبيق.",
|
||||
"FETCH_EPG": "جارٍ جلب بيانات EPG...",
|
||||
"ERROR": "عذراً، فشل تحميل الدليل.",
|
||||
@@ -615,7 +606,6 @@
|
||||
"TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.",
|
||||
"CURRENT_PROGRAM": "Current program",
|
||||
"ARCHIVE_PLAYBACK": "تشغيل الأرشيف",
|
||||
"RETURN_TO_LIVE": "العودة إلى البث المباشر",
|
||||
"COLLAPSE_PANEL": "Collapse EPG panel",
|
||||
"EXPAND_PANEL": "Expand EPG panel",
|
||||
"TIMELINE": {
|
||||
@@ -679,7 +669,6 @@
|
||||
"MENU": {
|
||||
"OPEN": "فتح القائمة",
|
||||
"SUPPORT_ARIA": "دعم IPTVnator على GitHub",
|
||||
"SETTINGS": "الإعدادات",
|
||||
"SETTINGS_ARIA": "فتح الإعدادات",
|
||||
"BUG_REPORT": "ابلاغ عن خلل",
|
||||
"WHAT_IS_NEW": "ما الجديد",
|
||||
@@ -804,10 +793,7 @@
|
||||
"SEARCH": "بحث",
|
||||
"RECENT": "الأخيرة",
|
||||
"RECENTLY_ADDED": "أُضيفت مؤخرًا",
|
||||
"FAVORITES": "المفضلة",
|
||||
"HOME": "الرئيسية",
|
||||
"PLAYLIST_INFO": "معلومات قائمة التشغيل",
|
||||
"ACCOUNT_INFO": "معلومات الحساب"
|
||||
"FAVORITES": "المفضلة"
|
||||
},
|
||||
"ERROR_VIEW": {
|
||||
"IMPORT_CANCELLED": {
|
||||
|
||||
@@ -192,10 +192,8 @@
|
||||
"VALIDATING": "جاري التحقق من البوابة..."
|
||||
},
|
||||
"FILTER_BY_NAME": "فلتر بالاسم",
|
||||
"FILTER_BY_TYPE": "فلتر بالنوع",
|
||||
"FILTER_AND_SORT": "فلتر وترتيب",
|
||||
"SORT_PLAYLISTS": "رتب قوائم التشغيل",
|
||||
"SORT_BY": "رتب بـ",
|
||||
"SORT_OPTIONS": {
|
||||
"NAME_ASC": "الاسم (أ-ي)",
|
||||
"NAME_DESC": "الاسم (ي-أ)",
|
||||
@@ -292,6 +290,10 @@
|
||||
"COVER_SIZE_SMALL": "صغير",
|
||||
"COVER_SIZE_MEDIUM": "متوسط",
|
||||
"COVER_SIZE_LARGE": "كبير",
|
||||
"EPG_VIEW_MODE": "عرض الدليل",
|
||||
"EPG_VIEW_MODE_DESCRIPTION": "اختار كيفاش يبان دليل البرامج تحت المشغل: شريط زمني أفقي ولا لائحة عمودية ديال نهار واحد.",
|
||||
"EPG_VIEW_MODE_TIMELINE": "الخط الزمني",
|
||||
"EPG_VIEW_MODE_LIST": "لائحة",
|
||||
"STARTUP_BEHAVIOR": "العرض عند البدء",
|
||||
"STARTUP_BEHAVIOR_DESCRIPTION": "اختار واش IPTVnator يحل أول عرض متوفر في مساحة العمل ولا يسترجع آخر عرض على مستوى القسم. أول عرض كيعني لوحة التحكم إلى كانت مفعلة، إلا فالا فهي Sources.",
|
||||
"STARTUP_BEHAVIOR_FIRST_VIEW": "أول عرض متوفر",
|
||||
@@ -483,7 +485,6 @@
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "تبديل قائمة القنوات (⌘/Ctrl+B)"
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SEARCH_CHANNEL": "قلب على قناة",
|
||||
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "رفع ولا اختار قائمة تشغيل أخرى",
|
||||
"ALL_CHANNELS": "جميع القنوات",
|
||||
"GROUPS": "المجموعات",
|
||||
@@ -557,14 +558,9 @@
|
||||
}
|
||||
},
|
||||
"TOP_MENU": {
|
||||
"OPEN_CHANNELS_LIST": "حل قائمة القنوات",
|
||||
"TOGGLE_FAVORITE_FLAG": "بدل علامة المفضلة",
|
||||
"OPEN_EPG_LIST": "حل قائمة EPG",
|
||||
"OPEN_MULTI_EPG": "حل عرض EPG المتعدد",
|
||||
"SHOW_INFO": "وري معلومات البرنامج (ضغط على I)"
|
||||
"OPEN_MULTI_EPG": "حل عرض EPG المتعدد"
|
||||
},
|
||||
"EPG": {
|
||||
"TITLE": "دليل البرامج الإلكتروني",
|
||||
"NEXT_DAY": "اليوم التالي",
|
||||
"PREVIOUS_DAY": "اليوم السابق",
|
||||
"ZOOM_IN": "كبر",
|
||||
@@ -573,14 +569,9 @@
|
||||
"CLOSE_FILTER": "سد الفلتر",
|
||||
"SEARCH_PROGRAMS": "قلب على البرامج",
|
||||
"CLOSE_PROGRAM_SEARCH": "سد البحث على البرامج",
|
||||
"LIVE_NOW": "مباشر الآن",
|
||||
"LIVE_STREAM": "بث مباشر",
|
||||
"TIMESHIFT_AVAILABLE": "التأجيل الزمني متوفر، ضغط باش تلعب",
|
||||
"ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "تشغيل الأرشيف ما متوفرش",
|
||||
"ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "هاد المزود كيوفر تاريخ جدول البرامج للقناة، ولكن ما كيوفرش تشغيل أرشيف/Catchup.",
|
||||
"EPG_NOT_AVAILABLE_DATE": "أوه، EPG ما متوفرش للتاريخ اللي اختاريتيه",
|
||||
"NO_GUIDE_HINT": "جرب تاريخ آخر أو حدّث اللائحة.",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "أوه، EPG ما متوفرش لهاد القناة.",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "عفاك زيد/بدل رابط قائمة EPG في إعدادات التطبيق.",
|
||||
"FETCH_EPG": "جاري جلب بيانات EPG...",
|
||||
"ERROR": "أوه، EPG ما قدرش يتشارجا.",
|
||||
@@ -615,7 +606,6 @@
|
||||
"TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.",
|
||||
"CURRENT_PROGRAM": "Current program",
|
||||
"ARCHIVE_PLAYBACK": "تشغيل الأرشيف",
|
||||
"RETURN_TO_LIVE": "رجع للبث المباشر",
|
||||
"COLLAPSE_PANEL": "Collapse EPG panel",
|
||||
"EXPAND_PANEL": "Expand EPG panel",
|
||||
"TIMELINE": {
|
||||
@@ -679,7 +669,6 @@
|
||||
"MENU": {
|
||||
"OPEN": "حل القائمة",
|
||||
"SUPPORT_ARIA": "دعم IPTVnator على GitHub",
|
||||
"SETTINGS": "الإعدادات",
|
||||
"SETTINGS_ARIA": "حل الإعدادات",
|
||||
"BUG_REPORT": "بلغ على شي مشكل",
|
||||
"WHAT_IS_NEW": "شنو الجديد",
|
||||
@@ -804,10 +793,7 @@
|
||||
"SEARCH": "بحث",
|
||||
"RECENT": "أخير",
|
||||
"RECENTLY_ADDED": "تزادو مؤخراً",
|
||||
"FAVORITES": "المفضلة",
|
||||
"HOME": "الرئيسية",
|
||||
"PLAYLIST_INFO": "معلومات قائمة التشغيل",
|
||||
"ACCOUNT_INFO": "معلومات الحساب"
|
||||
"FAVORITES": "المفضلة"
|
||||
},
|
||||
"ERROR_VIEW": {
|
||||
"IMPORT_CANCELLED": {
|
||||
|
||||
@@ -192,10 +192,8 @@
|
||||
"VALIDATING": "Праверка партала..."
|
||||
},
|
||||
"FILTER_BY_NAME": "Фільтраваць па імені",
|
||||
"FILTER_BY_TYPE": "Фільтраваць па тыпу",
|
||||
"FILTER_AND_SORT": "Фільтр і сартаванне",
|
||||
"SORT_PLAYLISTS": "Сартаваць плэйлісты",
|
||||
"SORT_BY": "Сартаваць па",
|
||||
"SORT_OPTIONS": {
|
||||
"NAME_ASC": "Назва (А-Я)",
|
||||
"NAME_DESC": "Назва (Я-А)",
|
||||
@@ -292,6 +290,10 @@
|
||||
"COVER_SIZE_SMALL": "Малы",
|
||||
"COVER_SIZE_MEDIUM": "Сярэдні",
|
||||
"COVER_SIZE_LARGE": "Вялікі",
|
||||
"EPG_VIEW_MODE": "Выгляд праграмы перадач",
|
||||
"EPG_VIEW_MODE_DESCRIPTION": "Выберыце, як паказваць праграму перадач пад прайгравальнікам: гарызантальнай стужкай часу або вертыкальным спісам на адзін дзень.",
|
||||
"EPG_VIEW_MODE_TIMELINE": "Стужка часу",
|
||||
"EPG_VIEW_MODE_LIST": "Спіс",
|
||||
"STARTUP_BEHAVIOR": "Старт праграмы",
|
||||
"STARTUP_BEHAVIOR_DESCRIPTION": "Выберыце, ці адкрывае IPTVnator першы даступны выгляд працоўнай прасторы, ці аднаўляе ваш апошні выгляд на ўзроўні раздзела. Першы выгляд азначае «Панэль», калі ўключана, інакш «Крыніцы».",
|
||||
"STARTUP_BEHAVIOR_FIRST_VIEW": "Першы даступны выгляд",
|
||||
@@ -483,7 +485,6 @@
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "Пераключыць спіс каналаў (⌘/Ctrl+B)"
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SEARCH_CHANNEL": "Пошук каналаў",
|
||||
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Загрузіць або выбраць іншы плэйліст",
|
||||
"ALL_CHANNELS": "Усе каналы",
|
||||
"GROUPS": "Групы",
|
||||
@@ -557,14 +558,9 @@
|
||||
}
|
||||
},
|
||||
"TOP_MENU": {
|
||||
"OPEN_CHANNELS_LIST": "Адкрыць спіс каналаў",
|
||||
"TOGGLE_FAVORITE_FLAG": "Выбраны",
|
||||
"OPEN_EPG_LIST": "Адкрыць праграму (EPG)",
|
||||
"OPEN_MULTI_EPG": "Адкрыць мульты-EPG прагляд",
|
||||
"SHOW_INFO": "Паказаць інфармацыю аб праграме (націсніце I)"
|
||||
"OPEN_MULTI_EPG": "Адкрыць мульты-EPG прагляд"
|
||||
},
|
||||
"EPG": {
|
||||
"TITLE": "Электронная праграма перадач",
|
||||
"NEXT_DAY": "Наступны дзень",
|
||||
"PREVIOUS_DAY": "Папярэдні дзень",
|
||||
"ZOOM_IN": "Павялічыць маштаб",
|
||||
@@ -573,14 +569,9 @@
|
||||
"CLOSE_FILTER": "Закрыць фільтр",
|
||||
"SEARCH_PROGRAMS": "Пошук перадач",
|
||||
"CLOSE_PROGRAM_SEARCH": "Закрыць пошук перадач",
|
||||
"LIVE_NOW": "Цяпер",
|
||||
"LIVE_STREAM": "Прамая трансляцыя",
|
||||
"TIMESHIFT_AVAILABLE": "Даступная функцыя архіва (timeshift). Націсніце на перадачу для прайгравання.",
|
||||
"ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "Прайграванне архіва недаступна",
|
||||
"ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "Гэты правайдэр прадастаўляе гісторыю перадач для канала, але не падтрымлівае прайграванне архіва/catch-up.",
|
||||
"EPG_NOT_AVAILABLE_DATE": "Ой, праграма на гэты дзень, на жаль, адсутнічае.",
|
||||
"NO_GUIDE_HINT": "Паспрабуйце іншую дату або абнавіце плэйліст.",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "Праграма для гэтага канала недаступная.",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Паспрабуйце змяніць або абнавіць спасылку на EPG у наладах праграмы.",
|
||||
"FETCH_EPG": "Загружаю праграму ТБ-перадач (EPG)...",
|
||||
"ERROR": "Ой, не ўдалося загрузіць праграму каналаў (EPG)",
|
||||
@@ -615,7 +606,6 @@
|
||||
"TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.",
|
||||
"CURRENT_PROGRAM": "Current program",
|
||||
"ARCHIVE_PLAYBACK": "Прайграванне архіва",
|
||||
"RETURN_TO_LIVE": "Вярнуцца да эфіру",
|
||||
"COLLAPSE_PANEL": "Collapse EPG panel",
|
||||
"EXPAND_PANEL": "Expand EPG panel",
|
||||
"TIMELINE": {
|
||||
@@ -679,7 +669,6 @@
|
||||
"MENU": {
|
||||
"OPEN": "Адкрыць меню",
|
||||
"SUPPORT_ARIA": "Падтрымаць IPTVnator на GitHub",
|
||||
"SETTINGS": "Налады",
|
||||
"SETTINGS_ARIA": "Адкрыць налады",
|
||||
"BUG_REPORT": "Паведаміць аб памылцы",
|
||||
"WHAT_IS_NEW": "Што новага?",
|
||||
@@ -804,10 +793,7 @@
|
||||
"SEARCH": "Пошук",
|
||||
"RECENT": "Нядаўнія",
|
||||
"RECENTLY_ADDED": "Нядаўна дададзеныя",
|
||||
"FAVORITES": "Выбранае",
|
||||
"HOME": "Галоўная",
|
||||
"PLAYLIST_INFO": "Інфармацыя аб плэйлісце",
|
||||
"ACCOUNT_INFO": "Інфармацыя аб уліковым запісе"
|
||||
"FAVORITES": "Выбранае"
|
||||
},
|
||||
"ERROR_VIEW": {
|
||||
"IMPORT_CANCELLED": {
|
||||
|
||||
@@ -192,10 +192,8 @@
|
||||
"VALIDATING": "Portal wird überprüft …"
|
||||
},
|
||||
"FILTER_BY_NAME": "Nach Namen filtern",
|
||||
"FILTER_BY_TYPE": "Playlist-Typen",
|
||||
"FILTER_AND_SORT": "Filtern & Sortieren",
|
||||
"SORT_PLAYLISTS": "Playlisten sortieren",
|
||||
"SORT_BY": "Sortieren nach",
|
||||
"SORT_OPTIONS": {
|
||||
"NAME_ASC": "Name (A-Z)",
|
||||
"NAME_DESC": "Name (Z-A)",
|
||||
@@ -292,6 +290,10 @@
|
||||
"COVER_SIZE_SMALL": "Klein",
|
||||
"COVER_SIZE_MEDIUM": "Mittel",
|
||||
"COVER_SIZE_LARGE": "Groß",
|
||||
"EPG_VIEW_MODE": "Programmführer-Ansicht",
|
||||
"EPG_VIEW_MODE_DESCRIPTION": "Wählen Sie, wie der Programmführer unter dem Player angezeigt wird: als horizontales Zeitleisten-Band oder als vertikale Tagesliste.",
|
||||
"EPG_VIEW_MODE_TIMELINE": "Zeitleiste",
|
||||
"EPG_VIEW_MODE_LIST": "Liste",
|
||||
"STARTUP_BEHAVIOR": "Startansicht",
|
||||
"STARTUP_BEHAVIOR_DESCRIPTION": "Legen Sie fest, ob IPTVnator die erste verfügbare Workspace-Ansicht oder Ihre zuletzt geöffnete Abschnittsansicht startet. Erste Ansicht bedeutet Dashboard, wenn aktiviert, sonst Quellen.",
|
||||
"STARTUP_BEHAVIOR_FIRST_VIEW": "Erste verfügbare Ansicht",
|
||||
@@ -483,7 +485,6 @@
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "Senderliste umschalten (⌘/Strg+B)"
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SEARCH_CHANNEL": "Nach Sender suchen",
|
||||
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Neue Playlist hochladen oder eine andere aussuchen",
|
||||
"ALL_CHANNELS": "Alle Sender",
|
||||
"GROUPS": "Gruppen",
|
||||
@@ -557,14 +558,9 @@
|
||||
}
|
||||
},
|
||||
"TOP_MENU": {
|
||||
"OPEN_CHANNELS_LIST": "Senderliste öffnen",
|
||||
"TOGGLE_FAVORITE_FLAG": "Als Favoriten hinzufügen/entfernen",
|
||||
"OPEN_EPG_LIST": "TV-Guide (EPG) öffnen",
|
||||
"OPEN_MULTI_EPG": "Open Multi-EPG view",
|
||||
"SHOW_INFO": "Programminfo anzeigen (Taste I)"
|
||||
"OPEN_MULTI_EPG": "Open Multi-EPG view"
|
||||
},
|
||||
"EPG": {
|
||||
"TITLE": "Elektronischer Programmführer",
|
||||
"NEXT_DAY": "Zum nächsten Tag",
|
||||
"PREVIOUS_DAY": "Zum vorherigen Tag",
|
||||
"ZOOM_IN": "Vergrößern",
|
||||
@@ -573,14 +569,9 @@
|
||||
"CLOSE_FILTER": "Filter schließen",
|
||||
"SEARCH_PROGRAMS": "Sendungen suchen",
|
||||
"CLOSE_PROGRAM_SEARCH": "Sendungssuche schließen",
|
||||
"LIVE_NOW": "Live jetzt",
|
||||
"LIVE_STREAM": "Live stream",
|
||||
"TIMESHIFT_AVAILABLE": "Die Timeshift-Funktion (Archiv) ist verfügbar, klicken Sie an um abzuspielen",
|
||||
"ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "Archivwiedergabe nicht verfügbar",
|
||||
"ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "Dieser Anbieter stellt zwar den Programmverlauf für den Sender bereit, jedoch keine abspielbare Archiv-/Catchup-Wiedergabe.",
|
||||
"EPG_NOT_AVAILABLE_DATE": "Ups, leider ist das TV-Programm für das ausgewählte Datum nicht verfügbar",
|
||||
"NO_GUIDE_HINT": "Versuchen Sie ein anderes Datum oder aktualisieren Sie die Playlist.",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "Ups, leider ist das TV-Programm nicht verfügbar.",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Versuchen Sie bitte die TV-Guide Einstellungen zu ändern.",
|
||||
"FETCH_EPG": "EPG-Daten werden geladen...",
|
||||
"ERROR": "Ooops, EPG konnte nicht geladen werden",
|
||||
@@ -615,7 +606,6 @@
|
||||
"TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.",
|
||||
"CURRENT_PROGRAM": "Current program",
|
||||
"ARCHIVE_PLAYBACK": "Archivwiedergabe",
|
||||
"RETURN_TO_LIVE": "Zurück zum Live-TV",
|
||||
"COLLAPSE_PANEL": "Collapse EPG panel",
|
||||
"EXPAND_PANEL": "Expand EPG panel",
|
||||
"TIMELINE": {
|
||||
@@ -679,7 +669,6 @@
|
||||
"MENU": {
|
||||
"OPEN": "Menü öffnen",
|
||||
"SUPPORT_ARIA": "IPTVnator auf GitHub unterstützen",
|
||||
"SETTINGS": "Einstellungen",
|
||||
"SETTINGS_ARIA": "Einstellungen öffnen",
|
||||
"BUG_REPORT": "Fehler melden",
|
||||
"WHAT_IS_NEW": "Was ist neu",
|
||||
@@ -804,10 +793,7 @@
|
||||
"SEARCH": "Suche",
|
||||
"RECENT": "Zuletzt angesehen",
|
||||
"RECENTLY_ADDED": "Kürzlich hinzugefügt",
|
||||
"FAVORITES": "Favoriten",
|
||||
"HOME": "Startseite",
|
||||
"PLAYLIST_INFO": "Playlist-Info",
|
||||
"ACCOUNT_INFO": "Account-Info"
|
||||
"FAVORITES": "Favoriten"
|
||||
},
|
||||
"ERROR_VIEW": {
|
||||
"IMPORT_CANCELLED": {
|
||||
|
||||
@@ -192,10 +192,8 @@
|
||||
"VALIDATING": "Επικύρωση πύλης..."
|
||||
},
|
||||
"FILTER_BY_NAME": "Φιλτράρισμα κατά όνομα",
|
||||
"FILTER_BY_TYPE": "Τύποι λιστών αναπαραγωγής",
|
||||
"FILTER_AND_SORT": "Φιλτράρισμα & Ταξινόμηση",
|
||||
"SORT_PLAYLISTS": "Ταξινόμηση λιστών αναπαραγωγής",
|
||||
"SORT_BY": "Ταξινόμηση κατά",
|
||||
"SORT_OPTIONS": {
|
||||
"NAME_ASC": "Όνομα (A-Z)",
|
||||
"NAME_DESC": "Όνομα (Z-A)",
|
||||
@@ -292,6 +290,10 @@
|
||||
"COVER_SIZE_SMALL": "Μικρό",
|
||||
"COVER_SIZE_MEDIUM": "Μεσαίο",
|
||||
"COVER_SIZE_LARGE": "Μεγάλο",
|
||||
"EPG_VIEW_MODE": "Προβολή οδηγού",
|
||||
"EPG_VIEW_MODE_DESCRIPTION": "Επιλέξτε πώς εμφανίζεται ο οδηγός προγράμματος κάτω από το πρόγραμμα αναπαραγωγής: ως οριζόντια λωρίδα χρονολογίου ή ως κατακόρυφη λίστα μίας ημέρας.",
|
||||
"EPG_VIEW_MODE_TIMELINE": "Χρονολόγιο",
|
||||
"EPG_VIEW_MODE_LIST": "Λίστα",
|
||||
"STARTUP_BEHAVIOR": "Προβολή εκκίνησης",
|
||||
"STARTUP_BEHAVIOR_DESCRIPTION": "Επιλέξτε αν το IPTVnator ανοίγει την πρώτη διαθέσιμη προβολή του χώρου εργασίας ή επαναφέρει την τελευταία προβολή σας. Πρώτη προβολή σημαίνει ο πίνακας ελέγχου, όταν είναι ενεργοποιημένος, διαφορετικά οι Πηγές.",
|
||||
"STARTUP_BEHAVIOR_FIRST_VIEW": "Πρώτη διαθέσιμη προβολή",
|
||||
@@ -483,7 +485,6 @@
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "Εναλλαγή λίστας καναλιών (⌘/Ctrl+B)"
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SEARCH_CHANNEL": "Αναζήτηση καναλιού",
|
||||
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Μεταφορτώστε ή επιλέξτε άλλη λίστα αναπαραγωγής",
|
||||
"ALL_CHANNELS": "Όλα τα κανάλια",
|
||||
"GROUPS": "Ομάδες",
|
||||
@@ -557,14 +558,9 @@
|
||||
}
|
||||
},
|
||||
"TOP_MENU": {
|
||||
"OPEN_CHANNELS_LIST": "Άνοιγμα λίστας καναλιών",
|
||||
"TOGGLE_FAVORITE_FLAG": "Εναλλαγή σημαίας αγαπημένων",
|
||||
"OPEN_EPG_LIST": "Άνοιγμα λίστας EPG",
|
||||
"OPEN_MULTI_EPG": "Άνοιγμα προβολής Πολλαπλού EPG",
|
||||
"SHOW_INFO": "Εμφάνιση πληροφοριών προγράμματος (Πατήστε το I)"
|
||||
"OPEN_MULTI_EPG": "Άνοιγμα προβολής Πολλαπλού EPG"
|
||||
},
|
||||
"EPG": {
|
||||
"TITLE": "Ηλεκτρονικός Οδηγός Προγράμματος",
|
||||
"NEXT_DAY": "Επόμενη μέρα",
|
||||
"PREVIOUS_DAY": "Προηγούμενη ημέρα",
|
||||
"ZOOM_IN": "Μεγέθυνση",
|
||||
@@ -573,14 +569,9 @@
|
||||
"CLOSE_FILTER": "Κλείσιμο φίλτρου",
|
||||
"SEARCH_PROGRAMS": "Αναζήτηση προγραμμάτων",
|
||||
"CLOSE_PROGRAM_SEARCH": "Κλείσιμο αναζήτησης προγραμμάτων",
|
||||
"LIVE_NOW": "Ζωντανά τώρα",
|
||||
"LIVE_STREAM": "Ζωντανή ροή",
|
||||
"TIMESHIFT_AVAILABLE": "Η χρονική μετατόπιση είναι διαθέσιμη, κάντε κλικ για αναπαραγωγή.",
|
||||
"ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "Η αναπαραγωγή αρχείου δεν είναι διαθέσιμη",
|
||||
"ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "Αυτός ο πάροχος εκθέτει ιστορικό προγράμματος για το κανάλι, αλλά δεν παρέχει αναπαραγωγή αρχείου/catch-up.",
|
||||
"EPG_NOT_AVAILABLE_DATE": "Ωχ, το EPG δεν είναι διαθέσιμο για την επιλεγμένη ημερομηνία",
|
||||
"NO_GUIDE_HINT": "Δοκιμάστε άλλη ημερομηνία ή ανανεώστε τη λίστα αναπαραγωγής.",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "Ωχ, το EPG δεν είναι διαθέσιμο για αυτό το κανάλι.",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Προσθέστε/αλλάξτε τη διεύθυνση URL της λίστας EPG στις ρυθμίσεις της εφαρμογής.",
|
||||
"FETCH_EPG": "Ανάκτηση δεδομένων EPG...",
|
||||
"ERROR": "Ωχ, δεν ήταν δυνατή η φόρτωση του EPG.",
|
||||
@@ -615,7 +606,6 @@
|
||||
"TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.",
|
||||
"CURRENT_PROGRAM": "Current program",
|
||||
"ARCHIVE_PLAYBACK": "Αναπαραγωγή αρχείου",
|
||||
"RETURN_TO_LIVE": "Επιστροφή στη ζωντανή ροή",
|
||||
"COLLAPSE_PANEL": "Collapse EPG panel",
|
||||
"EXPAND_PANEL": "Expand EPG panel",
|
||||
"TIMELINE": {
|
||||
@@ -679,7 +669,6 @@
|
||||
"MENU": {
|
||||
"OPEN": "Άνοιγμα μενού",
|
||||
"SUPPORT_ARIA": "Υποστήριξη του IPTVnator στο GitHub",
|
||||
"SETTINGS": "Ρυθμίσεις",
|
||||
"SETTINGS_ARIA": "Άνοιγμα ρυθμίσεων",
|
||||
"BUG_REPORT": "Αναφορά σφάλματος",
|
||||
"WHAT_IS_NEW": "Τι νέο υπάρχει",
|
||||
@@ -804,10 +793,7 @@
|
||||
"SEARCH": "Αναζήτηση",
|
||||
"RECENT": "Πρόσφατα",
|
||||
"RECENTLY_ADDED": "Πρόσφατα προστέθηκε",
|
||||
"FAVORITES": "Αγαπημένα",
|
||||
"HOME": "Αρχικό",
|
||||
"PLAYLIST_INFO": "Πληροφορίες λίστας αναπαραγωγής",
|
||||
"ACCOUNT_INFO": "Πληροφορίες λογαριασμού"
|
||||
"FAVORITES": "Αγαπημένα"
|
||||
},
|
||||
"ERROR_VIEW": {
|
||||
"IMPORT_CANCELLED": {
|
||||
|
||||
@@ -192,10 +192,8 @@
|
||||
"VALIDATING": "Validating portal..."
|
||||
},
|
||||
"FILTER_BY_NAME": "Filter by name",
|
||||
"FILTER_BY_TYPE": "Playlist Types",
|
||||
"FILTER_AND_SORT": "Filter & Sort",
|
||||
"SORT_PLAYLISTS": "Sort playlists",
|
||||
"SORT_BY": "Sort By",
|
||||
"SORT_OPTIONS": {
|
||||
"NAME_ASC": "Name (A-Z)",
|
||||
"NAME_DESC": "Name (Z-A)",
|
||||
@@ -292,6 +290,10 @@
|
||||
"COVER_SIZE_SMALL": "Small",
|
||||
"COVER_SIZE_MEDIUM": "Medium",
|
||||
"COVER_SIZE_LARGE": "Large",
|
||||
"EPG_VIEW_MODE": "Guide view",
|
||||
"EPG_VIEW_MODE_DESCRIPTION": "Choose how the programme guide under the player is shown: a horizontal timeline ribbon or a vertical, single-day list.",
|
||||
"EPG_VIEW_MODE_TIMELINE": "Timeline",
|
||||
"EPG_VIEW_MODE_LIST": "List",
|
||||
"STARTUP_BEHAVIOR": "Startup view",
|
||||
"STARTUP_BEHAVIOR_DESCRIPTION": "Choose whether IPTVnator opens the first available workspace view or restores your last section-level view. First view means Dashboard when enabled, otherwise Sources.",
|
||||
"STARTUP_BEHAVIOR_FIRST_VIEW": "First available view",
|
||||
@@ -483,7 +485,6 @@
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "Toggle channels list (⌘/Ctrl+B)"
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SEARCH_CHANNEL": "Search channel",
|
||||
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Upload or select other playlist",
|
||||
"ALL_CHANNELS": "All channels",
|
||||
"GROUPS": "Groups",
|
||||
@@ -557,14 +558,9 @@
|
||||
}
|
||||
},
|
||||
"TOP_MENU": {
|
||||
"OPEN_CHANNELS_LIST": "Open channel list",
|
||||
"TOGGLE_FAVORITE_FLAG": "Toggle favorite flag",
|
||||
"OPEN_EPG_LIST": "Open EPG list",
|
||||
"OPEN_MULTI_EPG": "Open Multi-EPG view",
|
||||
"SHOW_INFO": "Show program info (Press I)"
|
||||
"OPEN_MULTI_EPG": "Open Multi-EPG view"
|
||||
},
|
||||
"EPG": {
|
||||
"TITLE": "Electronic Program Guide",
|
||||
"NEXT_DAY": "Next day",
|
||||
"PREVIOUS_DAY": "Previous day",
|
||||
"ZOOM_IN": "Zoom in",
|
||||
@@ -573,14 +569,9 @@
|
||||
"CLOSE_FILTER": "Close filter",
|
||||
"SEARCH_PROGRAMS": "Search programs",
|
||||
"CLOSE_PROGRAM_SEARCH": "Close program search",
|
||||
"LIVE_NOW": "Live now",
|
||||
"LIVE_STREAM": "Live stream",
|
||||
"TIMESHIFT_AVAILABLE": "Timeshift is available, click to play",
|
||||
"ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "Archive playback is unavailable",
|
||||
"ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "This provider exposes schedule history for the channel, but does not expose playable archive/catch-up playback.",
|
||||
"EPG_NOT_AVAILABLE_DATE": "No program guide",
|
||||
"NO_GUIDE_HINT": "Try a different date or refresh the playlist.",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "Ooops, EPG is not available for this channel.",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Please add/change the URL of the EPG list in the settings of the app.",
|
||||
"FETCH_EPG": "Fetching EPG data...",
|
||||
"ERROR": "Ooops, EPG could not be loaded.",
|
||||
@@ -615,7 +606,6 @@
|
||||
"TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.",
|
||||
"CURRENT_PROGRAM": "Current program",
|
||||
"ARCHIVE_PLAYBACK": "Archive playback",
|
||||
"RETURN_TO_LIVE": "Return to live",
|
||||
"COLLAPSE_PANEL": "Collapse EPG panel",
|
||||
"EXPAND_PANEL": "Expand EPG panel",
|
||||
"TIMELINE": {
|
||||
@@ -679,7 +669,6 @@
|
||||
"MENU": {
|
||||
"OPEN": "Open menu",
|
||||
"SUPPORT_ARIA": "Support IPTVnator on GitHub",
|
||||
"SETTINGS": "Settings",
|
||||
"SETTINGS_ARIA": "Open settings",
|
||||
"BUG_REPORT": "Report a bug",
|
||||
"WHAT_IS_NEW": "What is new",
|
||||
@@ -804,10 +793,7 @@
|
||||
"SEARCH": "Search",
|
||||
"RECENT": "Recent",
|
||||
"RECENTLY_ADDED": "Recently added",
|
||||
"FAVORITES": "Favorites",
|
||||
"HOME": "Home",
|
||||
"PLAYLIST_INFO": "Playlist info",
|
||||
"ACCOUNT_INFO": "Account info"
|
||||
"FAVORITES": "Favorites"
|
||||
},
|
||||
"ERROR_VIEW": {
|
||||
"IMPORT_CANCELLED": {
|
||||
|
||||
@@ -192,10 +192,8 @@
|
||||
"VALIDATING": "Validando portal…"
|
||||
},
|
||||
"FILTER_BY_NAME": "Filtrar por nombre",
|
||||
"FILTER_BY_TYPE": "Filtrar por tipo",
|
||||
"FILTER_AND_SORT": "Filtrar y ordenar",
|
||||
"SORT_PLAYLISTS": "Ordenar listas",
|
||||
"SORT_BY": "Ordenar por",
|
||||
"SORT_OPTIONS": {
|
||||
"NAME_ASC": "Nombre (A-Z)",
|
||||
"NAME_DESC": "Nombre (Z-A)",
|
||||
@@ -292,6 +290,10 @@
|
||||
"COVER_SIZE_SMALL": "Pequeño",
|
||||
"COVER_SIZE_MEDIUM": "Mediano",
|
||||
"COVER_SIZE_LARGE": "Grande",
|
||||
"EPG_VIEW_MODE": "Vista de la guía",
|
||||
"EPG_VIEW_MODE_DESCRIPTION": "Elige cómo se muestra la guía de programas bajo el reproductor: una cinta de cronología horizontal o una lista vertical de un solo día.",
|
||||
"EPG_VIEW_MODE_TIMELINE": "Cronología",
|
||||
"EPG_VIEW_MODE_LIST": "Lista",
|
||||
"STARTUP_BEHAVIOR": "Vista al iniciar",
|
||||
"STARTUP_BEHAVIOR_DESCRIPTION": "Elige si IPTVnator abre la primera vista disponible del espacio de trabajo o restaura la última vista a nivel de sección. Primera vista significa Panel cuando está activado, de lo contrario Fuentes.",
|
||||
"STARTUP_BEHAVIOR_FIRST_VIEW": "Primera vista disponible",
|
||||
@@ -483,7 +485,6 @@
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "Alternar lista de canales (⌘/Ctrl+B)"
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SEARCH_CHANNEL": "Buscar canal",
|
||||
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Carga o selecciona una lista de reproducción",
|
||||
"ALL_CHANNELS": "Todos los canales",
|
||||
"GROUPS": "Grupos",
|
||||
@@ -557,14 +558,9 @@
|
||||
}
|
||||
},
|
||||
"TOP_MENU": {
|
||||
"OPEN_CHANNELS_LIST": "Abrir lista de canales",
|
||||
"TOGGLE_FAVORITE_FLAG": "Alternar bandera favorita",
|
||||
"OPEN_EPG_LIST": "Abrir lista EPG",
|
||||
"OPEN_MULTI_EPG": "Abrir vista Multi-EPG",
|
||||
"SHOW_INFO": "Mostrar información del programa (presiona I)"
|
||||
"OPEN_MULTI_EPG": "Abrir vista Multi-EPG"
|
||||
},
|
||||
"EPG": {
|
||||
"TITLE": "Guía electrónica de programación",
|
||||
"NEXT_DAY": "Día siguiente",
|
||||
"PREVIOUS_DAY": "Día anterior",
|
||||
"ZOOM_IN": "Acercar",
|
||||
@@ -573,14 +569,9 @@
|
||||
"CLOSE_FILTER": "Cerrar filtro",
|
||||
"SEARCH_PROGRAMS": "Buscar programas",
|
||||
"CLOSE_PROGRAM_SEARCH": "Cerrar búsqueda de programas",
|
||||
"LIVE_NOW": "Ahora en vivo",
|
||||
"LIVE_STREAM": "Transmisión en vivo",
|
||||
"TIMESHIFT_AVAILABLE": "Timeshift está disponible, clic para reproducir",
|
||||
"ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "La reproducción del archivo no está disponible",
|
||||
"ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "Este proveedor expone el historial de programación del canal, pero no permite la reproducción del archivo/Catchup.",
|
||||
"EPG_NOT_AVAILABLE_DATE": "Vaya, no está disponible la EPG de esa fecha",
|
||||
"NO_GUIDE_HINT": "Prueba otra fecha o actualiza la lista de reproducción.",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "Vaya, no está disponible la EPG del canal.",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Por favor añada/cargue la url de la lista EPG en los ajustes de la app.",
|
||||
"FETCH_EPG": "Obteniendo datos de EPG...",
|
||||
"ERROR": "¡Vaya! Esta EPG no se pudo cargar.",
|
||||
@@ -615,7 +606,6 @@
|
||||
"TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.",
|
||||
"CURRENT_PROGRAM": "Current program",
|
||||
"ARCHIVE_PLAYBACK": "Reproducción de archivo",
|
||||
"RETURN_TO_LIVE": "Volver al directo",
|
||||
"COLLAPSE_PANEL": "Collapse EPG panel",
|
||||
"EXPAND_PANEL": "Expand EPG panel",
|
||||
"TIMELINE": {
|
||||
@@ -679,7 +669,6 @@
|
||||
"MENU": {
|
||||
"OPEN": "Abrir menú",
|
||||
"SUPPORT_ARIA": "Apoyar IPTVnator en GitHub",
|
||||
"SETTINGS": "Ajustes",
|
||||
"SETTINGS_ARIA": "Abrir ajustes",
|
||||
"BUG_REPORT": "Reportar un bug",
|
||||
"WHAT_IS_NEW": "Novedades",
|
||||
@@ -804,10 +793,7 @@
|
||||
"SEARCH": "Buscar",
|
||||
"RECENT": "Reciente",
|
||||
"RECENTLY_ADDED": "Añadidos recientemente",
|
||||
"FAVORITES": "Favoritos",
|
||||
"HOME": "Inicio",
|
||||
"PLAYLIST_INFO": "Información de la lista",
|
||||
"ACCOUNT_INFO": "Información de la cuenta"
|
||||
"FAVORITES": "Favoritos"
|
||||
},
|
||||
"ERROR_VIEW": {
|
||||
"IMPORT_CANCELLED": {
|
||||
|
||||
@@ -192,10 +192,8 @@
|
||||
"VALIDATING": "Validation du portail…"
|
||||
},
|
||||
"FILTER_BY_NAME": "Filtrer par nom",
|
||||
"FILTER_BY_TYPE": "Filtrer par type",
|
||||
"FILTER_AND_SORT": "Filtrer et trier",
|
||||
"SORT_PLAYLISTS": "Trier les listes",
|
||||
"SORT_BY": "Trier par",
|
||||
"SORT_OPTIONS": {
|
||||
"NAME_ASC": "Nom (A-Z)",
|
||||
"NAME_DESC": "Nom (Z-A)",
|
||||
@@ -292,6 +290,10 @@
|
||||
"COVER_SIZE_SMALL": "Petite",
|
||||
"COVER_SIZE_MEDIUM": "Moyenne",
|
||||
"COVER_SIZE_LARGE": "Grande",
|
||||
"EPG_VIEW_MODE": "Vue du guide",
|
||||
"EPG_VIEW_MODE_DESCRIPTION": "Choisissez comment le guide des programmes sous le lecteur est affiché : une frise chronologique horizontale ou une liste verticale d'une seule journée.",
|
||||
"EPG_VIEW_MODE_TIMELINE": "Chronologie",
|
||||
"EPG_VIEW_MODE_LIST": "Liste",
|
||||
"STARTUP_BEHAVIOR": "Vue de démarrage",
|
||||
"STARTUP_BEHAVIOR_DESCRIPTION": "Choisissez si IPTVnator ouvre la première vue disponible de l'espace de travail ou restaure votre dernière vue de section. Première vue signifie Tableau de bord lorsqu'il est activé, sinon Sources.",
|
||||
"STARTUP_BEHAVIOR_FIRST_VIEW": "Première vue disponible",
|
||||
@@ -483,7 +485,6 @@
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "Basculer la liste des chaînes (⌘/Ctrl+B)"
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SEARCH_CHANNEL": "Rechercher une chaîne",
|
||||
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Transférer ou choisir une autre liste de lecture",
|
||||
"ALL_CHANNELS": "Toutes les chaînes",
|
||||
"GROUPS": "Groupes",
|
||||
@@ -557,14 +558,9 @@
|
||||
}
|
||||
},
|
||||
"TOP_MENU": {
|
||||
"OPEN_CHANNELS_LIST": "Ouvrir la liste des chaînes",
|
||||
"TOGGLE_FAVORITE_FLAG": "Basculer le statut de favoris",
|
||||
"OPEN_EPG_LIST": "Ouvrir la liste EPG",
|
||||
"OPEN_MULTI_EPG": "Ouvrir la vue Multi-EPG",
|
||||
"SHOW_INFO": "Afficher les infos du programme (Appuyez sur I)"
|
||||
"OPEN_MULTI_EPG": "Ouvrir la vue Multi-EPG"
|
||||
},
|
||||
"EPG": {
|
||||
"TITLE": "Guide électronique des programmes",
|
||||
"NEXT_DAY": "Jour suivant",
|
||||
"PREVIOUS_DAY": "Jour précédent",
|
||||
"ZOOM_IN": "Zoom avant",
|
||||
@@ -573,14 +569,9 @@
|
||||
"CLOSE_FILTER": "Fermer le filtre",
|
||||
"SEARCH_PROGRAMS": "Rechercher des programmes",
|
||||
"CLOSE_PROGRAM_SEARCH": "Fermer la recherche de programmes",
|
||||
"LIVE_NOW": "En direct",
|
||||
"LIVE_STREAM": "Diffusion en direct",
|
||||
"TIMESHIFT_AVAILABLE": "Contrôle du direct disponible, cliquez pour lire",
|
||||
"ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "Lecture des archives indisponible",
|
||||
"ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "Ce fournisseur expose l'historique des programmes pour la chaîne, mais n'expose pas la lecture archive/catch-up.",
|
||||
"EPG_NOT_AVAILABLE_DATE": "L'EPG n'est pas disponible pour la date sélectionnée",
|
||||
"NO_GUIDE_HINT": "Essayez une autre date ou actualisez la playlist.",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "L'EPG n'est pas disponible pour cette chaîne.",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Veuillez ajouter/modifier l'URL de la liste EPG dans les réglages de l'application.",
|
||||
"FETCH_EPG": "Récupération des données EPG...",
|
||||
"ERROR": "L'EPG n'a pas pu être chargé.",
|
||||
@@ -615,7 +606,6 @@
|
||||
"TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.",
|
||||
"CURRENT_PROGRAM": "Current program",
|
||||
"ARCHIVE_PLAYBACK": "Lecture archive",
|
||||
"RETURN_TO_LIVE": "Retour au direct",
|
||||
"COLLAPSE_PANEL": "Collapse EPG panel",
|
||||
"EXPAND_PANEL": "Expand EPG panel",
|
||||
"TIMELINE": {
|
||||
@@ -679,7 +669,6 @@
|
||||
"MENU": {
|
||||
"OPEN": "Ouvrir le menu",
|
||||
"SUPPORT_ARIA": "Soutenir IPTVnator sur GitHub",
|
||||
"SETTINGS": "Paramètres",
|
||||
"SETTINGS_ARIA": "Ouvrir les paramètres",
|
||||
"BUG_REPORT": "Signaler un bug",
|
||||
"WHAT_IS_NEW": "Nouveautés",
|
||||
@@ -804,10 +793,7 @@
|
||||
"SEARCH": "Rechercher",
|
||||
"RECENT": "Récent",
|
||||
"RECENTLY_ADDED": "Récemment ajoutés",
|
||||
"FAVORITES": "Favoris",
|
||||
"HOME": "Accueil",
|
||||
"PLAYLIST_INFO": "Infos de la liste",
|
||||
"ACCOUNT_INFO": "Infos du compte"
|
||||
"FAVORITES": "Favoris"
|
||||
},
|
||||
"ERROR_VIEW": {
|
||||
"IMPORT_CANCELLED": {
|
||||
|
||||
@@ -192,10 +192,8 @@
|
||||
"VALIDATING": "Validazione del portale..."
|
||||
},
|
||||
"FILTER_BY_NAME": "Filtra per nome",
|
||||
"FILTER_BY_TYPE": "Filtra per tipo",
|
||||
"FILTER_AND_SORT": "Filtra e ordina",
|
||||
"SORT_PLAYLISTS": "Ordina playlist",
|
||||
"SORT_BY": "Ordina per",
|
||||
"SORT_OPTIONS": {
|
||||
"NAME_ASC": "Nome (A-Z)",
|
||||
"NAME_DESC": "Nome (Z-A)",
|
||||
@@ -292,6 +290,10 @@
|
||||
"COVER_SIZE_SMALL": "Piccola",
|
||||
"COVER_SIZE_MEDIUM": "Media",
|
||||
"COVER_SIZE_LARGE": "Grande",
|
||||
"EPG_VIEW_MODE": "Vista guida",
|
||||
"EPG_VIEW_MODE_DESCRIPTION": "Scegli come mostrare la guida programmi sotto il lettore: una fascia con timeline orizzontale o un elenco verticale di un singolo giorno.",
|
||||
"EPG_VIEW_MODE_TIMELINE": "Timeline",
|
||||
"EPG_VIEW_MODE_LIST": "Elenco",
|
||||
"STARTUP_BEHAVIOR": "Vista all'avvio",
|
||||
"STARTUP_BEHAVIOR_DESCRIPTION": "Scegli se IPTVnator apre la prima vista dell'area di lavoro disponibile o ripristina l'ultima vista a livello di sezione. La prima vista è la dashboard se attiva, altrimenti Sorgenti.",
|
||||
"STARTUP_BEHAVIOR_FIRST_VIEW": "Prima vista disponibile",
|
||||
@@ -483,7 +485,6 @@
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "Attiva/disattiva elenco canali (⌘/Ctrl+B)"
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SEARCH_CHANNEL": "Cerca canale",
|
||||
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Carica o seleziona un'altra playlist",
|
||||
"ALL_CHANNELS": "Tutti i canali",
|
||||
"GROUPS": "Gruppi",
|
||||
@@ -557,14 +558,9 @@
|
||||
}
|
||||
},
|
||||
"TOP_MENU": {
|
||||
"OPEN_CHANNELS_LIST": "Apri elenco canali",
|
||||
"TOGGLE_FAVORITE_FLAG": "Attiva/disattiva flag preferiti",
|
||||
"OPEN_EPG_LIST": "Apri elenco EPG",
|
||||
"OPEN_MULTI_EPG": "Apri vista Multi-EPG",
|
||||
"SHOW_INFO": "Mostra info programma (premi I)"
|
||||
"OPEN_MULTI_EPG": "Apri vista Multi-EPG"
|
||||
},
|
||||
"EPG": {
|
||||
"TITLE": "Guida elettronica programmi",
|
||||
"NEXT_DAY": "Giorno successivo",
|
||||
"PREVIOUS_DAY": "Giorno precedente",
|
||||
"ZOOM_IN": "Ingrandisci",
|
||||
@@ -573,14 +569,9 @@
|
||||
"CLOSE_FILTER": "Chiudi filtro",
|
||||
"SEARCH_PROGRAMS": "Cerca programmi",
|
||||
"CLOSE_PROGRAM_SEARCH": "Chiudi ricerca programmi",
|
||||
"LIVE_NOW": "Live attuale",
|
||||
"LIVE_STREAM": "Stream live",
|
||||
"TIMESHIFT_AVAILABLE": "Disponibile il timeshift, fai clic per riprodurre",
|
||||
"ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "Riproduzione archivio non disponibile",
|
||||
"ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "Questo fornitore espone la cronologia della programmazione del canale, ma non la riproduzione di archivio/catch-up.",
|
||||
"EPG_NOT_AVAILABLE_DATE": "Non è disponibile l'EPG per la data selezionata",
|
||||
"NO_GUIDE_HINT": "Prova una data diversa o aggiorna la playlist.",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "Non è disponibile l'EPG per questo canale.",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Aggiungi/modifica l'URL dell'elenco EPG nelle impostazioni dell'app.",
|
||||
"FETCH_EPG": "Recupero dati EPG...",
|
||||
"ERROR": "Non è stato possibile caricare l'EPG.",
|
||||
@@ -615,7 +606,6 @@
|
||||
"TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.",
|
||||
"CURRENT_PROGRAM": "Current program",
|
||||
"ARCHIVE_PLAYBACK": "Riproduzione archivio",
|
||||
"RETURN_TO_LIVE": "Torna alla diretta",
|
||||
"COLLAPSE_PANEL": "Collapse EPG panel",
|
||||
"EXPAND_PANEL": "Expand EPG panel",
|
||||
"TIMELINE": {
|
||||
@@ -679,7 +669,6 @@
|
||||
"MENU": {
|
||||
"OPEN": "Apri menu",
|
||||
"SUPPORT_ARIA": "Supporta IPTVnator su GitHub",
|
||||
"SETTINGS": "Impostazioni",
|
||||
"SETTINGS_ARIA": "Apri impostazioni",
|
||||
"BUG_REPORT": "Segnala un bug",
|
||||
"WHAT_IS_NEW": "Novità del programma",
|
||||
@@ -804,10 +793,7 @@
|
||||
"SEARCH": "Cerca",
|
||||
"RECENT": "Recenti",
|
||||
"RECENTLY_ADDED": "Aggiunti di recente",
|
||||
"FAVORITES": "Preferiti",
|
||||
"HOME": "Home",
|
||||
"PLAYLIST_INFO": "Info playlist",
|
||||
"ACCOUNT_INFO": "Info account"
|
||||
"FAVORITES": "Preferiti"
|
||||
},
|
||||
"ERROR_VIEW": {
|
||||
"IMPORT_CANCELLED": {
|
||||
|
||||
@@ -192,10 +192,8 @@
|
||||
"VALIDATING": "ポータルを検証中..."
|
||||
},
|
||||
"FILTER_BY_NAME": "名前でフィルター",
|
||||
"FILTER_BY_TYPE": "種類でフィルター",
|
||||
"FILTER_AND_SORT": "フィルターと並び替え",
|
||||
"SORT_PLAYLISTS": "プレイリストを並び替え",
|
||||
"SORT_BY": "並び替え",
|
||||
"SORT_OPTIONS": {
|
||||
"NAME_ASC": "名前(A-Z)",
|
||||
"NAME_DESC": "名前(Z-A)",
|
||||
@@ -292,6 +290,10 @@
|
||||
"COVER_SIZE_SMALL": "小",
|
||||
"COVER_SIZE_MEDIUM": "中",
|
||||
"COVER_SIZE_LARGE": "大",
|
||||
"EPG_VIEW_MODE": "番組表の表示",
|
||||
"EPG_VIEW_MODE_DESCRIPTION": "プレーヤー下部の番組表の表示方法を選択します。横方向のタイムライン形式か、縦方向の1日分のリスト形式かを選べます。",
|
||||
"EPG_VIEW_MODE_TIMELINE": "タイムライン",
|
||||
"EPG_VIEW_MODE_LIST": "リスト",
|
||||
"STARTUP_BEHAVIOR": "起動時の表示",
|
||||
"STARTUP_BEHAVIOR_DESCRIPTION": "IPTVnatorを起動したときに最初に利用可能なワークスペースビューを開くか、最後に表示していたセクションを復元するかを選択します。「最初のビュー」は、ダッシュボードが有効な場合はダッシュボード、無効な場合はソースを意味します。",
|
||||
"STARTUP_BEHAVIOR_FIRST_VIEW": "最初に利用可能なビュー",
|
||||
@@ -483,7 +485,6 @@
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "チャンネル一覧の切り替え (⌘/Ctrl+B)"
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SEARCH_CHANNEL": "チャンネルを検索",
|
||||
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "アップロードまたは他のプレイリストを選択",
|
||||
"ALL_CHANNELS": "すべてのチャンネル",
|
||||
"GROUPS": "グループ",
|
||||
@@ -557,14 +558,9 @@
|
||||
}
|
||||
},
|
||||
"TOP_MENU": {
|
||||
"OPEN_CHANNELS_LIST": "チャンネルリストを開く",
|
||||
"TOGGLE_FAVORITE_FLAG": "お気に入りフラグを切り替え",
|
||||
"OPEN_EPG_LIST": "EPGリストを開く",
|
||||
"OPEN_MULTI_EPG": "マルチEPGビューを開く",
|
||||
"SHOW_INFO": "番組情報を表示(Iキー)"
|
||||
"OPEN_MULTI_EPG": "マルチEPGビューを開く"
|
||||
},
|
||||
"EPG": {
|
||||
"TITLE": "電子番組表",
|
||||
"NEXT_DAY": "翌日",
|
||||
"PREVIOUS_DAY": "前日",
|
||||
"ZOOM_IN": "拡大",
|
||||
@@ -573,14 +569,9 @@
|
||||
"CLOSE_FILTER": "フィルターを閉じる",
|
||||
"SEARCH_PROGRAMS": "番組を検索",
|
||||
"CLOSE_PROGRAM_SEARCH": "番組検索を閉じる",
|
||||
"LIVE_NOW": "現在放送中",
|
||||
"LIVE_STREAM": "ライブストリーム",
|
||||
"TIMESHIFT_AVAILABLE": "タイムシフトが利用可能です。クリックして再生",
|
||||
"ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "アーカイブ再生は利用できません",
|
||||
"ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "このプロバイダーはチャンネルの番組履歴を提供していますが、アーカイブ/Catchup再生には対応していません。",
|
||||
"EPG_NOT_AVAILABLE_DATE": "申し訳ありません。選択した日付のEPGは利用できません",
|
||||
"NO_GUIDE_HINT": "別の日付を試すか、プレイリストを更新してください。",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "申し訳ありません。このチャンネルのEPGは利用できません。",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "アプリの設定でEPGリストのURLを追加/変更してください。",
|
||||
"FETCH_EPG": "EPGデータを取得中...",
|
||||
"ERROR": "申し訳ありません。EPGを読み込めませんでした。",
|
||||
@@ -615,7 +606,6 @@
|
||||
"TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.",
|
||||
"CURRENT_PROGRAM": "Current program",
|
||||
"ARCHIVE_PLAYBACK": "アーカイブ再生",
|
||||
"RETURN_TO_LIVE": "ライブに戻る",
|
||||
"COLLAPSE_PANEL": "Collapse EPG panel",
|
||||
"EXPAND_PANEL": "Expand EPG panel",
|
||||
"TIMELINE": {
|
||||
@@ -679,7 +669,6 @@
|
||||
"MENU": {
|
||||
"OPEN": "メニューを開く",
|
||||
"SUPPORT_ARIA": "GitHubでIPTVnatorをサポート",
|
||||
"SETTINGS": "設定",
|
||||
"SETTINGS_ARIA": "設定を開く",
|
||||
"BUG_REPORT": "バグを報告",
|
||||
"WHAT_IS_NEW": "新着情報",
|
||||
@@ -804,10 +793,7 @@
|
||||
"SEARCH": "検索",
|
||||
"RECENT": "最近",
|
||||
"RECENTLY_ADDED": "最近追加した項目",
|
||||
"FAVORITES": "お気に入り",
|
||||
"HOME": "ホーム",
|
||||
"PLAYLIST_INFO": "プレイリスト情報",
|
||||
"ACCOUNT_INFO": "アカウント情報"
|
||||
"FAVORITES": "お気に入り"
|
||||
},
|
||||
"ERROR_VIEW": {
|
||||
"IMPORT_CANCELLED": {
|
||||
|
||||
@@ -192,10 +192,8 @@
|
||||
"VALIDATING": "포털을 검증하는 중..."
|
||||
},
|
||||
"FILTER_BY_NAME": "이름으로 필터",
|
||||
"FILTER_BY_TYPE": "재생목록 유형",
|
||||
"FILTER_AND_SORT": "필터 및 정렬",
|
||||
"SORT_PLAYLISTS": "재생목록 정렬",
|
||||
"SORT_BY": "정렬 기준",
|
||||
"SORT_OPTIONS": {
|
||||
"NAME_ASC": "이름 (A-Z)",
|
||||
"NAME_DESC": "이름 (Z-A)",
|
||||
@@ -292,6 +290,10 @@
|
||||
"COVER_SIZE_SMALL": "작게",
|
||||
"COVER_SIZE_MEDIUM": "중간",
|
||||
"COVER_SIZE_LARGE": "크게",
|
||||
"EPG_VIEW_MODE": "가이드 보기",
|
||||
"EPG_VIEW_MODE_DESCRIPTION": "플레이어 아래 프로그램 가이드를 표시하는 방식을 선택합니다: 가로 타임라인 리본 또는 하루 단위의 세로 목록입니다.",
|
||||
"EPG_VIEW_MODE_TIMELINE": "타임라인",
|
||||
"EPG_VIEW_MODE_LIST": "목록",
|
||||
"STARTUP_BEHAVIOR": "시작 화면",
|
||||
"STARTUP_BEHAVIOR_DESCRIPTION": "IPTVnator를 처음 시작할 때 첫 번째로 사용 가능한 작업 공간 화면을 열지, 마지막으로 본 섹션 화면을 복원할지 선택합니다. 첫 번째 화면은 활성화된 경우 대시보드, 그렇지 않으면 소스를 의미합니다.",
|
||||
"STARTUP_BEHAVIOR_FIRST_VIEW": "첫 번째로 사용 가능한 화면",
|
||||
@@ -483,7 +485,6 @@
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "채널 목록 전환 (⌘/Ctrl+B)"
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SEARCH_CHANNEL": "검색 채널",
|
||||
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "다른 재생 목록을 업로드하거나 선택하십시오",
|
||||
"ALL_CHANNELS": "모든 채널",
|
||||
"GROUPS": "그룹",
|
||||
@@ -557,14 +558,9 @@
|
||||
}
|
||||
},
|
||||
"TOP_MENU": {
|
||||
"OPEN_CHANNELS_LIST": "채널 열기 목록",
|
||||
"TOGGLE_FAVORITE_FLAG": "즐겨찾기 보이기/숨기기",
|
||||
"OPEN_EPG_LIST": "EPG 목록 열기",
|
||||
"OPEN_MULTI_EPG": "Open Multi-EPG view",
|
||||
"SHOW_INFO": "프로그램 정보 표시 (I 키)"
|
||||
"OPEN_MULTI_EPG": "Open Multi-EPG view"
|
||||
},
|
||||
"EPG": {
|
||||
"TITLE": "전자 프로그램 가이드",
|
||||
"NEXT_DAY": "다음날",
|
||||
"PREVIOUS_DAY": "전날",
|
||||
"ZOOM_IN": "확대",
|
||||
@@ -573,14 +569,9 @@
|
||||
"CLOSE_FILTER": "필터 닫기",
|
||||
"SEARCH_PROGRAMS": "프로그램 검색",
|
||||
"CLOSE_PROGRAM_SEARCH": "프로그램 검색 닫기",
|
||||
"LIVE_NOW": "Live now",
|
||||
"LIVE_STREAM": "Live stream",
|
||||
"TIMESHIFT_AVAILABLE": "Timeshift를 사용할 수 있습니다, 클릭하여 재생합니다",
|
||||
"ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "아카이브 재생을 사용할 수 없습니다",
|
||||
"ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "이 공급자는 채널의 편성 이력을 제공하지만 재생 가능한 아카이브/Catchup 재생은 제공하지 않습니다.",
|
||||
"EPG_NOT_AVAILABLE_DATE": "선택한 날짜에는 EPG를 사용할 수 없습니다.",
|
||||
"NO_GUIDE_HINT": "다른 날짜를 시도하거나 재생 목록을 새로 고치세요.",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "이 채널에는 EPG를 사용할 수 없습니다.",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "설정에서 EPG의 URL을 추가/변경하십시오.",
|
||||
"FETCH_EPG": "EPG 데이터 가져 오기...",
|
||||
"ERROR": "Ooops, EPG could not be loaded.",
|
||||
@@ -615,7 +606,6 @@
|
||||
"TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.",
|
||||
"CURRENT_PROGRAM": "Current program",
|
||||
"ARCHIVE_PLAYBACK": "아카이브 재생",
|
||||
"RETURN_TO_LIVE": "라이브로 돌아가기",
|
||||
"COLLAPSE_PANEL": "Collapse EPG panel",
|
||||
"EXPAND_PANEL": "Expand EPG panel",
|
||||
"TIMELINE": {
|
||||
@@ -679,7 +669,6 @@
|
||||
"MENU": {
|
||||
"OPEN": "Open menu",
|
||||
"SUPPORT_ARIA": "Support IPTVnator on GitHub",
|
||||
"SETTINGS": "Settings",
|
||||
"SETTINGS_ARIA": "Open settings",
|
||||
"BUG_REPORT": "Report a bug",
|
||||
"WHAT_IS_NEW": "What is new",
|
||||
@@ -804,10 +793,7 @@
|
||||
"SEARCH": "검색",
|
||||
"RECENT": "최근",
|
||||
"RECENTLY_ADDED": "최근 추가됨",
|
||||
"FAVORITES": "즐겨찾기",
|
||||
"HOME": "홈",
|
||||
"PLAYLIST_INFO": "재생목록 정보",
|
||||
"ACCOUNT_INFO": "계정 정보"
|
||||
"FAVORITES": "즐겨찾기"
|
||||
},
|
||||
"ERROR_VIEW": {
|
||||
"IMPORT_CANCELLED": {
|
||||
|
||||
@@ -192,10 +192,8 @@
|
||||
"VALIDATING": "Portaal valideren..."
|
||||
},
|
||||
"FILTER_BY_NAME": "Filter op naam",
|
||||
"FILTER_BY_TYPE": "Filter op type",
|
||||
"FILTER_AND_SORT": "Filteren & sorteren",
|
||||
"SORT_PLAYLISTS": "Sorteer afspeellijsten",
|
||||
"SORT_BY": "Sorteer op",
|
||||
"SORT_OPTIONS": {
|
||||
"NAME_ASC": "Naam (A-Z)",
|
||||
"NAME_DESC": "Naam (Z-A)",
|
||||
@@ -292,6 +290,10 @@
|
||||
"COVER_SIZE_SMALL": "Klein",
|
||||
"COVER_SIZE_MEDIUM": "Gemiddeld",
|
||||
"COVER_SIZE_LARGE": "Groot",
|
||||
"EPG_VIEW_MODE": "Gidsweergave",
|
||||
"EPG_VIEW_MODE_DESCRIPTION": "Kies hoe de programmagids onder de speler wordt getoond: een horizontale tijdlijn of een verticale lijst voor één dag.",
|
||||
"EPG_VIEW_MODE_TIMELINE": "Tijdlijn",
|
||||
"EPG_VIEW_MODE_LIST": "Lijst",
|
||||
"STARTUP_BEHAVIOR": "Opstartweergave",
|
||||
"STARTUP_BEHAVIOR_DESCRIPTION": "Kies of IPTVnator de eerste beschikbare werkruimteweergave opent of je laatste sectieweergave herstelt. Eerste weergave betekent het dashboard wanneer ingeschakeld, anders Bronnen.",
|
||||
"STARTUP_BEHAVIOR_FIRST_VIEW": "Eerste beschikbare weergave",
|
||||
@@ -483,7 +485,6 @@
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "Zenderlijst aan-/uitzetten (⌘/Ctrl+B)"
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SEARCH_CHANNEL": "Zoek een kanaal",
|
||||
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Upload of selecteer een andere afspeellijst",
|
||||
"ALL_CHANNELS": "Alle kanalen",
|
||||
"GROUPS": "Groepen",
|
||||
@@ -557,14 +558,9 @@
|
||||
}
|
||||
},
|
||||
"TOP_MENU": {
|
||||
"OPEN_CHANNELS_LIST": "Open kanalenlijst",
|
||||
"TOGGLE_FAVORITE_FLAG": "Wissel favorietenstatus",
|
||||
"OPEN_EPG_LIST": "Open TV-gids lijst",
|
||||
"OPEN_MULTI_EPG": "Open Multi TV-Gids weergave",
|
||||
"SHOW_INFO": "Programma-info tonen (druk op I)"
|
||||
"OPEN_MULTI_EPG": "Open Multi TV-Gids weergave"
|
||||
},
|
||||
"EPG": {
|
||||
"TITLE": "TV-Gids",
|
||||
"NEXT_DAY": "Volgende dag",
|
||||
"PREVIOUS_DAY": "Vorige dag",
|
||||
"ZOOM_IN": "Inzoomen",
|
||||
@@ -573,14 +569,9 @@
|
||||
"CLOSE_FILTER": "Filter sluiten",
|
||||
"SEARCH_PROGRAMS": "Programma's zoeken",
|
||||
"CLOSE_PROGRAM_SEARCH": "Programmazoekopdracht sluiten",
|
||||
"LIVE_NOW": "Nu live",
|
||||
"LIVE_STREAM": "Live stream",
|
||||
"TIMESHIFT_AVAILABLE": "Doorspoelen is beschikbaar, klik om af te spelen",
|
||||
"ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "Archiefweergave is niet beschikbaar",
|
||||
"ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "Deze provider toont de programmageschiedenis voor het kanaal, maar biedt geen afspeelbare archief-/catch-up-weergave.",
|
||||
"EPG_NOT_AVAILABLE_DATE": "De TV-Gids is niet beschikbaar voor de geselecteerde datum",
|
||||
"NO_GUIDE_HINT": "Probeer een andere datum of vernieuw de afspeellijst.",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "De TV-Gids is niet beschikbaar voor dit kanaal.",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Voeg een TV-Gids URL toe of wijzig deze in de instellingen van de app.",
|
||||
"FETCH_EPG": "Ophalen TV-Gids...",
|
||||
"ERROR": "TV-Gids kan niet geladen worden.",
|
||||
@@ -615,7 +606,6 @@
|
||||
"TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.",
|
||||
"CURRENT_PROGRAM": "Current program",
|
||||
"ARCHIVE_PLAYBACK": "Archief afspelen",
|
||||
"RETURN_TO_LIVE": "Terug naar live",
|
||||
"COLLAPSE_PANEL": "Collapse EPG panel",
|
||||
"EXPAND_PANEL": "Expand EPG panel",
|
||||
"TIMELINE": {
|
||||
@@ -679,7 +669,6 @@
|
||||
"MENU": {
|
||||
"OPEN": "Open menu",
|
||||
"SUPPORT_ARIA": "Ondersteun IPTVnator op GitHub",
|
||||
"SETTINGS": "Instellingen",
|
||||
"SETTINGS_ARIA": "Open instellingen",
|
||||
"BUG_REPORT": "Meld een bug",
|
||||
"WHAT_IS_NEW": "Wat is er nieuw",
|
||||
@@ -804,10 +793,7 @@
|
||||
"SEARCH": "Zoeken",
|
||||
"RECENT": "Recent",
|
||||
"RECENTLY_ADDED": "Recent toegevoegd",
|
||||
"FAVORITES": "Favorieten",
|
||||
"HOME": "Home",
|
||||
"PLAYLIST_INFO": "Afspeellijstinfo",
|
||||
"ACCOUNT_INFO": "Accountinfo"
|
||||
"FAVORITES": "Favorieten"
|
||||
},
|
||||
"ERROR_VIEW": {
|
||||
"IMPORT_CANCELLED": {
|
||||
|
||||
@@ -192,10 +192,8 @@
|
||||
"VALIDATING": "Walidacja portalu..."
|
||||
},
|
||||
"FILTER_BY_NAME": "Filtruj według nazwy",
|
||||
"FILTER_BY_TYPE": "Filtruj według typu",
|
||||
"FILTER_AND_SORT": "Filtruj i sortuj",
|
||||
"SORT_PLAYLISTS": "Sortuj listy odtwarzania",
|
||||
"SORT_BY": "Sortuj według",
|
||||
"SORT_OPTIONS": {
|
||||
"NAME_ASC": "Nazwa (A-Z)",
|
||||
"NAME_DESC": "Nazwa (Z-A)",
|
||||
@@ -292,6 +290,10 @@
|
||||
"COVER_SIZE_SMALL": "Mały",
|
||||
"COVER_SIZE_MEDIUM": "Średni",
|
||||
"COVER_SIZE_LARGE": "Duży",
|
||||
"EPG_VIEW_MODE": "Widok przewodnika",
|
||||
"EPG_VIEW_MODE_DESCRIPTION": "Wybierz sposób wyświetlania przewodnika po programach pod odtwarzaczem: pozioma oś czasu lub pionowa lista dla jednego dnia.",
|
||||
"EPG_VIEW_MODE_TIMELINE": "Oś czasu",
|
||||
"EPG_VIEW_MODE_LIST": "Lista",
|
||||
"STARTUP_BEHAVIOR": "Widok startowy",
|
||||
"STARTUP_BEHAVIOR_DESCRIPTION": "Wybierz, czy IPTVnator otwiera pierwszy dostępny widok obszaru roboczego, czy przywraca ostatni widok na poziomie sekcji. Pierwszy widok oznacza pulpit, jeśli jest włączony, w przeciwnym razie Źródła.",
|
||||
"STARTUP_BEHAVIOR_FIRST_VIEW": "Pierwszy dostępny widok",
|
||||
@@ -483,7 +485,6 @@
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "Przełącz listę kanałów (⌘/Ctrl+B)"
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SEARCH_CHANNEL": "Szukaj kanału",
|
||||
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Prześlij lub wybierz inną listę odtwarzania",
|
||||
"ALL_CHANNELS": "Wszystkie kanały",
|
||||
"GROUPS": "Grupy",
|
||||
@@ -557,14 +558,9 @@
|
||||
}
|
||||
},
|
||||
"TOP_MENU": {
|
||||
"OPEN_CHANNELS_LIST": "Otwórz listę kanałów",
|
||||
"TOGGLE_FAVORITE_FLAG": "Przełącz flagę ulubionych",
|
||||
"OPEN_EPG_LIST": "Otwórz listę EPG",
|
||||
"OPEN_MULTI_EPG": "Otwórz widok Multi-EPG",
|
||||
"SHOW_INFO": "Pokaż informacje o programie (naciśnij I)"
|
||||
"OPEN_MULTI_EPG": "Otwórz widok Multi-EPG"
|
||||
},
|
||||
"EPG": {
|
||||
"TITLE": "Elektroniczny przewodnik po programach",
|
||||
"NEXT_DAY": "Następny dzień",
|
||||
"PREVIOUS_DAY": "Poprzedni dzień",
|
||||
"ZOOM_IN": "Powiększ",
|
||||
@@ -573,14 +569,9 @@
|
||||
"CLOSE_FILTER": "Zamknij filtr",
|
||||
"SEARCH_PROGRAMS": "Szukaj programów",
|
||||
"CLOSE_PROGRAM_SEARCH": "Zamknij wyszukiwanie programów",
|
||||
"LIVE_NOW": "Na żywo teraz",
|
||||
"LIVE_STREAM": "Transmisja na żywo",
|
||||
"TIMESHIFT_AVAILABLE": "Dostępne jest przesunięcie czasowe, kliknij, aby odtworzyć",
|
||||
"ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "Odtwarzanie archiwum niedostępne",
|
||||
"ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "Ten dostawca udostępnia historię programu dla kanału, ale nie udostępnia odtwarzalnego archiwum/catch-up.",
|
||||
"EPG_NOT_AVAILABLE_DATE": "Ups, EPG nie jest dostępne dla wybranej daty",
|
||||
"NO_GUIDE_HINT": "Spróbuj innej daty lub odśwież playlistę.",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "Ups, EPG nie jest dostępne dla tego kanału.",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Dodaj/zmień URL listy EPG w ustawieniach aplikacji.",
|
||||
"FETCH_EPG": "Pobieranie danych EPG...",
|
||||
"ERROR": "Ups, nie można załadować EPG.",
|
||||
@@ -615,7 +606,6 @@
|
||||
"TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.",
|
||||
"CURRENT_PROGRAM": "Current program",
|
||||
"ARCHIVE_PLAYBACK": "Odtwarzanie archiwum",
|
||||
"RETURN_TO_LIVE": "Wróć do transmisji na żywo",
|
||||
"COLLAPSE_PANEL": "Collapse EPG panel",
|
||||
"EXPAND_PANEL": "Expand EPG panel",
|
||||
"TIMELINE": {
|
||||
@@ -679,7 +669,6 @@
|
||||
"MENU": {
|
||||
"OPEN": "Otwórz menu",
|
||||
"SUPPORT_ARIA": "Wsparcie IPTVnator na GitHubie",
|
||||
"SETTINGS": "Ustawienia",
|
||||
"SETTINGS_ARIA": "Otwórz ustawienia",
|
||||
"BUG_REPORT": "Zgłoś błąd",
|
||||
"WHAT_IS_NEW": "Co nowego",
|
||||
@@ -804,10 +793,7 @@
|
||||
"SEARCH": "Szukaj",
|
||||
"RECENT": "Ostatnie",
|
||||
"RECENTLY_ADDED": "Ostatnio dodane",
|
||||
"FAVORITES": "Ulubione",
|
||||
"HOME": "Strona główna",
|
||||
"PLAYLIST_INFO": "Informacje o liście odtwarzania",
|
||||
"ACCOUNT_INFO": "Informacje o koncie"
|
||||
"FAVORITES": "Ulubione"
|
||||
},
|
||||
"ERROR_VIEW": {
|
||||
"IMPORT_CANCELLED": {
|
||||
|
||||
@@ -192,10 +192,8 @@
|
||||
"VALIDATING": "Validando portal..."
|
||||
},
|
||||
"FILTER_BY_NAME": "Filtrar por nome",
|
||||
"FILTER_BY_TYPE": "Filtrar por tipo",
|
||||
"FILTER_AND_SORT": "Filtrar e ordenar",
|
||||
"SORT_PLAYLISTS": "Ordenar playlists",
|
||||
"SORT_BY": "Ordenar por",
|
||||
"SORT_OPTIONS": {
|
||||
"NAME_ASC": "Nome (A-Z)",
|
||||
"NAME_DESC": "Nome (Z-A)",
|
||||
@@ -292,6 +290,10 @@
|
||||
"COVER_SIZE_SMALL": "Pequeno",
|
||||
"COVER_SIZE_MEDIUM": "Médio",
|
||||
"COVER_SIZE_LARGE": "Grande",
|
||||
"EPG_VIEW_MODE": "Visualização do guia",
|
||||
"EPG_VIEW_MODE_DESCRIPTION": "Escolha como o guia de programação abaixo do reprodutor é exibido: uma faixa horizontal em linha do tempo ou uma lista vertical de um único dia.",
|
||||
"EPG_VIEW_MODE_TIMELINE": "Linha do tempo",
|
||||
"EPG_VIEW_MODE_LIST": "Lista",
|
||||
"STARTUP_BEHAVIOR": "Visualização inicial",
|
||||
"STARTUP_BEHAVIOR_DESCRIPTION": "Escolha se o IPTVnator abre a primeira visualização disponível do espaço de trabalho ou restaura sua última visualização. Primeira visualização significa Dashboard quando ativada, caso contrário, Fontes.",
|
||||
"STARTUP_BEHAVIOR_FIRST_VIEW": "Primeira visualização disponível",
|
||||
@@ -483,7 +485,6 @@
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "Alternar lista de canais (⌘/Ctrl+B)"
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SEARCH_CHANNEL": "Pesquisar canal",
|
||||
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Enviar ou selecionar outra playlist",
|
||||
"ALL_CHANNELS": "Todos os canais",
|
||||
"GROUPS": "Grupos",
|
||||
@@ -557,14 +558,9 @@
|
||||
}
|
||||
},
|
||||
"TOP_MENU": {
|
||||
"OPEN_CHANNELS_LIST": "Abrir lista de canais",
|
||||
"TOGGLE_FAVORITE_FLAG": "Alternar sinalizador de favorito",
|
||||
"OPEN_EPG_LIST": "Abrir lista de EPG",
|
||||
"OPEN_MULTI_EPG": "Abrir visualização Multi-EPG",
|
||||
"SHOW_INFO": "Mostrar informações do programa (pressione I)"
|
||||
"OPEN_MULTI_EPG": "Abrir visualização Multi-EPG"
|
||||
},
|
||||
"EPG": {
|
||||
"TITLE": "Guia Eletrônico de Programação",
|
||||
"NEXT_DAY": "Próximo dia",
|
||||
"PREVIOUS_DAY": "Dia anterior",
|
||||
"ZOOM_IN": "Mais zoom",
|
||||
@@ -573,14 +569,9 @@
|
||||
"CLOSE_FILTER": "Fechar filtro",
|
||||
"SEARCH_PROGRAMS": "Pesquisar programas",
|
||||
"CLOSE_PROGRAM_SEARCH": "Fechar pesquisa de programas",
|
||||
"LIVE_NOW": "Ao vivo agora",
|
||||
"LIVE_STREAM": "Transmissão ao vivo",
|
||||
"TIMESHIFT_AVAILABLE": "Timeshift está disponível, clique para reproduzir",
|
||||
"ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "Reprodução de arquivo indisponível",
|
||||
"ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "Este provedor expõe o histórico de programação do canal, mas não disponibiliza reprodução de arquivo/Catchup.",
|
||||
"EPG_NOT_AVAILABLE_DATE": "Ooops, EPG não está disponível para a data selecionada",
|
||||
"NO_GUIDE_HINT": "Tente uma data diferente ou atualize a lista de reprodução.",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "Ooops, EPG não está disponível para este canal.",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Adicione/altere a URL da lista de EPG nas configurações do aplicativo.",
|
||||
"FETCH_EPG": "Buscando dados do EPG...",
|
||||
"ERROR": "Ooops, EPG não pôde ser carregado.",
|
||||
@@ -615,7 +606,6 @@
|
||||
"TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.",
|
||||
"CURRENT_PROGRAM": "Current program",
|
||||
"ARCHIVE_PLAYBACK": "Reprodução do arquivo",
|
||||
"RETURN_TO_LIVE": "Voltar ao vivo",
|
||||
"COLLAPSE_PANEL": "Collapse EPG panel",
|
||||
"EXPAND_PANEL": "Expand EPG panel",
|
||||
"TIMELINE": {
|
||||
@@ -679,7 +669,6 @@
|
||||
"MENU": {
|
||||
"OPEN": "Abrir menu",
|
||||
"SUPPORT_ARIA": "Apoie o IPTVnator no GitHub",
|
||||
"SETTINGS": "Configurações",
|
||||
"SETTINGS_ARIA": "Abrir configurações",
|
||||
"BUG_REPORT": "Reportar um bug",
|
||||
"WHAT_IS_NEW": "O que há de novo",
|
||||
@@ -804,10 +793,7 @@
|
||||
"SEARCH": "Pesquisar",
|
||||
"RECENT": "Recente",
|
||||
"RECENTLY_ADDED": "Adicionados recentemente",
|
||||
"FAVORITES": "Favoritos",
|
||||
"HOME": "Início",
|
||||
"PLAYLIST_INFO": "Informações da playlist",
|
||||
"ACCOUNT_INFO": "Informações da conta"
|
||||
"FAVORITES": "Favoritos"
|
||||
},
|
||||
"ERROR_VIEW": {
|
||||
"IMPORT_CANCELLED": {
|
||||
|
||||
@@ -192,10 +192,8 @@
|
||||
"VALIDATING": "Проверка портала…"
|
||||
},
|
||||
"FILTER_BY_NAME": "Фильтровать по имени",
|
||||
"FILTER_BY_TYPE": "Типы плейлистов",
|
||||
"FILTER_AND_SORT": "Фильтр и сортировка",
|
||||
"SORT_PLAYLISTS": "Сортировать плейлисты",
|
||||
"SORT_BY": "Сортировать по",
|
||||
"SORT_OPTIONS": {
|
||||
"NAME_ASC": "Имени (А-Я)",
|
||||
"NAME_DESC": "Имени (Я-А)",
|
||||
@@ -292,6 +290,10 @@
|
||||
"COVER_SIZE_SMALL": "Маленький",
|
||||
"COVER_SIZE_MEDIUM": "Средний",
|
||||
"COVER_SIZE_LARGE": "Большой",
|
||||
"EPG_VIEW_MODE": "Вид программы передач",
|
||||
"EPG_VIEW_MODE_DESCRIPTION": "Выберите, как отображается программа передач под плеером: горизонтальная лента-таймлайн или вертикальный список на один день.",
|
||||
"EPG_VIEW_MODE_TIMELINE": "Таймлайн",
|
||||
"EPG_VIEW_MODE_LIST": "Список",
|
||||
"STARTUP_BEHAVIOR": "Начальный экран",
|
||||
"STARTUP_BEHAVIOR_DESCRIPTION": "Выберите, должен ли IPTVnator открывать первый доступный экран рабочего пространства или восстанавливать последний экран уровня раздела. Первый экран означает Dashboard, если он включён, иначе Источники.",
|
||||
"STARTUP_BEHAVIOR_FIRST_VIEW": "Первый доступный экран",
|
||||
@@ -483,7 +485,6 @@
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "Переключить список каналов (⌘/Ctrl+B)"
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SEARCH_CHANNEL": "Поиск каналов",
|
||||
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Загрузить или выбрать другой плейлист",
|
||||
"ALL_CHANNELS": "Все каналы",
|
||||
"GROUPS": "Группы",
|
||||
@@ -557,14 +558,9 @@
|
||||
}
|
||||
},
|
||||
"TOP_MENU": {
|
||||
"OPEN_CHANNELS_LIST": "Открыть список каналов",
|
||||
"TOGGLE_FAVORITE_FLAG": "Избранный",
|
||||
"OPEN_EPG_LIST": "Открыть программу (EPG)",
|
||||
"OPEN_MULTI_EPG": "Открыть Multi-EPG вид",
|
||||
"SHOW_INFO": "Показать информацию о программе (нажмите I)"
|
||||
"OPEN_MULTI_EPG": "Открыть Multi-EPG вид"
|
||||
},
|
||||
"EPG": {
|
||||
"TITLE": "Электронный программный гид",
|
||||
"NEXT_DAY": "Следующий день",
|
||||
"PREVIOUS_DAY": "Предыдущий день",
|
||||
"ZOOM_IN": "Увеличить",
|
||||
@@ -573,14 +569,9 @@
|
||||
"CLOSE_FILTER": "Закрыть фильтр",
|
||||
"SEARCH_PROGRAMS": "Поиск программ",
|
||||
"CLOSE_PROGRAM_SEARCH": "Закрыть поиск программ",
|
||||
"LIVE_NOW": "Сейчас",
|
||||
"LIVE_STREAM": "Прямая трансляция",
|
||||
"TIMESHIFT_AVAILABLE": "Доступна функция архива (timeshift). Нажмите на передачу для воспроизведения.",
|
||||
"ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "Воспроизведение архива недоступно",
|
||||
"ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "Этот провайдер предоставляет историю программы для канала, но не предоставляет воспроизводимый архив/catch-up.",
|
||||
"EPG_NOT_AVAILABLE_DATE": "Ой, программа на этот день, к сожалению, отсутствует.",
|
||||
"NO_GUIDE_HINT": "Попробуйте другую дату или обновите плейлист.",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "Программа для этого канала недоступна.",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Попробуйте изменить или обновить ссылку на EPG в настройках приложения.",
|
||||
"FETCH_EPG": "Загружаю программу ТВ-передач (EPG)...",
|
||||
"ERROR": "Ой, не удалось загрузить программу каналов (EPG)",
|
||||
@@ -615,7 +606,6 @@
|
||||
"TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.",
|
||||
"CURRENT_PROGRAM": "Current program",
|
||||
"ARCHIVE_PLAYBACK": "Архивная запись",
|
||||
"RETURN_TO_LIVE": "Вернуться к эфиру",
|
||||
"COLLAPSE_PANEL": "Collapse EPG panel",
|
||||
"EXPAND_PANEL": "Expand EPG panel",
|
||||
"TIMELINE": {
|
||||
@@ -679,7 +669,6 @@
|
||||
"MENU": {
|
||||
"OPEN": "Открыть меню",
|
||||
"SUPPORT_ARIA": "Поддержать IPTVnator на GitHub",
|
||||
"SETTINGS": "Настройки",
|
||||
"SETTINGS_ARIA": "Открыть настройки",
|
||||
"BUG_REPORT": "Сообщить об ошибке",
|
||||
"WHAT_IS_NEW": "Что нового?",
|
||||
@@ -804,10 +793,7 @@
|
||||
"SEARCH": "Поиск",
|
||||
"RECENT": "Недавние",
|
||||
"RECENTLY_ADDED": "Недавно добавленные",
|
||||
"FAVORITES": "Избранное",
|
||||
"HOME": "Домой",
|
||||
"PLAYLIST_INFO": "Информация о плейлисте",
|
||||
"ACCOUNT_INFO": "Информация об аккаунте"
|
||||
"FAVORITES": "Избранное"
|
||||
},
|
||||
"ERROR_VIEW": {
|
||||
"IMPORT_CANCELLED": {
|
||||
|
||||
@@ -192,10 +192,8 @@
|
||||
"VALIDATING": "Portal doğrulanıyor..."
|
||||
},
|
||||
"FILTER_BY_NAME": "İsime göre filtrele",
|
||||
"FILTER_BY_TYPE": "Türe göre filtrele",
|
||||
"FILTER_AND_SORT": "Filtrele ve Sırala",
|
||||
"SORT_PLAYLISTS": "Oynatma listelerini sırala",
|
||||
"SORT_BY": "Sıralama ölçütü",
|
||||
"SORT_OPTIONS": {
|
||||
"NAME_ASC": "İsim (A-Z)",
|
||||
"NAME_DESC": "İsim (Z-A)",
|
||||
@@ -292,6 +290,10 @@
|
||||
"COVER_SIZE_SMALL": "Küçük",
|
||||
"COVER_SIZE_MEDIUM": "Orta",
|
||||
"COVER_SIZE_LARGE": "Büyük",
|
||||
"EPG_VIEW_MODE": "Rehber görünümü",
|
||||
"EPG_VIEW_MODE_DESCRIPTION": "Oynatıcının altındaki program rehberinin nasıl gösterileceğini seçin: yatay bir zaman çizelgesi şeridi veya dikey, tek günlük bir liste.",
|
||||
"EPG_VIEW_MODE_TIMELINE": "Zaman çizelgesi",
|
||||
"EPG_VIEW_MODE_LIST": "Liste",
|
||||
"STARTUP_BEHAVIOR": "Başlangıç görünümü",
|
||||
"STARTUP_BEHAVIOR_DESCRIPTION": "IPTVnator'un ilk kullanılabilir çalışma alanı görünümünü mü açacağını yoksa bölüm düzeyindeki son görünümünüzü mü geri yükleyeceğini seçin. İlk görünüm, Pano etkinleştirildiğinde Pano, aksi takdirde Kaynaklar anlamına gelir.",
|
||||
"STARTUP_BEHAVIOR_FIRST_VIEW": "İlk kullanılabilir görünüm",
|
||||
@@ -483,7 +485,6 @@
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "Kanal listesini aç/kapat (⌘/Ctrl+B)"
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SEARCH_CHANNEL": "Kanal ara",
|
||||
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Dosya yükle veya başka bir oynatma listesi seçin",
|
||||
"ALL_CHANNELS": "Tüm kanallar",
|
||||
"GROUPS": "Gruplar",
|
||||
@@ -557,14 +558,9 @@
|
||||
}
|
||||
},
|
||||
"TOP_MENU": {
|
||||
"OPEN_CHANNELS_LIST": "Kanal listesini aç",
|
||||
"TOGGLE_FAVORITE_FLAG": "Favori işaretini değiştir",
|
||||
"OPEN_EPG_LIST": "EPG listesini aç",
|
||||
"OPEN_MULTI_EPG": "Çoklu EPG görünümünü aç",
|
||||
"SHOW_INFO": "Program bilgisini göster (I tuşuna basın)"
|
||||
"OPEN_MULTI_EPG": "Çoklu EPG görünümünü aç"
|
||||
},
|
||||
"EPG": {
|
||||
"TITLE": "Elektronik Program Rehberi",
|
||||
"NEXT_DAY": "Sonraki gün",
|
||||
"PREVIOUS_DAY": "Önceki gün",
|
||||
"ZOOM_IN": "Yakınlaştır",
|
||||
@@ -573,14 +569,9 @@
|
||||
"CLOSE_FILTER": "Filtreyi kapat",
|
||||
"SEARCH_PROGRAMS": "Programları ara",
|
||||
"CLOSE_PROGRAM_SEARCH": "Program aramayı kapat",
|
||||
"LIVE_NOW": "Şu an canlı",
|
||||
"LIVE_STREAM": "Canlı yayın",
|
||||
"TIMESHIFT_AVAILABLE": "Zaman kaydırma mevcut, tıklayarak oynat",
|
||||
"ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "Arşiv oynatma kullanılamıyor",
|
||||
"ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "Bu sağlayıcı kanal için yayın akışı geçmişini sunar, ancak oynatılabilir arşiv/Catchup oynatma sunmaz.",
|
||||
"EPG_NOT_AVAILABLE_DATE": "Üzgünüz, seçilen tarih için EPG mevcut değil",
|
||||
"NO_GUIDE_HINT": "Başka bir tarih deneyin veya oynatma listesini yenileyin.",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "Üzgünüz, bu kanal için EPG mevcut değil.",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Lütfen uygulamanın ayarlarında EPG listesi URL'sini ekleyin veya değiştirin.",
|
||||
"FETCH_EPG": "EPG verileri alınıyor...",
|
||||
"ERROR": "Üzgünüz, EPG yüklenemedi.",
|
||||
@@ -615,7 +606,6 @@
|
||||
"TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.",
|
||||
"CURRENT_PROGRAM": "Current program",
|
||||
"ARCHIVE_PLAYBACK": "Arşiv oynatma",
|
||||
"RETURN_TO_LIVE": "Canlı yayına dön",
|
||||
"COLLAPSE_PANEL": "Collapse EPG panel",
|
||||
"EXPAND_PANEL": "Expand EPG panel",
|
||||
"TIMELINE": {
|
||||
@@ -679,7 +669,6 @@
|
||||
"MENU": {
|
||||
"OPEN": "Menüyü aç",
|
||||
"SUPPORT_ARIA": "IPTVnator'u GitHub'da destekleyin",
|
||||
"SETTINGS": "Ayarlar",
|
||||
"SETTINGS_ARIA": "Ayarları aç",
|
||||
"BUG_REPORT": "Hata bildir",
|
||||
"WHAT_IS_NEW": "Yeni ne var?",
|
||||
@@ -804,10 +793,7 @@
|
||||
"SEARCH": "Ara",
|
||||
"RECENT": "Son İzlenenler",
|
||||
"RECENTLY_ADDED": "Yeni Eklenenler",
|
||||
"FAVORITES": "Favoriler",
|
||||
"HOME": "Ana Sayfa",
|
||||
"PLAYLIST_INFO": "Oynatma Listesi Bilgisi",
|
||||
"ACCOUNT_INFO": "Hesap Bilgisi"
|
||||
"FAVORITES": "Favoriler"
|
||||
},
|
||||
"ERROR_VIEW": {
|
||||
"IMPORT_CANCELLED": {
|
||||
|
||||
@@ -192,10 +192,8 @@
|
||||
"VALIDATING": "正在验证门户…"
|
||||
},
|
||||
"FILTER_BY_NAME": "按名称筛选",
|
||||
"FILTER_BY_TYPE": "播放列表类型",
|
||||
"FILTER_AND_SORT": "筛选与排序",
|
||||
"SORT_PLAYLISTS": "排序播放列表",
|
||||
"SORT_BY": "排序方式",
|
||||
"SORT_OPTIONS": {
|
||||
"NAME_ASC": "名称(A-Z)",
|
||||
"NAME_DESC": "名称(Z-A)",
|
||||
@@ -292,6 +290,10 @@
|
||||
"COVER_SIZE_SMALL": "小",
|
||||
"COVER_SIZE_MEDIUM": "中",
|
||||
"COVER_SIZE_LARGE": "大",
|
||||
"EPG_VIEW_MODE": "节目指南视图",
|
||||
"EPG_VIEW_MODE_DESCRIPTION": "选择播放器下方的节目指南如何显示:横向时间轴条,或纵向的单日列表。",
|
||||
"EPG_VIEW_MODE_TIMELINE": "时间轴",
|
||||
"EPG_VIEW_MODE_LIST": "列表",
|
||||
"STARTUP_BEHAVIOR": "启动视图",
|
||||
"STARTUP_BEHAVIOR_DESCRIPTION": "选择 IPTVnator 启动时是打开第一个可用的工作区视图,还是恢复您上次的章节级视图。第一个视图在启用时为仪表盘,否则为「源」。",
|
||||
"STARTUP_BEHAVIOR_FIRST_VIEW": "首个可用视图",
|
||||
@@ -483,7 +485,6 @@
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "切换频道列表 (⌘/Ctrl+B)"
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SEARCH_CHANNEL": "搜索频道",
|
||||
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "上传或选择其他播放列表",
|
||||
"ALL_CHANNELS": "所有频道",
|
||||
"GROUPS": "分组",
|
||||
@@ -557,14 +558,9 @@
|
||||
}
|
||||
},
|
||||
"TOP_MENU": {
|
||||
"OPEN_CHANNELS_LIST": "打开频道列表",
|
||||
"TOGGLE_FAVORITE_FLAG": "切换收藏夹标志",
|
||||
"OPEN_EPG_LIST": "打开电子节目单EPG列表",
|
||||
"OPEN_MULTI_EPG": "打开多EPG显示",
|
||||
"SHOW_INFO": "显示节目信息(按 I 键)"
|
||||
"OPEN_MULTI_EPG": "打开多EPG显示"
|
||||
},
|
||||
"EPG": {
|
||||
"TITLE": "电子节目指南",
|
||||
"NEXT_DAY": "明天",
|
||||
"PREVIOUS_DAY": "昨天",
|
||||
"ZOOM_IN": "放大",
|
||||
@@ -573,14 +569,9 @@
|
||||
"CLOSE_FILTER": "关闭筛选",
|
||||
"SEARCH_PROGRAMS": "搜索节目",
|
||||
"CLOSE_PROGRAM_SEARCH": "关闭节目搜索",
|
||||
"LIVE_NOW": "正在播放",
|
||||
"LIVE_STREAM": "正在直播",
|
||||
"TIMESHIFT_AVAILABLE": "时移功能可用,点击播放",
|
||||
"ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "归档播放不可用",
|
||||
"ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "此提供商提供了该频道的节目历史,但不提供可播放的归档/Catchup 回看。",
|
||||
"EPG_NOT_AVAILABLE_DATE": "错误:电子节目单EPG在所选日期不可用",
|
||||
"NO_GUIDE_HINT": "尝试其他日期或刷新播放列表。",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "错误:电子节目单EPG不适用于此频道",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "请添加/更改设置项中电子节目单的URL",
|
||||
"FETCH_EPG": "获取 EPG 数据...",
|
||||
"ERROR": "错误: 获取 EPG 数据失败",
|
||||
@@ -615,7 +606,6 @@
|
||||
"TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.",
|
||||
"CURRENT_PROGRAM": "Current program",
|
||||
"ARCHIVE_PLAYBACK": "回看播放",
|
||||
"RETURN_TO_LIVE": "返回直播",
|
||||
"COLLAPSE_PANEL": "Collapse EPG panel",
|
||||
"EXPAND_PANEL": "Expand EPG panel",
|
||||
"TIMELINE": {
|
||||
@@ -679,7 +669,6 @@
|
||||
"MENU": {
|
||||
"OPEN": "打开菜单",
|
||||
"SUPPORT_ARIA": "在 GitHub 上支持 IPTVnator",
|
||||
"SETTINGS": "设置",
|
||||
"SETTINGS_ARIA": "打开设置",
|
||||
"BUG_REPORT": "问题反馈",
|
||||
"WHAT_IS_NEW": "更新日志",
|
||||
@@ -804,10 +793,7 @@
|
||||
"SEARCH": "搜索",
|
||||
"RECENT": "最近",
|
||||
"RECENTLY_ADDED": "最近添加",
|
||||
"FAVORITES": "收藏",
|
||||
"HOME": "首页",
|
||||
"PLAYLIST_INFO": "播放列表信息",
|
||||
"ACCOUNT_INFO": "账户信息"
|
||||
"FAVORITES": "收藏"
|
||||
},
|
||||
"ERROR_VIEW": {
|
||||
"IMPORT_CANCELLED": {
|
||||
|
||||
@@ -192,10 +192,8 @@
|
||||
"VALIDATING": "正在驗證入口網站..."
|
||||
},
|
||||
"FILTER_BY_NAME": "依名稱篩選",
|
||||
"FILTER_BY_TYPE": "播放清單類型",
|
||||
"FILTER_AND_SORT": "篩選與排序",
|
||||
"SORT_PLAYLISTS": "排序播放清單",
|
||||
"SORT_BY": "排序依據",
|
||||
"SORT_OPTIONS": {
|
||||
"NAME_ASC": "名稱(A-Z)",
|
||||
"NAME_DESC": "名稱(Z-A)",
|
||||
@@ -292,6 +290,10 @@
|
||||
"COVER_SIZE_SMALL": "小",
|
||||
"COVER_SIZE_MEDIUM": "中",
|
||||
"COVER_SIZE_LARGE": "大",
|
||||
"EPG_VIEW_MODE": "節目表檢視方式",
|
||||
"EPG_VIEW_MODE_DESCRIPTION": "選擇播放器下方節目表的顯示方式:橫向時間軸條,或縱向的單日清單。",
|
||||
"EPG_VIEW_MODE_TIMELINE": "時間軸",
|
||||
"EPG_VIEW_MODE_LIST": "清單",
|
||||
"STARTUP_BEHAVIOR": "啟動畫面",
|
||||
"STARTUP_BEHAVIOR_DESCRIPTION": "選擇 IPTVnator 啟動時要開啟第一個可用的工作區畫面,或還原您上次瀏覽的區段層級畫面。第一個畫面在啟用儀表板時為儀表板,否則為「來源」。",
|
||||
"STARTUP_BEHAVIOR_FIRST_VIEW": "第一個可用畫面",
|
||||
@@ -483,7 +485,6 @@
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "切換頻道列表 (⌘/Ctrl+B)"
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SEARCH_CHANNEL": "搜尋頻道",
|
||||
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "上傳或選擇其他播放清單",
|
||||
"ALL_CHANNELS": "所有頻道",
|
||||
"GROUPS": "群組",
|
||||
@@ -557,14 +558,9 @@
|
||||
}
|
||||
},
|
||||
"TOP_MENU": {
|
||||
"OPEN_CHANNELS_LIST": "打開頻道列表",
|
||||
"TOGGLE_FAVORITE_FLAG": "切換我的最愛標記",
|
||||
"OPEN_EPG_LIST": "開啟 EPG 列表",
|
||||
"OPEN_MULTI_EPG": "開啟多 EPG 視圖",
|
||||
"SHOW_INFO": "顯示節目資訊(按 I 鍵)"
|
||||
"OPEN_MULTI_EPG": "開啟多 EPG 視圖"
|
||||
},
|
||||
"EPG": {
|
||||
"TITLE": "電子節目指南",
|
||||
"NEXT_DAY": "下一天",
|
||||
"PREVIOUS_DAY": "上一天",
|
||||
"ZOOM_IN": "放大",
|
||||
@@ -573,14 +569,9 @@
|
||||
"CLOSE_FILTER": "關閉篩選",
|
||||
"SEARCH_PROGRAMS": "搜尋節目",
|
||||
"CLOSE_PROGRAM_SEARCH": "關閉節目搜尋",
|
||||
"LIVE_NOW": "現在播放",
|
||||
"LIVE_STREAM": "現場直播",
|
||||
"TIMESHIFT_AVAILABLE": "時間平移功能可用,點選以播放",
|
||||
"ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "無法播放存檔內容",
|
||||
"ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "此供應商有提供本頻道的歷史節目表,但未提供可播放的存檔/Catchup 播放功能。",
|
||||
"EPG_NOT_AVAILABLE_DATE": "抱歉,選擇的日期上 EPG 無法使用",
|
||||
"NO_GUIDE_HINT": "嘗試其他日期或重新整理播放清單。",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "抱歉,此頻道上的 EPG 無法使用。",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "請在應用程式設定中增加/更改 EPG 列表的URL。",
|
||||
"FETCH_EPG": "取得 EPG 資料...",
|
||||
"ERROR": "抱歉,無法載入 EPG。",
|
||||
@@ -615,7 +606,6 @@
|
||||
"TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.",
|
||||
"CURRENT_PROGRAM": "Current program",
|
||||
"ARCHIVE_PLAYBACK": "回看播放",
|
||||
"RETURN_TO_LIVE": "返回直播",
|
||||
"COLLAPSE_PANEL": "Collapse EPG panel",
|
||||
"EXPAND_PANEL": "Expand EPG panel",
|
||||
"TIMELINE": {
|
||||
@@ -679,7 +669,6 @@
|
||||
"MENU": {
|
||||
"OPEN": "開啟選單",
|
||||
"SUPPORT_ARIA": "在 GitHub 上支持 IPTVnator",
|
||||
"SETTINGS": "設定",
|
||||
"SETTINGS_ARIA": "開啟設定",
|
||||
"BUG_REPORT": "回報問題",
|
||||
"WHAT_IS_NEW": "更新內容",
|
||||
@@ -804,10 +793,7 @@
|
||||
"SEARCH": "搜尋",
|
||||
"RECENT": "最近觀看",
|
||||
"RECENTLY_ADDED": "最近新增",
|
||||
"FAVORITES": "我的最愛",
|
||||
"HOME": "首頁",
|
||||
"PLAYLIST_INFO": "播放清單資訊",
|
||||
"ACCOUNT_INFO": "帳戶資訊"
|
||||
"FAVORITES": "我的最愛"
|
||||
},
|
||||
"ERROR_VIEW": {
|
||||
"IMPORT_CANCELLED": {
|
||||
|
||||
@@ -96,10 +96,6 @@ app-root.macos-platform .current-playlist {
|
||||
padding-top: 30px;
|
||||
}
|
||||
|
||||
app-root.macos-platform app-navigation .portal-status-container {
|
||||
padding-top: 30px;
|
||||
}
|
||||
|
||||
// Windows/Linux frameless title bar: reserve room in top-aligned drag
|
||||
// regions for the renderer-drawn window controls (3 × 46px buttons).
|
||||
// Body-level class so it also reaches cdk-overlay content (multi-EPG).
|
||||
|
||||
@@ -28,10 +28,10 @@ Use it when changing existing views or introducing new list-based UI in the work
|
||||
`libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html`
|
||||
- Channel row styles:
|
||||
`libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.scss`
|
||||
- Shared EPG pane:
|
||||
`libs/ui/shared-portals/src/lib/epg-view/epg-view.component.html`
|
||||
- Shared EPG pane styles:
|
||||
`libs/ui/shared-portals/src/lib/epg-view/epg-view.component.scss`
|
||||
- Shared EPG timeline:
|
||||
`libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.html`
|
||||
- Shared EPG timeline styles:
|
||||
`libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.scss`
|
||||
- Shared list selection style:
|
||||
`apps/web/src/nav-list.scss`
|
||||
- Theme tokens:
|
||||
@@ -141,12 +141,11 @@ The shared row should be reused instead of rebuilding channel markup per view.
|
||||
|
||||
### Collapsible Live EPG
|
||||
|
||||
- Live TV layouts with an internal player use `app-live-epg-panel` around the
|
||||
- Live TV layouts with an internal player render `app-epg-timeline` as the
|
||||
EPG content, including playlist-specific live pages and the global
|
||||
favorites/recent live tabs.
|
||||
- The live panel toolbar owns the current-program summary and live date
|
||||
navigation together, so `app-epg-list` hides its internal date navigator when
|
||||
projected inside that panel.
|
||||
- The timeline's own panel bar owns the current-program summary and live date
|
||||
navigation together; there is no separate wrapper component around it.
|
||||
- Collapsed state is shared across M3U, Xtream, and Stalker with
|
||||
`live-epg-panel-state`; missing or invalid values restore to expanded.
|
||||
- The collapsed panel is a slim current-program strip with a trailing progress
|
||||
@@ -289,7 +288,7 @@ Settings use the same system but are flatter than content-heavy views.
|
||||
Before creating new markup or CSS:
|
||||
|
||||
1. Check whether `app-channel-list-item` can be reused.
|
||||
2. Check whether `app-epg-view` already provides the correct structure.
|
||||
2. Check whether `app-epg-timeline` already provides the correct structure.
|
||||
3. Check whether `nav-list.scss` already solves the list-selection problem.
|
||||
4. Extend tokens first, duplicate styles last.
|
||||
|
||||
|
||||
@@ -266,16 +266,33 @@ per-tab EPG logic:
|
||||
favorites). Callers read their `progressTick()` signal first so the computed
|
||||
re-runs on the ~30s tick.
|
||||
|
||||
### EPG Timeline Panel
|
||||
### EPG Panel (Timeline & List views)
|
||||
|
||||
The programme guide under the player is a horizontal **timeline ribbon**
|
||||
(`app-epg-timeline`, `libs/ui/epg/src/lib/epg-timeline/`) shared by all four live
|
||||
surfaces: the M3U video player, the unified live tab, and the Xtream and Stalker
|
||||
live-stream layouts. It replaces the former vertical `app-epg-list` /
|
||||
`app-epg-view`.
|
||||
The programme guide under the player renders in one of **two interchangeable
|
||||
views**, chosen by the **`epgViewMode`** setting (`'timeline'` default, or
|
||||
`'list'`; Settings → EPG → *Guide view*):
|
||||
|
||||
The component stays presentation-focused; the reusable, view-agnostic pieces
|
||||
(so a future list view can share them) are split out and re-exported from
|
||||
- **Timeline** — a horizontal **ribbon** (`app-epg-timeline`,
|
||||
`libs/ui/epg/src/lib/epg-timeline/`).
|
||||
- **List** — a vertical, single-day **programme list** (`app-epg-list-view`,
|
||||
`libs/ui/epg/src/lib/epg-list-view/`) with a prev/today/next stepper.
|
||||
|
||||
Both are shared by all four live surfaces: the M3U video player, the unified live
|
||||
tab, and the Xtream and Stalker live-stream layouts (replacing the former
|
||||
vertical `app-epg-list` / `app-epg-view`). `EpgListViewComponent` mirrors
|
||||
`EpgTimelineComponent`'s input/output contract **1:1**, so each host swaps them
|
||||
with a plain `@if (epgViewMode() === 'list') { <app-epg-list-view … /> } @else {
|
||||
<app-epg-timeline … /> }` — identical bindings in both branches. Hosts read
|
||||
`epgViewMode` from `SettingsStore` (a signal), so flipping the setting swaps the
|
||||
panel live. The setting flows end-to-end (`Settings.epgViewMode` →
|
||||
`DEFAULT_SETTINGS` → `SettingsStore`/`StorageMap` → the segmented control in
|
||||
`settings-epg-section`) and needs no backend change. The control is
|
||||
**Electron-only in practice** — the EPG settings section (and the form control)
|
||||
is gated behind `supportsEpg`, which is false in PWA; there the stored value
|
||||
simply stays at the `'timeline'` default.
|
||||
|
||||
Both components stay presentation-focused; the reusable, view-agnostic pieces
|
||||
(shared by the timeline and the list) are split out and re-exported from
|
||||
`@iptvnator/ui/epg`:
|
||||
|
||||
- `epg-timeline.utils.ts` (axis/blocks/date helpers) + `epg-timeline-render.util.ts`
|
||||
@@ -290,6 +307,19 @@ The component stays presentation-focused; the reusable, view-agnostic pieces
|
||||
scrolling + channel-select auto-focus); timeline-specific, kept out of the
|
||||
component so it stays under the line ceiling.
|
||||
|
||||
The **list view** (`epg-list-view/`) composes those same shared modules — it does
|
||||
**not** duplicate classification or gating logic. It reuses `classifyTimelineWhen`
|
||||
/ `hasProgramsForDateKey` / `nearestDateKeyWithPrograms`, `epg-archive.util`,
|
||||
`epg-summary.util`, the `epg-date` helpers, `EpgProgrammeDialogService`, and the
|
||||
shared `app-epg-timeline-empty-state` — and drops all ribbon geometry, zoom, and
|
||||
horizontal scroll. It filters the loaded window to the selected day (overlap-based,
|
||||
matching `hasProgramsForDateKey`), sorts, and deduplicates via a pure
|
||||
`buildEpgListRows` (`epg-list-view.utils.ts`); renders each row through the dumb
|
||||
`app-epg-list-view-row`; and delegates its own vertical auto-focus + sticky
|
||||
"now" strip to `EpgListScrollController` (`epg-list-scroll.controller.ts`). Render
|
||||
states, the collapsed inline summary, the date stepper, catch-up/timeshift
|
||||
activation, and the details dialog behave identically to the timeline.
|
||||
|
||||
- **One channel, preloaded window.** The panel always shows a single channel.
|
||||
Each provider returns a multi-day window in roughly one call (M3U
|
||||
`GET_CHANNEL_PROGRAMS`; Stalker `get_epg_info`; Xtream `get_simple_data_table`),
|
||||
@@ -370,7 +400,11 @@ The component stays presentation-focused; the reusable, view-agnostic pieces
|
||||
tag / "Watch") stays pinned at the bottom. With an inline player the guide is
|
||||
a compact panel (`.epg.epg--inline` → `flex: 0 0 clamp(180px, 36vh, 264px)`
|
||||
in `_portal-layout.scss`) so the player stays dominant; with an external
|
||||
player the guide keeps `flex: 1` and fills the whole content area.
|
||||
player the guide keeps `flex: 1` and fills the whole content area. In **list
|
||||
mode** the hosts also set `.epg--list`, which raises only the inline clamp
|
||||
(`--epg-inline-height: clamp(280px, 46vh, 430px)`) — vertical rows need more
|
||||
height than the ribbon; the timeline height is unchanged, and the collapsed
|
||||
56px clamp still wins because the modifier sets just the CSS variable.
|
||||
- **Wide-tier description preview.** When a block is the `wide` tier (rendered
|
||||
width ≥ `132px`, i.e. long programmes and/or zoomed in) **and** the programme
|
||||
has a `desc`, a dimmed (`--text-secondary`) preview of the description renders
|
||||
@@ -564,8 +598,8 @@ class EpgService {
|
||||
|
||||
| Component | Purpose |
|
||||
| ----------------------------- | ------------------------------------ |
|
||||
| `EpgListComponent` | Timeline view for single channel |
|
||||
| `EpgListItemComponent` | Individual program in timeline |
|
||||
| `EpgTimelineComponent` | Horizontal timeline for one channel |
|
||||
| `EpgListViewComponent` | Vertical single-day list alternative |
|
||||
| `EpgItemDescriptionComponent` | Program details dialog |
|
||||
| `MultiEpgContainerComponent` | Grid view of all channels' schedules |
|
||||
|
||||
|
||||
@@ -82,3 +82,16 @@ playback, or EPG data belongs in `libs/portal/shared/data-access`, not
|
||||
`libs/portal/shared/util`. That keeps pure collection helpers importable by
|
||||
Xtream/Stalker data-access libraries while allowing shared UI to use
|
||||
provider-specific collection services without creating cycles.
|
||||
|
||||
Note: `workspace-shell-util` (`libs/workspace/shell/util`) is tagged
|
||||
`type:data-access` despite its path. It exports injectable services such as
|
||||
`WorkspaceStartupPreferencesService` that depend on `@iptvnator/services`, and
|
||||
it must stay eagerly importable from `apps/web/src/app/app.routes.ts` without
|
||||
pulling the lazy-loaded workspace shell feature bundle into the initial chunk.
|
||||
|
||||
## CI Enforcement
|
||||
|
||||
The `Lint` job in `.github/workflows/ci.yml` runs
|
||||
`pnpm nx run-many --target=lint --all` on every PR, so
|
||||
`@nx/enforce-module-boundaries` violations, legacy bare-alias imports, and
|
||||
`max-lines` violations fail CI. Run `pnpm run lint` locally before pushing.
|
||||
@@ -14,7 +14,7 @@ Stalker now uses two EPG paths with different purposes:
|
||||
|
||||
- The active channel EPG panel uses `get_epg_info` as a bulk endpoint, fetches a
|
||||
7-day window once per playlist session, caches programs by channel id, and
|
||||
renders the selected channel through the shared `app-epg-list` component.
|
||||
renders the selected channel through the shared `app-epg-timeline` component.
|
||||
- Channel rows no longer send preview EPG requests during initial category load.
|
||||
They stay empty until bulk EPG has been fetched once, then derive their
|
||||
current program and progress bar from the cached bulk map.
|
||||
@@ -39,7 +39,7 @@ date-navigator UI used in the M3U/Xtream flows.
|
||||
│ │ ensureBulkItvEpg(168) │
|
||||
│ │ selectedItvEpgPrograms() │
|
||||
│ ▼ │ │
|
||||
│ current program preview ├── bulk hit → app-epg-list │
|
||||
│ current program preview ├── bulk hit → app-epg-timeline │
|
||||
│ after first bulk load └── empty/unsupported → short fallback │
|
||||
└────────────────────────────────────────────────────────────────────────────┘
|
||||
│ │
|
||||
@@ -166,7 +166,7 @@ Key mapped fields:
|
||||
|
||||
### Active panel data (`get_epg_info` / fallback) → `EpgProgram`
|
||||
|
||||
The active panel uses controlled `EpgProgram[]` because `app-epg-list` filters
|
||||
The active panel uses controlled `EpgProgram[]` because `app-epg-timeline` filters
|
||||
and groups by day.
|
||||
|
||||
Normalization rules:
|
||||
@@ -183,10 +183,9 @@ Normalization rules:
|
||||
| File | Purpose |
|
||||
| -------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- |
|
||||
| `libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.ts` | bulk cache and fallback handling |
|
||||
| `libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts` | active-channel EPG loading and controlled `app-epg-list` wiring |
|
||||
| `libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts` | active-channel EPG loading and controlled `app-epg-timeline` wiring |
|
||||
| `libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html` | active panel template |
|
||||
| `libs/portal/stalker/feature/src/lib/stalker-collection-channels-list/stalker-collection-channels-list.component.ts` | row preview loading |
|
||||
| `libs/ui/shared-portals/src/lib/epg-list/epg-list.component.ts` | shared controlled EPG list with date navigator |
|
||||
| `libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.ts` | shared controlled EPG timeline with date navigator |
|
||||
|
||||
### Store API
|
||||
|
||||
@@ -210,7 +209,7 @@ playlists.
|
||||
1. User activates a live channel
|
||||
2. The component ensures playback link resolution as before
|
||||
3. The component calls `ensureBulkItvEpg(168)` on first use for the playlist
|
||||
4. `selectedItvEpgPrograms()` feeds `app-epg-list`
|
||||
4. `selectedItvEpgPrograms()` feeds `app-epg-timeline`
|
||||
5. If the selected channel has no bulk programs, the component falls back to
|
||||
`get_short_epg`
|
||||
|
||||
@@ -267,5 +266,5 @@ is available.
|
||||
## Future Enhancements
|
||||
|
||||
- add cache refresh / invalidation for long-running live sessions
|
||||
- add Stalker catch-up support to `app-epg-list` once the playback flow exists
|
||||
- add Stalker catch-up support to `app-epg-timeline` once the playback flow exists
|
||||
- optionally add category-level prefetch timing metrics for bulk EPG
|
||||
@@ -17,16 +17,38 @@ pnpm nx show projects --withTarget e2e
|
||||
| ---------------------------------- | ----------------------------------- |
|
||||
| Angular renderer entry points | `pnpm run typecheck:web` |
|
||||
| Electron main process entry points | `pnpm run typecheck:backend` |
|
||||
| Curated CI unit suite | `pnpm run test:unit:ci` |
|
||||
| Full unit suite (all projects) | `pnpm run test:unit:ci` |
|
||||
| EPG data access | `pnpm nx test epg-data-access` |
|
||||
| Workspace shell utilities | `pnpm nx test workspace-shell-util` |
|
||||
| Shared SQLite schema/connection | `pnpm nx test database` |
|
||||
| Packaging metadata | `pnpm nx test packaging` |
|
||||
|
||||
## Lint
|
||||
|
||||
```bash
|
||||
pnpm run lint # nx run-many --target=lint --all
|
||||
pnpm nx lint <project> # single project
|
||||
```
|
||||
|
||||
The CI workflow (`.github/workflows/ci.yml`) runs lint for every project on
|
||||
each PR. This enforces `@nx/enforce-module-boundaries` (scope/domain/type tag
|
||||
constraints), the legacy bare-alias ban, and the `max-lines` file-size rule
|
||||
(hard maximum 400 lines per TypeScript file). Files that predate the
|
||||
`max-lines` rule are baselined in `tools/eslint/max-lines-baseline.mjs`; after
|
||||
splitting a baselined file below the limit, regenerate the list with
|
||||
`node tools/eslint/generate-max-lines-baseline.mjs`. Never add new files to
|
||||
the baseline.
|
||||
|
||||
## Coverage Tiers
|
||||
|
||||
Use `tools/coverage/coverage-policy.json` as the source of truth for coverage
|
||||
ownership.
|
||||
ownership. Every project with a `test` target must be classified in a tier;
|
||||
`pnpm run coverage:policy:check` (part of `coverage:ci`) fails CI when a new
|
||||
project is missing from the policy, a listed project no longer exists, or a
|
||||
Tier A entry has no test target. CI runs Tier A with coverage (uploaded to
|
||||
Codecov) and each Tier B/C project's `validationCommand` (falling back to
|
||||
`nx test`) without coverage; projects with an `e2e` target are skipped there
|
||||
because the E2E workflow already runs them on every PR.
|
||||
|
||||
| Tier | Rule | Validation |
|
||||
| --- | --- | --- |
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import nx from '@nx/eslint-plugin';
|
||||
import { maxLinesBaseline } from './tools/eslint/max-lines-baseline.mjs';
|
||||
|
||||
const legacyBareAliases = [
|
||||
'components',
|
||||
@@ -246,6 +247,23 @@ export default [
|
||||
'@angular-eslint/template/interactive-supports-focus': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
// CLAUDE.md file-size rule: keep TypeScript files under 300 lines,
|
||||
// hard maximum 400. Files that predate the rule are baselined below.
|
||||
files: ['**/*.ts', '**/*.tsx'],
|
||||
rules: {
|
||||
'max-lines': [
|
||||
'error',
|
||||
{ max: 400, skipBlankLines: false, skipComments: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: maxLinesBaseline,
|
||||
rules: {
|
||||
'max-lines': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: [
|
||||
'apps/web/src/app/app.component.ts',
|
||||
|
||||
@@ -94,32 +94,65 @@
|
||||
<div
|
||||
class="epg"
|
||||
[class.epg--inline]="canCollapseEpgPanel"
|
||||
[class.epg--list]="epgViewMode() === 'list'"
|
||||
[class.epg-collapsed]="
|
||||
canCollapseEpgPanel && isLiveEpgPanelCollapsed()
|
||||
"
|
||||
>
|
||||
<div class="epg-content">
|
||||
<app-epg-timeline
|
||||
[programs]="epgPrograms()"
|
||||
[channelName]="timelineChannelName()"
|
||||
[channelLogo]="timelineChannelLogo()"
|
||||
[archivePlaybackAvailable]="archivePlaybackAvailable()"
|
||||
[archiveDays]="epgArchiveDays()"
|
||||
[activeProgram]="activeEpgProgramOrNull()"
|
||||
[isLivePlayback]="!showReturnToLive()"
|
||||
[selectedDate]="selectedLiveEpgDate()"
|
||||
[collapsed]="
|
||||
canCollapseEpgPanel && isLiveEpgPanelCollapsed()
|
||||
"
|
||||
[summary]="liveEpgPanelSummary()"
|
||||
[summaryLabelKey]="liveEpgPanelSummaryLabelKey()"
|
||||
(programActivated)="onTimelineProgramActivated($event)"
|
||||
(returnToLive)="returnToLivePlayback()"
|
||||
(selectedDateChange)="
|
||||
onLiveEpgSelectedDateChange($event)
|
||||
"
|
||||
(collapsedChange)="onLiveEpgPanelCollapsedChange($event)"
|
||||
/>
|
||||
@if (epgViewMode() === 'list') {
|
||||
<app-epg-list-view
|
||||
[programs]="epgPrograms()"
|
||||
[channelName]="timelineChannelName()"
|
||||
[channelLogo]="timelineChannelLogo()"
|
||||
[archivePlaybackAvailable]="archivePlaybackAvailable()"
|
||||
[archiveDays]="epgArchiveDays()"
|
||||
[activeProgram]="activeEpgProgramOrNull()"
|
||||
[isLivePlayback]="!showReturnToLive()"
|
||||
[selectedDate]="selectedLiveEpgDate()"
|
||||
[collapsed]="
|
||||
canCollapseEpgPanel && isLiveEpgPanelCollapsed()
|
||||
"
|
||||
[summary]="liveEpgPanelSummary()"
|
||||
[summaryLabelKey]="liveEpgPanelSummaryLabelKey()"
|
||||
(programActivated)="
|
||||
onTimelineProgramActivated($event)
|
||||
"
|
||||
(returnToLive)="returnToLivePlayback()"
|
||||
(selectedDateChange)="
|
||||
onLiveEpgSelectedDateChange($event)
|
||||
"
|
||||
(collapsedChange)="
|
||||
onLiveEpgPanelCollapsedChange($event)
|
||||
"
|
||||
/>
|
||||
} @else {
|
||||
<app-epg-timeline
|
||||
[programs]="epgPrograms()"
|
||||
[channelName]="timelineChannelName()"
|
||||
[channelLogo]="timelineChannelLogo()"
|
||||
[archivePlaybackAvailable]="archivePlaybackAvailable()"
|
||||
[archiveDays]="epgArchiveDays()"
|
||||
[activeProgram]="activeEpgProgramOrNull()"
|
||||
[isLivePlayback]="!showReturnToLive()"
|
||||
[selectedDate]="selectedLiveEpgDate()"
|
||||
[collapsed]="
|
||||
canCollapseEpgPanel && isLiveEpgPanelCollapsed()
|
||||
"
|
||||
[summary]="liveEpgPanelSummary()"
|
||||
[summaryLabelKey]="liveEpgPanelSummaryLabelKey()"
|
||||
(programActivated)="
|
||||
onTimelineProgramActivated($event)
|
||||
"
|
||||
(returnToLive)="returnToLivePlayback()"
|
||||
(selectedDateChange)="
|
||||
onLiveEpgSelectedDateChange($event)
|
||||
"
|
||||
(collapsedChange)="
|
||||
onLiveEpgPanelCollapsedChange($event)
|
||||
"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
+28
-1
@@ -121,8 +121,10 @@ class StubWebPlayerViewComponent {
|
||||
readonly externalFallbackRequested = output<PlaybackFallbackRequest>();
|
||||
}
|
||||
|
||||
// Matches both live-panel selectors so the host's timeline ↔ list swap can be
|
||||
// asserted by tag name; both branches share the identical contract.
|
||||
@Component({
|
||||
selector: 'app-epg-timeline',
|
||||
selector: 'app-epg-timeline, app-epg-list-view',
|
||||
standalone: true,
|
||||
template: '',
|
||||
})
|
||||
@@ -189,6 +191,7 @@ describe('VideoPlayerComponent', () => {
|
||||
|
||||
const player = signal<VideoPlayer>(VideoPlayer.VideoJs);
|
||||
const showCaptions = signal(false);
|
||||
const epgViewMode = signal<'timeline' | 'list'>('timeline');
|
||||
const originalElectron = window.electron;
|
||||
|
||||
const overlayRef = {
|
||||
@@ -386,6 +389,7 @@ describe('VideoPlayerComponent', () => {
|
||||
useValue: {
|
||||
player,
|
||||
showCaptions,
|
||||
resolvedEpgViewMode: epgViewMode,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -469,6 +473,29 @@ describe('VideoPlayerComponent', () => {
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('swaps the timeline for the list view when epgViewMode is "list"', () => {
|
||||
syncStoreState(sampleChannel);
|
||||
player.set(VideoPlayer.VideoJs);
|
||||
epgViewMode.set('list');
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('app-epg-list-view')
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('app-epg-timeline')
|
||||
).toBeNull();
|
||||
// Taller inline panel for the list view (see _portal-layout.scss).
|
||||
expect(
|
||||
fixture.nativeElement
|
||||
.querySelector('.epg')
|
||||
?.classList.contains('epg--list')
|
||||
).toBe(true);
|
||||
|
||||
epgViewMode.set('timeline'); // restore for sibling tests
|
||||
});
|
||||
|
||||
it('hides EPG controls and the multi-EPG header action in browser/PWA playback', () => {
|
||||
fixture.destroy();
|
||||
window.electron = undefined as unknown as typeof window.electron;
|
||||
|
||||
@@ -29,6 +29,7 @@ import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
|
||||
import {
|
||||
COMPONENT_OVERLAY_REF,
|
||||
EpgDateNavigationDirection,
|
||||
EpgListViewComponent,
|
||||
EpgProgramActivationEvent,
|
||||
EpgTimelineComponent,
|
||||
getTodayEpgDateKey,
|
||||
@@ -122,6 +123,7 @@ const M3U_SIDEBAR_DEFAULT_WIDTH = 460;
|
||||
AudioPlayerComponent,
|
||||
ChannelListLoadingStateComponent,
|
||||
CommonModule,
|
||||
EpgListViewComponent,
|
||||
EpgTimelineComponent,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
@@ -273,6 +275,8 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
|
||||
restoreLiveEpgPanelState()
|
||||
);
|
||||
readonly selectedLiveEpgDate = signal(getTodayEpgDateKey());
|
||||
/** Live EPG panel layout chosen in settings; hosts swap timeline ↔ list. */
|
||||
readonly epgViewMode = this.settingsStore.resolvedEpgViewMode;
|
||||
readonly isLiveEpgPanelCollapsed = computed(
|
||||
() => this.liveEpgPanelState() === 'collapsed'
|
||||
);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export * from './lib/add-playlist-menu/playlist-type';
|
||||
export * from './lib/filter-sort-menu/filter-sort-menu.component';
|
||||
export * from './lib/playlist-switcher/playlist-switcher.component';
|
||||
export * from './lib/recent-playlists/playlist-info/playlist-info.component';
|
||||
export * from './lib/recent-playlists/recent-playlists.component';
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
<mat-menu #filterSortMenu="matMenu" class="filter-sort-menu" xPosition="before">
|
||||
<section class="filter-section">
|
||||
<div class="menu-section-header">
|
||||
<mat-icon class="section-icon">filter_list</mat-icon>
|
||||
<span>{{ 'HOME.FILTER_BY_TYPE' | translate }}</span>
|
||||
</div>
|
||||
@for (type of playlistTypes; track type.id) {
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="togglePlaylistType(type); $event.stopPropagation()"
|
||||
[class.active-option]="type.checked"
|
||||
>
|
||||
<mat-icon>{{ type.icon }}</mat-icon>
|
||||
<span class="menu-item-text">{{
|
||||
type.translationKey | translate
|
||||
}}</span>
|
||||
<mat-icon
|
||||
class="check-icon"
|
||||
[class.check-visible]="type.checked"
|
||||
>check</mat-icon
|
||||
>
|
||||
</button>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="sort-section">
|
||||
<div class="menu-section-header">
|
||||
<mat-icon class="section-icon">sort</mat-icon>
|
||||
<span>{{ 'HOME.SORT_BY' | translate }}</span>
|
||||
</div>
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="
|
||||
setSortOptions(SortBy.NAME, SortOrder.ASC);
|
||||
$event.stopPropagation()
|
||||
"
|
||||
[class.active-option]="isSortActive(SortBy.NAME, SortOrder.ASC)"
|
||||
>
|
||||
<mat-icon>sort_by_alpha</mat-icon>
|
||||
<span class="menu-item-text">{{
|
||||
'HOME.SORT_OPTIONS.NAME_ASC' | translate
|
||||
}}</span>
|
||||
<mat-icon
|
||||
class="check-icon"
|
||||
[class.check-visible]="isSortActive(SortBy.NAME, SortOrder.ASC)"
|
||||
>check</mat-icon
|
||||
>
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="
|
||||
setSortOptions(SortBy.NAME, SortOrder.DESC);
|
||||
$event.stopPropagation()
|
||||
"
|
||||
[class.active-option]="isSortActive(SortBy.NAME, SortOrder.DESC)"
|
||||
>
|
||||
<mat-icon>sort_by_alpha</mat-icon>
|
||||
<span class="menu-item-text">{{
|
||||
'HOME.SORT_OPTIONS.NAME_DESC' | translate
|
||||
}}</span>
|
||||
<mat-icon
|
||||
class="check-icon"
|
||||
[class.check-visible]="
|
||||
isSortActive(SortBy.NAME, SortOrder.DESC)
|
||||
"
|
||||
>check</mat-icon
|
||||
>
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="
|
||||
setSortOptions(SortBy.DATE_ADDED, SortOrder.DESC);
|
||||
$event.stopPropagation()
|
||||
"
|
||||
[class.active-option]="
|
||||
isSortActive(SortBy.DATE_ADDED, SortOrder.DESC)
|
||||
"
|
||||
>
|
||||
<mat-icon>date_range</mat-icon>
|
||||
<span class="menu-item-text">{{
|
||||
'HOME.SORT_OPTIONS.NEWEST' | translate
|
||||
}}</span>
|
||||
<mat-icon
|
||||
class="check-icon"
|
||||
[class.check-visible]="
|
||||
isSortActive(SortBy.DATE_ADDED, SortOrder.DESC)
|
||||
"
|
||||
>check</mat-icon
|
||||
>
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="
|
||||
setSortOptions(SortBy.DATE_ADDED, SortOrder.ASC);
|
||||
$event.stopPropagation()
|
||||
"
|
||||
[class.active-option]="
|
||||
isSortActive(SortBy.DATE_ADDED, SortOrder.ASC)
|
||||
"
|
||||
>
|
||||
<mat-icon>date_range</mat-icon>
|
||||
<span class="menu-item-text">{{
|
||||
'HOME.SORT_OPTIONS.OLDEST' | translate
|
||||
}}</span>
|
||||
<mat-icon
|
||||
class="check-icon"
|
||||
[class.check-visible]="
|
||||
isSortActive(SortBy.DATE_ADDED, SortOrder.ASC)
|
||||
"
|
||||
>check</mat-icon
|
||||
>
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="
|
||||
setSortOptions(SortBy.CUSTOM, SortOrder.ASC);
|
||||
$event.stopPropagation()
|
||||
"
|
||||
[class.active-option]="isSortActive(SortBy.CUSTOM, SortOrder.ASC)"
|
||||
>
|
||||
<mat-icon>drag_indicator</mat-icon>
|
||||
<span class="menu-item-text">{{
|
||||
'HOME.SORT_OPTIONS.CUSTOM_ORDER' | translate
|
||||
}}</span>
|
||||
<mat-icon
|
||||
class="check-icon"
|
||||
[class.check-visible]="
|
||||
isSortActive(SortBy.CUSTOM, SortOrder.ASC)
|
||||
"
|
||||
>check</mat-icon
|
||||
>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div class="menu-footer">
|
||||
<button mat-button (click)="closeMenu()">
|
||||
{{ 'CLOSE' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</mat-menu>
|
||||
@@ -1,87 +0,0 @@
|
||||
.filter-section,
|
||||
.sort-section {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.menu-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
pointer-events: none;
|
||||
|
||||
.section-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: rgba(139, 92, 246, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
::ng-deep .filter-sort-menu {
|
||||
.mat-mdc-menu-item {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
gap: 12px !important;
|
||||
min-height: 40px !important;
|
||||
padding: 0 16px !important;
|
||||
|
||||
.mat-icon:first-child {
|
||||
margin-right: 0 !important;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.menu-item-text {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
margin-left: auto !important;
|
||||
margin-right: 0 !important;
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
&.check-visible {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.active-option {
|
||||
background-color: rgba(139, 92, 246, 0.12) !important;
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 3px;
|
||||
height: 60%;
|
||||
background: rgba(139, 92, 246, 0.8);
|
||||
border-radius: 0 2px 2px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
font-size: 18px !important;
|
||||
width: 18px !important;
|
||||
height: 18px !important;
|
||||
color: rgba(139, 92, 246, 1) !important;
|
||||
}
|
||||
|
||||
.menu-footer {
|
||||
padding: 4px 8px 4px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
import {
|
||||
Component,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatMenu, MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { FilterActions, selectActiveTypeFilters } from '@iptvnator/m3u-state';
|
||||
import { SortBy, SortOrder, SortService } from '@iptvnator/services';
|
||||
|
||||
@Component({
|
||||
selector: 'app-filter-sort-menu',
|
||||
templateUrl: './filter-sort-menu.component.html',
|
||||
styleUrls: ['./filter-sort-menu.component.scss'],
|
||||
imports: [MatButtonModule, MatIconModule, MatMenuModule, TranslateModule],
|
||||
})
|
||||
export class FilterSortMenuComponent {
|
||||
private readonly store = inject(Store);
|
||||
private readonly sortService = inject(SortService);
|
||||
|
||||
readonly menu = viewChild.required<MatMenu>('filterSortMenu');
|
||||
readonly menuTrigger = input<MatMenuTrigger>();
|
||||
readonly filterChanged = output<void>();
|
||||
|
||||
playlistTypes = [
|
||||
{
|
||||
id: 'm3u',
|
||||
icon: 'playlist_play',
|
||||
translationKey: 'HOME.PLAYLIST_TYPES.M3U',
|
||||
checked: true,
|
||||
},
|
||||
{
|
||||
id: 'xtream',
|
||||
icon: 'cloud',
|
||||
translationKey: 'HOME.PLAYLIST_TYPES.XTREAM',
|
||||
checked: true,
|
||||
},
|
||||
{
|
||||
id: 'stalker',
|
||||
icon: 'router',
|
||||
translationKey: 'HOME.PLAYLIST_TYPES.STALKER',
|
||||
checked: true,
|
||||
},
|
||||
];
|
||||
|
||||
private readonly selectedTypeFilters = this.store.selectSignal(
|
||||
selectActiveTypeFilters
|
||||
);
|
||||
|
||||
readonly SortBy = SortBy;
|
||||
readonly SortOrder = SortOrder;
|
||||
private readonly currentSortOptions = toSignal(
|
||||
this.sortService.getSortOptions(),
|
||||
{
|
||||
requireSync: true,
|
||||
}
|
||||
);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
if (this.selectedTypeFilters) {
|
||||
this.playlistTypes = this.playlistTypes.map((type) => {
|
||||
type.checked = this.selectedTypeFilters().includes(type.id);
|
||||
return type;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
togglePlaylistType(type: {
|
||||
id: string;
|
||||
icon: string;
|
||||
translationKey: string;
|
||||
checked: boolean;
|
||||
}) {
|
||||
const currentlySelectedCount = this.playlistTypes.filter(
|
||||
(item) => item.checked
|
||||
).length;
|
||||
|
||||
if (type.checked && currentlySelectedCount === 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
type.checked = !type.checked;
|
||||
this.store.dispatch(
|
||||
FilterActions.setSelectedFilters({
|
||||
selectedFilters: this.playlistTypes
|
||||
.filter((item) => item.checked)
|
||||
.map((item) => item.id),
|
||||
})
|
||||
);
|
||||
this.filterChanged.emit();
|
||||
}
|
||||
|
||||
setSortOptions(by: SortBy, order: SortOrder): void {
|
||||
this.sortService.setSortOptions({ by, order });
|
||||
}
|
||||
|
||||
isSortActive(by: SortBy, order: SortOrder): boolean {
|
||||
const options = this.currentSortOptions();
|
||||
return options?.by === by && options?.order === order;
|
||||
}
|
||||
|
||||
get activeFiltersCount(): number {
|
||||
return this.playlistTypes.filter((type) => !type.checked).length;
|
||||
}
|
||||
|
||||
closeMenu(): void {
|
||||
this.menuTrigger()?.closeMenu();
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ export * from './lib/components/global-favorites-list/global-favorites-list.comp
|
||||
export * from './lib/components/grid-list/grid-list.component';
|
||||
export * from './lib/components/playlist-error-view/playlist-error-view.component';
|
||||
export { ProgressCapsuleComponent } from '@iptvnator/ui/components';
|
||||
export * from './lib/components/portal-collection-shell/portal-collection-shell.component';
|
||||
export * from './lib/components/portal-empty-state/portal-empty-state.component';
|
||||
export * from './lib/components/search-form/search-form.component';
|
||||
export * from './lib/components/search-layout/search-layout.component';
|
||||
@@ -14,5 +13,4 @@ export { WatchedBadgeComponent } from '@iptvnator/ui/components';
|
||||
export * from './lib/components/unified-collection/unified-collection-page.component';
|
||||
export * from './lib/components/unified-collection/unified-collection-detail.directive';
|
||||
export * from './lib/components/unified-collection/unified-live-tab.component';
|
||||
export * from './lib/navigation/navigation.component';
|
||||
export * from './lib/navigation/portal-rail-links.component';
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
@switch (mode()) {
|
||||
@case ('detail') {
|
||||
<ng-content select="[details]" />
|
||||
}
|
||||
@case ('live') {
|
||||
<ng-content select="[live]" />
|
||||
}
|
||||
@default {
|
||||
<app-favorites-layout
|
||||
[categories]="categories()"
|
||||
[favorites]="items()"
|
||||
[playlistSubtitle]="playlistSubtitle()"
|
||||
[playlistTitle]="playlistTitle()"
|
||||
[selectedCategoryId]="selectedCategoryId()"
|
||||
[titleTranslationString]="
|
||||
layout().titleTranslationKey || 'CHANNELS.FAVORITES'
|
||||
"
|
||||
[removeTooltip]="layout().removeTooltip || ''"
|
||||
[emptyIcon]="layout().emptyIcon || 'favorite_border'"
|
||||
[showHeaderAction]="layout().showHeaderAction || false"
|
||||
[headerActionIcon]="layout().headerActionIcon || 'delete_sweep'"
|
||||
[headerActionTooltip]="layout().headerActionTooltip || ''"
|
||||
(categoryClicked)="onCategoryClicked($event)"
|
||||
(removeFavorite)="removeItem.emit($event)"
|
||||
(headerActionClicked)="headerActionClicked.emit()"
|
||||
(openItem)="openItem.emit($event)"
|
||||
/>
|
||||
}
|
||||
}
|
||||
-61
@@ -1,61 +0,0 @@
|
||||
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
|
||||
import { XtreamCategory } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
FavoriteLayoutItem,
|
||||
FavoritesLayoutComponent,
|
||||
} from '../favorites-layout/favorites-layout.component';
|
||||
|
||||
interface PortalCategorySelection {
|
||||
readonly category_id?: string | number;
|
||||
readonly id?: string | number;
|
||||
}
|
||||
|
||||
export interface PortalCollectionShellLayout {
|
||||
titleTranslationKey?: string;
|
||||
removeTooltip?: string;
|
||||
emptyIcon?: string;
|
||||
showHeaderAction?: boolean;
|
||||
headerActionIcon?: string;
|
||||
headerActionTooltip?: string;
|
||||
}
|
||||
|
||||
export type PortalCollectionMode = 'grid' | 'detail' | 'live';
|
||||
|
||||
@Component({
|
||||
selector: 'app-portal-collection-shell',
|
||||
imports: [FavoritesLayoutComponent],
|
||||
templateUrl: './portal-collection-shell.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class PortalCollectionShellComponent {
|
||||
readonly categories = input<XtreamCategory[]>([]);
|
||||
readonly items = input<FavoriteLayoutItem[]>([]);
|
||||
readonly playlistSubtitle = input<string>('');
|
||||
readonly playlistTitle = input<string>('Playlist');
|
||||
readonly selectedCategoryId = input<string>('all');
|
||||
readonly layout = input<PortalCollectionShellLayout>({});
|
||||
readonly mode = input<PortalCollectionMode>('grid');
|
||||
|
||||
readonly categoryClicked = output<string>();
|
||||
readonly removeItem = output<FavoriteLayoutItem>();
|
||||
readonly openItem = output<FavoriteLayoutItem>();
|
||||
readonly headerActionClicked = output<void>();
|
||||
|
||||
onCategoryClicked(event: PortalCategorySelection): void {
|
||||
const categoryId = event.category_id ?? event.id;
|
||||
if (categoryId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.categoryClicked.emit(String(categoryId));
|
||||
}
|
||||
}
|
||||
+42
-14
@@ -73,25 +73,53 @@
|
||||
<div
|
||||
class="epg"
|
||||
[class.epg--inline]="shouldUseInlinePlayer()"
|
||||
[class.epg--list]="epgViewMode() === 'list'"
|
||||
[class.epg-collapsed]="
|
||||
shouldUseInlinePlayer() && isLiveEpgPanelCollapsed()
|
||||
"
|
||||
>
|
||||
<div class="epg-content">
|
||||
<app-epg-timeline
|
||||
[programs]="timelinePrograms()"
|
||||
[channelName]="timelineChannelName()"
|
||||
[channelLogo]="timelineChannelLogo()"
|
||||
[archivePlaybackAvailable]="timelineArchiveAvailable()"
|
||||
[archiveDays]="timelineArchiveDays()"
|
||||
[selectedDate]="selectedLiveEpgDate()"
|
||||
[collapsed]="
|
||||
shouldUseInlinePlayer() && isLiveEpgPanelCollapsed()
|
||||
"
|
||||
[summary]="liveEpgPanelSummary()"
|
||||
(selectedDateChange)="onLiveEpgSelectedDateChange($event)"
|
||||
(collapsedChange)="onLiveEpgPanelCollapsedChange($event)"
|
||||
/>
|
||||
@if (epgViewMode() === 'list') {
|
||||
<app-epg-list-view
|
||||
[programs]="timelinePrograms()"
|
||||
[channelName]="timelineChannelName()"
|
||||
[channelLogo]="timelineChannelLogo()"
|
||||
[archivePlaybackAvailable]="timelineArchiveAvailable()"
|
||||
[archiveDays]="timelineArchiveDays()"
|
||||
[selectedDate]="selectedLiveEpgDate()"
|
||||
[collapsed]="
|
||||
shouldUseInlinePlayer() &&
|
||||
isLiveEpgPanelCollapsed()
|
||||
"
|
||||
[summary]="liveEpgPanelSummary()"
|
||||
(selectedDateChange)="
|
||||
onLiveEpgSelectedDateChange($event)
|
||||
"
|
||||
(collapsedChange)="
|
||||
onLiveEpgPanelCollapsedChange($event)
|
||||
"
|
||||
/>
|
||||
} @else {
|
||||
<app-epg-timeline
|
||||
[programs]="timelinePrograms()"
|
||||
[channelName]="timelineChannelName()"
|
||||
[channelLogo]="timelineChannelLogo()"
|
||||
[archivePlaybackAvailable]="timelineArchiveAvailable()"
|
||||
[archiveDays]="timelineArchiveDays()"
|
||||
[selectedDate]="selectedLiveEpgDate()"
|
||||
[collapsed]="
|
||||
shouldUseInlinePlayer() &&
|
||||
isLiveEpgPanelCollapsed()
|
||||
"
|
||||
[summary]="liveEpgPanelSummary()"
|
||||
(selectedDateChange)="
|
||||
onLiveEpgSelectedDateChange($event)
|
||||
"
|
||||
(collapsedChange)="
|
||||
onLiveEpgPanelCollapsedChange($event)
|
||||
"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
+2
-2
@@ -67,8 +67,8 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
> app-epg-view,
|
||||
> app-epg-list {
|
||||
> app-epg-timeline,
|
||||
> app-epg-list-view {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
+46
-1
@@ -17,6 +17,7 @@ import {
|
||||
WebPlayerViewComponent,
|
||||
} from '@iptvnator/ui/playback';
|
||||
import {
|
||||
EpgListViewComponent,
|
||||
EpgTimelineComponent,
|
||||
getTodayEpgDateKey,
|
||||
shiftEpgDateKey,
|
||||
@@ -78,8 +79,10 @@ class StubGlobalFavoritesListComponent {
|
||||
readonly removeRequested = output<UnifiedFavoriteChannel>();
|
||||
}
|
||||
|
||||
// Matches both live-panel selectors so the host's timeline ↔ list swap can be
|
||||
// asserted by tag name; both branches share the identical contract.
|
||||
@Component({
|
||||
selector: 'app-epg-timeline',
|
||||
selector: 'app-epg-timeline, app-epg-list-view',
|
||||
template: '<div class="stub-epg-timeline"></div>',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
@@ -127,6 +130,7 @@ describe('UnifiedLiveTabComponent', () => {
|
||||
let fixture: ComponentFixture<UnifiedLiveTabComponent>;
|
||||
let component: UnifiedLiveTabComponent;
|
||||
let player: ReturnType<typeof signal<VideoPlayer>>;
|
||||
let epgViewMode: ReturnType<typeof signal<'timeline' | 'list'>>;
|
||||
let streamResolver: {
|
||||
resolveLiveDetail: jest.Mock;
|
||||
resolveM3uPlaybackDetail: jest.Mock;
|
||||
@@ -160,6 +164,7 @@ describe('UnifiedLiveTabComponent', () => {
|
||||
recordLivePlayback: jest.fn(),
|
||||
};
|
||||
player = signal(VideoPlayer.VideoJs);
|
||||
epgViewMode = signal<'timeline' | 'list'>('timeline');
|
||||
portalPlayer = {
|
||||
isEmbeddedPlayer: jest.fn().mockReturnValue(false),
|
||||
openResolvedPlayback: jest.fn(),
|
||||
@@ -184,6 +189,7 @@ describe('UnifiedLiveTabComponent', () => {
|
||||
useValue: {
|
||||
openStreamOnDoubleClick: signal(false),
|
||||
player,
|
||||
resolvedEpgViewMode: epgViewMode,
|
||||
},
|
||||
},
|
||||
{ provide: PORTAL_PLAYER, useValue: portalPlayer },
|
||||
@@ -193,6 +199,7 @@ describe('UnifiedLiveTabComponent', () => {
|
||||
remove: {
|
||||
imports: [
|
||||
AudioPlayerComponent,
|
||||
EpgListViewComponent,
|
||||
EpgTimelineComponent,
|
||||
GlobalFavoritesListComponent,
|
||||
ResizableDirective,
|
||||
@@ -447,6 +454,44 @@ describe('UnifiedLiveTabComponent', () => {
|
||||
expect(timeline.selectedDate()).toBe(nextDate);
|
||||
});
|
||||
|
||||
it('swaps the timeline for the list view when epgViewMode is "list"', async () => {
|
||||
epgViewMode.set('list');
|
||||
const item = buildLiveItem('xtream');
|
||||
streamResolver.resolveLiveDetail.mockResolvedValue({
|
||||
epgMode: 'portal',
|
||||
playback: {
|
||||
streamUrl: 'https://example.com/xtream.m3u8',
|
||||
title: 'Xtream Live',
|
||||
},
|
||||
epgItems: [buildCurrentEpgItem('Xtream Now')],
|
||||
});
|
||||
recentData.recordLivePlayback.mockResolvedValue({
|
||||
...item,
|
||||
viewedAt: '2026-03-26T12:00:00.000Z',
|
||||
});
|
||||
|
||||
fixture.componentRef.setInput('items', [item]);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
await component.onChannelSelected(component.channelsForList()[0]);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('app-epg-list-view')
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('app-epg-timeline')
|
||||
).toBeNull();
|
||||
// Taller inline panel for the list view (see _portal-layout.scss).
|
||||
expect(
|
||||
fixture.nativeElement
|
||||
.querySelector('.epg')
|
||||
?.classList.contains('epg--list')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('renders inline portal EPG in the timeline and flows collapse state through', async () => {
|
||||
portalPlayer.isEmbeddedPlayer.mockReturnValue(true);
|
||||
const item = buildLiveItem('xtream');
|
||||
|
||||
+4
@@ -36,6 +36,7 @@ import {
|
||||
} from '@iptvnator/portal/shared/data-access';
|
||||
import {
|
||||
EpgDateNavigationDirection,
|
||||
EpgListViewComponent,
|
||||
EpgTimelineComponent,
|
||||
getTodayEpgDateKey,
|
||||
shiftEpgDateKey,
|
||||
@@ -59,6 +60,7 @@ import { LiveEpgPanelSummary } from '@iptvnator/ui/shared-portals';
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
AudioPlayerComponent,
|
||||
EpgListViewComponent,
|
||||
EpgTimelineComponent,
|
||||
GlobalFavoritesListComponent,
|
||||
MatButtonModule,
|
||||
@@ -191,6 +193,8 @@ export class UnifiedLiveTabComponent {
|
||||
readonly isLiveEpgPanelCollapsed = computed(
|
||||
() => this.liveEpgPanelState() === 'collapsed'
|
||||
);
|
||||
/** Live EPG panel layout chosen in settings; hosts swap timeline ↔ list. */
|
||||
readonly epgViewMode = this.settingsStore.resolvedEpgViewMode;
|
||||
readonly liveEpgPanelSummary = computed(() => {
|
||||
this.progressTick();
|
||||
return this.getLiveEpgPanelSummary(this.activeDetail());
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
<div
|
||||
class="portal-status-container"
|
||||
[class]="getStatusColor()"
|
||||
[matTooltip]="portalStatus() ?? ''"
|
||||
matTooltipPosition="right"
|
||||
>
|
||||
<mat-icon class="status-watermark">{{ getStatusIcon() }}</mat-icon>
|
||||
</div>
|
||||
<div class="nav-section">
|
||||
<mat-nav-list>
|
||||
<a
|
||||
class="portal-nav-link nav-item"
|
||||
mat-list-item
|
||||
routerLink="/"
|
||||
routerLinkActive="active"
|
||||
[routerLinkActiveOptions]="{ exact: true }"
|
||||
[matTooltip]="'PORTALS.SIDEBAR.HOME' | translate"
|
||||
matTooltipPosition="right"
|
||||
>
|
||||
<mat-icon matListItemIcon>home</mat-icon>
|
||||
</a>
|
||||
</mat-nav-list>
|
||||
</div>
|
||||
<mat-divider />
|
||||
<div class="nav-section">
|
||||
<app-portal-rail-links
|
||||
variant="list"
|
||||
activeClass="active"
|
||||
[selectedSection]="selectedContentType()"
|
||||
[links]="primaryLinks()"
|
||||
/>
|
||||
</div>
|
||||
<mat-divider />
|
||||
|
||||
<div class="nav-section">
|
||||
<app-portal-rail-links
|
||||
variant="list"
|
||||
activeClass="active"
|
||||
[selectedSection]="selectedContentType()"
|
||||
[links]="secondaryLinks()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="navigation-bottom">
|
||||
<mat-divider />
|
||||
<mat-nav-list>
|
||||
@if (!isStalkerPlaylist()) {
|
||||
<a
|
||||
mat-list-item
|
||||
class="portal-nav-link nav-item action-button"
|
||||
(click)="openAccountInfo()"
|
||||
[matTooltip]="'PORTALS.SIDEBAR.ACCOUNT_INFO' | translate"
|
||||
matTooltipPosition="right"
|
||||
>
|
||||
<mat-icon>person</mat-icon>
|
||||
</a>
|
||||
}
|
||||
<a
|
||||
class="portal-nav-link nav-item"
|
||||
mat-list-item
|
||||
(click)="openPlaylistInfo()"
|
||||
[matTooltip]="'PORTALS.SIDEBAR.PLAYLIST_INFO' | translate"
|
||||
matTooltipPosition="right"
|
||||
>
|
||||
<mat-icon matListItemIcon>playlist_play</mat-icon>
|
||||
</a>
|
||||
<mat-divider />
|
||||
<a
|
||||
class="portal-nav-link nav-item"
|
||||
mat-list-item
|
||||
(click)="openSettings()"
|
||||
[matTooltip]="'MENU.SETTINGS' | translate"
|
||||
matTooltipPosition="right"
|
||||
>
|
||||
<mat-icon matListItemIcon>settings</mat-icon>
|
||||
</a>
|
||||
</mat-nav-list>
|
||||
</div>
|
||||
@@ -1,116 +0,0 @@
|
||||
@use '../../../../../../../apps/web/src/nav-list.scss';
|
||||
|
||||
:host {
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.mdc-list {
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.mat-nav-list {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
.portal-nav-link.mdc-list-item {
|
||||
min-height: 28px;
|
||||
height: 28px;
|
||||
padding: 0 !important;
|
||||
border-radius: 10px;
|
||||
margin: 2px 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
|
||||
.mdc-list-item__start {
|
||||
margin-inline-end: 0 !important;
|
||||
margin-inline-start: 0 !important;
|
||||
}
|
||||
|
||||
.mdc-list-item__content {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
mat-icon.mat-mdc-list-item-icon,
|
||||
mat-icon {
|
||||
font-size: 18px !important;
|
||||
width: 18px !important;
|
||||
height: 18px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.navigation-bottom {
|
||||
margin-top: auto;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.active {
|
||||
color: var(--app-selection-color);
|
||||
|
||||
mat-icon {
|
||||
color: var(--app-selection-color);
|
||||
}
|
||||
}
|
||||
|
||||
mat-divider {
|
||||
margin: 1px 0;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
margin-right: 0;
|
||||
font-size: 18px !important;
|
||||
width: 18px !important;
|
||||
height: 18px !important;
|
||||
}
|
||||
|
||||
a.mat-mdc-list-item.mdc-list-item {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.portal-status-container {
|
||||
app-region: drag;
|
||||
margin: -4px -4px 0;
|
||||
height: 42px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
background: var(--mat-sys-surface-container);
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
border: 1px solid var(--mat-sys-outline-variant);
|
||||
}
|
||||
|
||||
.portal-status-container.status-active {
|
||||
background: var(--mat-sys-primary-container);
|
||||
color: var(--mat-sys-on-primary-container);
|
||||
}
|
||||
|
||||
.portal-status-container.status-inactive,
|
||||
.portal-status-container.status-unavailable {
|
||||
background: var(--mat-sys-error-container);
|
||||
color: var(--mat-sys-on-error-container);
|
||||
}
|
||||
|
||||
.portal-status-container.status-expired {
|
||||
background: var(--mat-sys-tertiary-container);
|
||||
color: var(--mat-sys-on-tertiary-container);
|
||||
}
|
||||
|
||||
.portal-status-container .status-watermark {
|
||||
font-size: 24px !important;
|
||||
width: 24px !important;
|
||||
height: 24px !important;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.nav-section {
|
||||
app-portal-rail-links {
|
||||
display: block;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { PORTAL_NAVIGATION_ACTIONS } from '@iptvnator/portal/shared/util';
|
||||
import { RuntimeCapabilitiesService } from '@iptvnator/services';
|
||||
import { Playlist } from '@iptvnator/shared/interfaces';
|
||||
import { NavigationComponent } from './navigation.component';
|
||||
|
||||
describe('NavigationComponent', () => {
|
||||
let playlist: ReturnType<typeof signal<Partial<Playlist> | undefined>>;
|
||||
let runtime: {
|
||||
supportsDownloads: boolean;
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
playlist = signal<Partial<Playlist> | undefined>({
|
||||
_id: 'xtream-1',
|
||||
});
|
||||
runtime = {
|
||||
supportsDownloads: true,
|
||||
};
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [TranslateModule.forRoot(), NavigationComponent],
|
||||
providers: [
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
snapshot: {
|
||||
params: {
|
||||
id: 'xtream-1',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: Store,
|
||||
useValue: {
|
||||
selectSignal: jest.fn(() => playlist),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PORTAL_NAVIGATION_ACTIONS,
|
||||
useValue: {
|
||||
openAccountInfo: jest.fn(),
|
||||
openPlaylistInfo: jest.fn(),
|
||||
openSettings: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: RuntimeCapabilitiesService,
|
||||
useValue: runtime,
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
function createComponent(): NavigationComponent {
|
||||
return TestBed.createComponent(NavigationComponent).componentInstance;
|
||||
}
|
||||
|
||||
it('includes downloads in portal rail links when downloads are supported', () => {
|
||||
runtime.supportsDownloads = true;
|
||||
|
||||
const component = createComponent();
|
||||
|
||||
expect(component.secondaryLinks().map((link) => link.section)).toEqual([
|
||||
'recently-added',
|
||||
'search',
|
||||
'downloads',
|
||||
]);
|
||||
});
|
||||
|
||||
it('hides downloads in portal rail links when downloads are unsupported', () => {
|
||||
runtime.supportsDownloads = false;
|
||||
|
||||
const component = createComponent();
|
||||
|
||||
expect(component.secondaryLinks().map((link) => link.section)).toEqual([
|
||||
'recently-added',
|
||||
'search',
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses the Stalker rail shape for Stalker playlists', () => {
|
||||
runtime.supportsDownloads = true;
|
||||
playlist.set({
|
||||
_id: 'stalker-1',
|
||||
macAddress: '00:1A:79:00:00:01',
|
||||
});
|
||||
|
||||
const component = createComponent();
|
||||
|
||||
expect(component.primaryLinks().map((link) => link.section)).toEqual([
|
||||
'vod',
|
||||
'itv',
|
||||
'radio',
|
||||
'series',
|
||||
]);
|
||||
expect(component.secondaryLinks().map((link) => link.section)).toEqual([
|
||||
'search',
|
||||
'downloads',
|
||||
]);
|
||||
});
|
||||
|
||||
it('hides Stalker downloads when downloads are unsupported', () => {
|
||||
runtime.supportsDownloads = false;
|
||||
playlist.set({
|
||||
_id: 'stalker-1',
|
||||
macAddress: '00:1A:79:00:00:01',
|
||||
});
|
||||
|
||||
const component = createComponent();
|
||||
|
||||
expect(component.secondaryLinks().map((link) => link.section)).toEqual([
|
||||
'search',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,116 +0,0 @@
|
||||
import { Component, computed, inject, input } from '@angular/core';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatListModule } from '@angular/material/list';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { ActivatedRoute, RouterLink, RouterLinkActive } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import {
|
||||
buildPortalRailLinks,
|
||||
PORTAL_NAVIGATION_ACTIONS,
|
||||
PortalRailSection,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { selectPlaylistById } from '@iptvnator/m3u-state';
|
||||
import { RuntimeCapabilitiesService } from '@iptvnator/services';
|
||||
import { Playlist } from '@iptvnator/shared/interfaces';
|
||||
import { PortalRailLinksComponent } from './portal-rail-links.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-navigation',
|
||||
imports: [
|
||||
MatDividerModule,
|
||||
MatIcon,
|
||||
MatListModule,
|
||||
MatTooltip,
|
||||
RouterLink,
|
||||
RouterLinkActive,
|
||||
TranslatePipe,
|
||||
PortalRailLinksComponent,
|
||||
],
|
||||
templateUrl: './navigation.component.html',
|
||||
styleUrl: './navigation.component.scss',
|
||||
})
|
||||
export class NavigationComponent {
|
||||
private readonly activatedRoute = inject(ActivatedRoute);
|
||||
private readonly navigationActions = inject(PORTAL_NAVIGATION_ACTIONS);
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
private readonly store = inject(Store);
|
||||
|
||||
readonly portalStatus = input<
|
||||
'active' | 'inactive' | 'expired' | 'unavailable'
|
||||
>();
|
||||
readonly selectedContentType = input<PortalRailSection | undefined>();
|
||||
|
||||
readonly currentPlaylist = this.store.selectSignal(
|
||||
selectPlaylistById(this.activatedRoute.snapshot.params.id)
|
||||
);
|
||||
|
||||
readonly isStalkerPlaylist = computed(
|
||||
() => !!(this.currentPlaylist() as Playlist | undefined)?.macAddress
|
||||
);
|
||||
|
||||
get supportsDownloads(): boolean {
|
||||
return this.runtime.supportsDownloads;
|
||||
}
|
||||
|
||||
readonly railLinks = computed(() => {
|
||||
const playlistId =
|
||||
(this.currentPlaylist() as Playlist | undefined)?._id ??
|
||||
this.activatedRoute.snapshot.params['id'];
|
||||
if (!playlistId) {
|
||||
return { primary: [], secondary: [] };
|
||||
}
|
||||
|
||||
return buildPortalRailLinks({
|
||||
provider: this.isStalkerPlaylist() ? 'stalker' : 'xtreams',
|
||||
playlistId,
|
||||
supportsDownloads: this.supportsDownloads,
|
||||
workspace: false,
|
||||
});
|
||||
});
|
||||
readonly primaryLinks = computed(() => this.railLinks().primary);
|
||||
readonly secondaryLinks = computed(() => this.railLinks().secondary);
|
||||
|
||||
getStatusColor(): string {
|
||||
if (this.isStalkerPlaylist()) return 'status-active';
|
||||
|
||||
switch (this.portalStatus()) {
|
||||
case 'active':
|
||||
return 'status-active';
|
||||
case 'inactive':
|
||||
return 'status-inactive';
|
||||
case 'expired':
|
||||
return 'status-expired';
|
||||
default:
|
||||
return 'status-unavailable';
|
||||
}
|
||||
}
|
||||
|
||||
getStatusIcon(): string {
|
||||
if (this.isStalkerPlaylist()) return 'play_circle';
|
||||
|
||||
switch (this.portalStatus()) {
|
||||
case 'active':
|
||||
return 'check_circle';
|
||||
case 'inactive':
|
||||
return 'cancel';
|
||||
case 'expired':
|
||||
return 'warning';
|
||||
default:
|
||||
return 'error';
|
||||
}
|
||||
}
|
||||
|
||||
openAccountInfo(): void {
|
||||
this.navigationActions.openAccountInfo();
|
||||
}
|
||||
|
||||
openSettings(): void {
|
||||
this.navigationActions.openSettings();
|
||||
}
|
||||
|
||||
openPlaylistInfo(): void {
|
||||
this.navigationActions.openPlaylistInfo(this.currentPlaylist());
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
export * from './lib/stalker-inline-detail/stalker-inline-detail.component';
|
||||
export * from './lib/stalker-collection-detail.component';
|
||||
export * from './lib/stalker-collection-channels-list/stalker-collection-channels-list.component';
|
||||
export * from './lib/stalker-catalog-detail/stalker-catalog-detail.component';
|
||||
export * from './lib/stalker-favorites-button/stalker-favorites-button.component';
|
||||
export * from './lib/stalker-catalog-facade.service';
|
||||
|
||||
-44
@@ -1,44 +0,0 @@
|
||||
<div class="search-panel">
|
||||
<label class="search-field">
|
||||
<mat-icon>search</mat-icon>
|
||||
<input
|
||||
type="search"
|
||||
spellcheck="false"
|
||||
[placeholder]="'CHANNELS.SEARCH_CHANNEL' | translate"
|
||||
[(ngModel)]="searchString"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="channels-list">
|
||||
@if (filteredItems().length > 0) {
|
||||
@for (item of filteredItems(); track item.id) {
|
||||
<app-channel-list-item
|
||||
[name]="item.o_name || item.name || ''"
|
||||
[logo]="item.logo"
|
||||
[selected]="isSelected(item)"
|
||||
[epgProgram]="
|
||||
epgPrograms.get(normalizeStalkerEntityId(item.id)) ?? null
|
||||
"
|
||||
[progressPercentage]="
|
||||
currentProgramsProgress.get(normalizeStalkerEntityId(item.id)) ?? 0
|
||||
"
|
||||
[showFavoriteButton]="true"
|
||||
[showProgramInfoButton]="false"
|
||||
[isFavorite]="isFavorite(item)"
|
||||
(clicked)="onPlay(item)"
|
||||
(favoriteToggled)="onFavoriteToggle(item)"
|
||||
/>
|
||||
}
|
||||
} @else {
|
||||
<div class="empty-search-state">
|
||||
<mat-icon class="empty-state-icon">search_off</mat-icon>
|
||||
<h3 class="empty-state-title">
|
||||
{{ 'CHANNELS.NO_CHANNELS_FOUND' | translate }}
|
||||
</h3>
|
||||
<p class="empty-state-hint">
|
||||
{{ 'CHANNELS.TRY_DIFFERENT_SEARCH' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
-115
@@ -1,115 +0,0 @@
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.channels-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100% - 56px);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
width: 100%;
|
||||
padding: 4px 8px 12px;
|
||||
box-sizing: border-box;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.search-panel {
|
||||
padding: 10px 12px;
|
||||
flex-shrink: 0;
|
||||
height: 56px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-field {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(127, 127, 127, 0.22);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0)),
|
||||
rgba(127, 127, 127, 0.08);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 12px;
|
||||
box-sizing: border-box;
|
||||
cursor: text;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
background-color 0.15s ease,
|
||||
box-shadow 0.15s ease;
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--app-selection-border);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--app-selection-color) 16%, transparent);
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
font-size: 18px;
|
||||
opacity: 0.68;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-search-state {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 24px;
|
||||
text-align: center;
|
||||
color: var(--app-muted-color);
|
||||
|
||||
.empty-state-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
font-size: 48px;
|
||||
opacity: 0.5;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-state-title {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 520;
|
||||
letter-spacing: -0.01em;
|
||||
margin: 0 0 8px;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.empty-state-hint {
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
opacity: 0.65;
|
||||
line-height: 1.4;
|
||||
max-width: 240px;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-width: 350px) {
|
||||
.channels-list {
|
||||
padding-inline: 6px;
|
||||
}
|
||||
}
|
||||
-118
@@ -1,118 +0,0 @@
|
||||
import { Component, input, output, signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { MockPipe } from 'ng-mocks';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { ChannelListItemComponent } from '@iptvnator/ui/components';
|
||||
import { EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
|
||||
import { StalkerCollectionChannelsListComponent } from './stalker-collection-channels-list.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-channel-list-item',
|
||||
standalone: true,
|
||||
template: '',
|
||||
})
|
||||
class StubChannelListItemComponent {
|
||||
readonly name = input('');
|
||||
readonly logo = input<string | null | undefined>(null);
|
||||
readonly selected = input(false);
|
||||
readonly epgProgram = input<unknown>(null);
|
||||
readonly progressPercentage = input(0);
|
||||
readonly showFavoriteButton = input(false);
|
||||
readonly showProgramInfoButton = input(false);
|
||||
readonly isFavorite = input(false);
|
||||
readonly clicked = output<void>();
|
||||
readonly favoriteToggled = output<void>();
|
||||
}
|
||||
|
||||
describe('StalkerCollectionChannelsListComponent', () => {
|
||||
let fixture: ComponentFixture<StalkerCollectionChannelsListComponent>;
|
||||
let component: StalkerCollectionChannelsListComponent;
|
||||
|
||||
const bulkItvEpgByChannel = signal<Record<string, EpgProgram[]>>({});
|
||||
const stalkerStore = {
|
||||
bulkItvEpgByChannel,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
bulkItvEpgByChannel.set({});
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [StalkerCollectionChannelsListComponent],
|
||||
providers: [
|
||||
{ provide: StalkerStore, useValue: stalkerStore },
|
||||
],
|
||||
})
|
||||
.overrideComponent(StalkerCollectionChannelsListComponent, {
|
||||
remove: {
|
||||
imports: [ChannelListItemComponent, TranslatePipe],
|
||||
},
|
||||
add: {
|
||||
imports: [
|
||||
StubChannelListItemComponent,
|
||||
MockPipe(
|
||||
TranslatePipe,
|
||||
(value: string | null | undefined) => value ?? ''
|
||||
),
|
||||
],
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(StalkerCollectionChannelsListComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.componentRef.setInput('items', [
|
||||
{
|
||||
id: '10001',
|
||||
name: 'Alpha TV',
|
||||
o_name: 'Alpha TV',
|
||||
logo: 'alpha.png',
|
||||
},
|
||||
{
|
||||
id: '10002',
|
||||
name: 'Beta TV',
|
||||
o_name: 'Beta TV',
|
||||
logo: 'beta.png',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fixture?.destroy();
|
||||
});
|
||||
|
||||
it('keeps row previews empty until bulk epg has been loaded', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.epgPrograms.size).toBe(0);
|
||||
expect(component.currentProgramsProgress.size).toBe(0);
|
||||
});
|
||||
|
||||
it('derives row previews from cached bulk epg', () => {
|
||||
bulkItvEpgByChannel.set({
|
||||
'10001': [buildProgram('10001', 'Current Show')],
|
||||
'10002': [buildProgram('10002', 'Other Show')],
|
||||
});
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.epgPrograms.get('10001')?.title).toBe('Current Show');
|
||||
expect(component.epgPrograms.get('10002')?.title).toBe('Other Show');
|
||||
});
|
||||
});
|
||||
|
||||
function buildProgram(channelId: string, title: string): EpgProgram {
|
||||
const startTimestamp = Math.floor((Date.now() - 10 * 60 * 1000) / 1000);
|
||||
const stopTimestamp = startTimestamp + 30 * 60;
|
||||
|
||||
return {
|
||||
start: new Date(startTimestamp * 1000).toISOString(),
|
||||
stop: new Date(stopTimestamp * 1000).toISOString(),
|
||||
channel: channelId,
|
||||
title,
|
||||
desc: `${title} description`,
|
||||
category: null,
|
||||
startTimestamp,
|
||||
stopTimestamp,
|
||||
};
|
||||
}
|
||||
-171
@@ -1,171 +0,0 @@
|
||||
import {
|
||||
ChangeDetectorRef,
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { ChannelListItemComponent } from '@iptvnator/ui/components';
|
||||
import { EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import { StalkerVodSource } from '@iptvnator/portal/stalker/data-access';
|
||||
import { normalizeStalkerEntityId } from '@iptvnator/portal/stalker/data-access';
|
||||
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
|
||||
|
||||
@Component({
|
||||
selector: 'app-stalker-collection-channels-list',
|
||||
imports: [ChannelListItemComponent, FormsModule, MatIconModule, TranslatePipe],
|
||||
templateUrl: './stalker-collection-channels-list.component.html',
|
||||
styleUrl: './stalker-collection-channels-list.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class StalkerCollectionChannelsListComponent {
|
||||
readonly items = input<StalkerVodSource[]>([]);
|
||||
readonly selectedItemId = input<string | number | null>(null);
|
||||
readonly favoriteIds = input<Map<string | number, boolean>>(new Map());
|
||||
|
||||
readonly playClicked = output<StalkerVodSource>();
|
||||
readonly favoriteToggled = output<StalkerVodSource>();
|
||||
|
||||
private readonly stalkerStore = inject(StalkerStore);
|
||||
private readonly cdr = inject(ChangeDetectorRef);
|
||||
protected readonly normalizeStalkerEntityId = normalizeStalkerEntityId;
|
||||
readonly searchString = signal('');
|
||||
readonly filteredItems = computed(() => {
|
||||
const search = this.searchString().trim().toLowerCase();
|
||||
if (!search) {
|
||||
return this.items();
|
||||
}
|
||||
|
||||
return this.items().filter((item) =>
|
||||
`${item.o_name ?? ''} ${item.name ?? ''}`
|
||||
.toLowerCase()
|
||||
.includes(search)
|
||||
);
|
||||
});
|
||||
|
||||
readonly epgPrograms = new Map<string | number, EpgProgram>();
|
||||
readonly currentProgramsProgress = new Map<string | number, number>();
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const items = this.items();
|
||||
const bulkProgramsByChannel = this.stalkerStore.bulkItvEpgByChannel();
|
||||
this.syncBulkEpgPreviews(items, bulkProgramsByChannel);
|
||||
});
|
||||
}
|
||||
|
||||
onPlay(item: StalkerVodSource): void {
|
||||
this.playClicked.emit(item);
|
||||
}
|
||||
|
||||
onFavoriteToggle(item: StalkerVodSource): void {
|
||||
this.favoriteToggled.emit(item);
|
||||
}
|
||||
|
||||
isSelected(item: StalkerVodSource): boolean {
|
||||
return String(this.selectedItemId() ?? '') === normalizeStalkerEntityId(item.id);
|
||||
}
|
||||
|
||||
isFavorite(item: StalkerVodSource): boolean {
|
||||
return this.favoriteIds().get(normalizeStalkerEntityId(item.id)) ?? false;
|
||||
}
|
||||
|
||||
private syncBulkEpgPreviews(
|
||||
items: StalkerVodSource[],
|
||||
bulkProgramsByChannel: Record<string, EpgProgram[]>
|
||||
): void {
|
||||
this.epgPrograms.clear();
|
||||
this.currentProgramsProgress.clear();
|
||||
|
||||
if (items.length === 0 || Object.keys(bulkProgramsByChannel).length === 0) {
|
||||
this.cdr.markForCheck();
|
||||
return;
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
const channelId = normalizeStalkerEntityId(item.id);
|
||||
const currentProgram = this.findCurrentProgram(
|
||||
bulkProgramsByChannel[channelId] ?? []
|
||||
);
|
||||
|
||||
if (!currentProgram) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.epgPrograms.set(channelId, currentProgram);
|
||||
this.updateProgramProgress(channelId, currentProgram);
|
||||
}
|
||||
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
private updateProgramProgress(
|
||||
channelId: string | number,
|
||||
program: EpgProgram
|
||||
): void {
|
||||
const startMs = this.getProgramTimestampMs(
|
||||
program.start,
|
||||
program.startTimestamp
|
||||
);
|
||||
const stopMs = this.getProgramTimestampMs(
|
||||
program.stop,
|
||||
program.stopTimestamp
|
||||
);
|
||||
const nowMs = Date.now();
|
||||
|
||||
if (
|
||||
Number.isFinite(startMs) &&
|
||||
Number.isFinite(stopMs) &&
|
||||
nowMs >= startMs &&
|
||||
nowMs <= stopMs &&
|
||||
stopMs > startMs
|
||||
) {
|
||||
this.currentProgramsProgress.set(
|
||||
channelId,
|
||||
((nowMs - startMs) / (stopMs - startMs)) * 100
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentProgramsProgress.delete(channelId);
|
||||
}
|
||||
|
||||
private findCurrentProgram(programs: EpgProgram[]): EpgProgram | null {
|
||||
const nowMs = Date.now();
|
||||
|
||||
return (
|
||||
programs.find((program) => {
|
||||
const startMs = this.getProgramTimestampMs(
|
||||
program.start,
|
||||
program.startTimestamp
|
||||
);
|
||||
const stopMs = this.getProgramTimestampMs(
|
||||
program.stop,
|
||||
program.stopTimestamp
|
||||
);
|
||||
|
||||
return nowMs >= startMs && nowMs <= stopMs;
|
||||
}) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
private getProgramTimestampMs(
|
||||
rawDate: string,
|
||||
timestamp?: number | null
|
||||
): number {
|
||||
const parsedTimestamp = Number(timestamp);
|
||||
if (Number.isFinite(parsedTimestamp) && parsedTimestamp > 0) {
|
||||
return parsedTimestamp * 1000;
|
||||
}
|
||||
|
||||
const parsedDate = Date.parse(rawDate);
|
||||
return Number.isFinite(parsedDate) ? parsedDate : Number.POSITIVE_INFINITY;
|
||||
}
|
||||
}
|
||||
+46
-19
@@ -141,6 +141,7 @@
|
||||
<div
|
||||
class="epg"
|
||||
[class.epg--inline]="canCollapseEpgPanel"
|
||||
[class.epg--list]="epgViewMode() === 'list'"
|
||||
[class.epg-collapsed]="
|
||||
canCollapseEpgPanel && isLiveEpgPanelCollapsed()
|
||||
"
|
||||
@@ -158,24 +159,50 @@
|
||||
|
||||
<ng-template #epgPanelContent>
|
||||
<div class="epg-content">
|
||||
<app-epg-timeline
|
||||
[programs]="activeEpgPrograms()"
|
||||
[channelName]="controlledChannel()?.name ?? activePlaybackTitle()"
|
||||
[channelLogo]="activePlaybackArtwork()"
|
||||
[sourceLabel]="'Stalker Portal'"
|
||||
[archivePlaybackAvailable]="false"
|
||||
[archiveDays]="0"
|
||||
[loading]="isLoadingEpg()"
|
||||
[emptyReason]="supportsEpg ? 'none' : 'provider-no-epg'"
|
||||
[selectedDate]="selectedLiveEpgDate()"
|
||||
[collapsed]="
|
||||
!!streamUrl() &&
|
||||
usesEmbeddedPlayer() &&
|
||||
isLiveEpgPanelCollapsed()
|
||||
"
|
||||
[summary]="liveEpgPanelSummary()"
|
||||
(selectedDateChange)="onLiveEpgSelectedDateChange($event)"
|
||||
(collapsedChange)="onLiveEpgPanelCollapsedChange($event)"
|
||||
/>
|
||||
@if (epgViewMode() === 'list') {
|
||||
<app-epg-list-view
|
||||
[programs]="activeEpgPrograms()"
|
||||
[channelName]="
|
||||
controlledChannel()?.name ?? activePlaybackTitle()
|
||||
"
|
||||
[channelLogo]="activePlaybackArtwork()"
|
||||
[sourceLabel]="'Stalker Portal'"
|
||||
[archivePlaybackAvailable]="false"
|
||||
[archiveDays]="0"
|
||||
[loading]="isLoadingEpg()"
|
||||
[emptyReason]="supportsEpg ? 'none' : 'provider-no-epg'"
|
||||
[selectedDate]="selectedLiveEpgDate()"
|
||||
[collapsed]="
|
||||
!!streamUrl() &&
|
||||
usesEmbeddedPlayer() &&
|
||||
isLiveEpgPanelCollapsed()
|
||||
"
|
||||
[summary]="liveEpgPanelSummary()"
|
||||
(selectedDateChange)="onLiveEpgSelectedDateChange($event)"
|
||||
(collapsedChange)="onLiveEpgPanelCollapsedChange($event)"
|
||||
/>
|
||||
} @else {
|
||||
<app-epg-timeline
|
||||
[programs]="activeEpgPrograms()"
|
||||
[channelName]="
|
||||
controlledChannel()?.name ?? activePlaybackTitle()
|
||||
"
|
||||
[channelLogo]="activePlaybackArtwork()"
|
||||
[sourceLabel]="'Stalker Portal'"
|
||||
[archivePlaybackAvailable]="false"
|
||||
[archiveDays]="0"
|
||||
[loading]="isLoadingEpg()"
|
||||
[emptyReason]="supportsEpg ? 'none' : 'provider-no-epg'"
|
||||
[selectedDate]="selectedLiveEpgDate()"
|
||||
[collapsed]="
|
||||
!!streamUrl() &&
|
||||
usesEmbeddedPlayer() &&
|
||||
isLiveEpgPanelCollapsed()
|
||||
"
|
||||
[summary]="liveEpgPanelSummary()"
|
||||
(selectedDateChange)="onLiveEpgSelectedDateChange($event)"
|
||||
(collapsedChange)="onLiveEpgPanelCollapsedChange($event)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
+33
-2
@@ -10,7 +10,10 @@ import {
|
||||
ResizableDirective,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
|
||||
import { EpgTimelineComponent } from '@iptvnator/ui/epg';
|
||||
import {
|
||||
EpgListViewComponent,
|
||||
EpgTimelineComponent,
|
||||
} from '@iptvnator/ui/epg';
|
||||
import { AudioPlayerComponent } from '@iptvnator/ui/playback';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { ChannelListItemComponent } from '@iptvnator/ui/components';
|
||||
@@ -69,8 +72,10 @@ class StubAudioPlayerComponent {
|
||||
readonly channelSwitchRequested = output<'next' | 'previous'>();
|
||||
}
|
||||
|
||||
// Matches both live-panel selectors so the host's timeline ↔ list swap can be
|
||||
// asserted by tag name; both branches share the identical contract.
|
||||
@Component({
|
||||
selector: 'app-epg-timeline',
|
||||
selector: 'app-epg-timeline, app-epg-list-view',
|
||||
standalone: true,
|
||||
template: `
|
||||
<div class="live-epg-panel-summary">{{ summary()?.title }}</div>
|
||||
@@ -230,10 +235,14 @@ describe('StalkerLiveStreamLayoutComponent', () => {
|
||||
};
|
||||
const settingsStore = {
|
||||
openStreamOnDoubleClick: signal(false),
|
||||
resolvedEpgViewMode: signal<'timeline' | 'list'>('timeline'),
|
||||
};
|
||||
const originalElectron = window.electron;
|
||||
|
||||
beforeEach(async () => {
|
||||
// The store mock is module-scoped: reset so a failed test can't leak
|
||||
// 'list' into siblings.
|
||||
settingsStore.resolvedEpgViewMode.set('timeline');
|
||||
window.electron = {
|
||||
platform: 'darwin',
|
||||
updateRemoteControlStatus: jest.fn(),
|
||||
@@ -341,6 +350,7 @@ describe('StalkerLiveStreamLayoutComponent', () => {
|
||||
imports: [
|
||||
ChannelListItemComponent,
|
||||
AudioPlayerComponent,
|
||||
EpgListViewComponent,
|
||||
EpgTimelineComponent,
|
||||
PortalEmptyStateComponent,
|
||||
ResizableDirective,
|
||||
@@ -386,6 +396,27 @@ describe('StalkerLiveStreamLayoutComponent', () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('swaps the timeline for the list view when epgViewMode is "list"', () => {
|
||||
settingsStore.resolvedEpgViewMode.set('list');
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('app-epg-list-view')
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('app-epg-timeline')
|
||||
).toBeNull();
|
||||
// Taller inline panel for the list view (see _portal-layout.scss).
|
||||
expect(
|
||||
fixture.nativeElement
|
||||
.querySelector('.epg')
|
||||
?.classList.contains('epg--list')
|
||||
).toBe(true);
|
||||
|
||||
settingsStore.resolvedEpgViewMode.set('timeline'); // restore for sibling tests
|
||||
});
|
||||
|
||||
it('does not request or render EPG in browser/PWA playback', async () => {
|
||||
fixture.destroy();
|
||||
window.electron = undefined as unknown as typeof window.electron;
|
||||
|
||||
+4
@@ -39,6 +39,7 @@ import {
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
EpgDateNavigationDirection,
|
||||
EpgListViewComponent,
|
||||
EpgTimelineComponent,
|
||||
getTodayEpgDateKey,
|
||||
shiftEpgDateKey,
|
||||
@@ -81,6 +82,7 @@ type StalkerPlayableChannel = StalkerPortalItem & {
|
||||
AudioPlayerComponent,
|
||||
ChannelListItemComponent,
|
||||
ChannelListSkeletonComponent,
|
||||
EpgListViewComponent,
|
||||
EpgTimelineComponent,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
@@ -191,6 +193,8 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
|
||||
readonly isLiveEpgPanelCollapsed = computed(
|
||||
() => this.liveEpgPanelState() === 'collapsed'
|
||||
);
|
||||
/** Live EPG panel layout chosen in settings; hosts swap timeline ↔ list. */
|
||||
readonly epgViewMode = this.settingsStore.resolvedEpgViewMode;
|
||||
readonly isSidebarCollapsed = this.liveSidebarStateService.isCollapsed;
|
||||
readonly liveEpgPanelSummary = computed(() =>
|
||||
this.toLiveEpgPanelSummary(this.currentProgram())
|
||||
|
||||
@@ -2,7 +2,6 @@ export * from './lib/account-info/account-info.component';
|
||||
export * from './lib/category-management-dialog/category-management-dialog.component';
|
||||
export * from './lib/global-search-results/global-search-results.component';
|
||||
export * from './lib/live-stream-layout/live-stream-layout.component';
|
||||
export * from './lib/loading-overlay.component';
|
||||
export * from './lib/portal-channels-list/portal-channels-list.component';
|
||||
export * from './lib/recently-added/recently-added.component';
|
||||
export * from './lib/search-results/search-results.component';
|
||||
|
||||
+42
-19
@@ -109,6 +109,7 @@
|
||||
<div
|
||||
class="epg"
|
||||
[class.epg--inline]="isEmbeddedPlayer"
|
||||
[class.epg--list]="epgViewMode() === 'list'"
|
||||
[class.epg-collapsed]="
|
||||
isEmbeddedPlayer && isLiveEpgPanelCollapsed()
|
||||
"
|
||||
@@ -172,24 +173,46 @@
|
||||
|
||||
<ng-template #epgPanelContent>
|
||||
<div class="epg-content">
|
||||
<app-epg-timeline
|
||||
[programs]="controlledEpgPrograms()"
|
||||
[channelName]="activePlayback()?.title ?? ''"
|
||||
[channelLogo]="activePlayback()?.thumbnail ?? ''"
|
||||
[sourceLabel]="'Xtream'"
|
||||
[archivePlaybackAvailable]="archivePlaybackAvailable()"
|
||||
[archiveDays]="controlledArchiveDays()"
|
||||
[activeProgram]="activeCatchupProgram()"
|
||||
[isLivePlayback]="!showReturnToLive()"
|
||||
[loading]="isLoadingEpg()"
|
||||
[selectedDate]="selectedLiveEpgDate()"
|
||||
[collapsed]="usesEmbeddedPlayer() && isLiveEpgPanelCollapsed()"
|
||||
[summary]="liveEpgPanelSummary()"
|
||||
[summaryLabelKey]="liveEpgPanelSummaryLabelKey()"
|
||||
(programActivated)="onProgramActivated($event)"
|
||||
(returnToLive)="returnToLivePlayback()"
|
||||
(selectedDateChange)="onLiveEpgSelectedDateChange($event)"
|
||||
(collapsedChange)="onLiveEpgPanelCollapsedChange($event)"
|
||||
/>
|
||||
@if (epgViewMode() === 'list') {
|
||||
<app-epg-list-view
|
||||
[programs]="controlledEpgPrograms()"
|
||||
[channelName]="activePlayback()?.title ?? ''"
|
||||
[channelLogo]="activePlayback()?.thumbnail ?? ''"
|
||||
[sourceLabel]="'Xtream'"
|
||||
[archivePlaybackAvailable]="archivePlaybackAvailable()"
|
||||
[archiveDays]="controlledArchiveDays()"
|
||||
[activeProgram]="activeCatchupProgram()"
|
||||
[isLivePlayback]="!showReturnToLive()"
|
||||
[loading]="isLoadingEpg()"
|
||||
[selectedDate]="selectedLiveEpgDate()"
|
||||
[collapsed]="usesEmbeddedPlayer() && isLiveEpgPanelCollapsed()"
|
||||
[summary]="liveEpgPanelSummary()"
|
||||
[summaryLabelKey]="liveEpgPanelSummaryLabelKey()"
|
||||
(programActivated)="onProgramActivated($event)"
|
||||
(returnToLive)="returnToLivePlayback()"
|
||||
(selectedDateChange)="onLiveEpgSelectedDateChange($event)"
|
||||
(collapsedChange)="onLiveEpgPanelCollapsedChange($event)"
|
||||
/>
|
||||
} @else {
|
||||
<app-epg-timeline
|
||||
[programs]="controlledEpgPrograms()"
|
||||
[channelName]="activePlayback()?.title ?? ''"
|
||||
[channelLogo]="activePlayback()?.thumbnail ?? ''"
|
||||
[sourceLabel]="'Xtream'"
|
||||
[archivePlaybackAvailable]="archivePlaybackAvailable()"
|
||||
[archiveDays]="controlledArchiveDays()"
|
||||
[activeProgram]="activeCatchupProgram()"
|
||||
[isLivePlayback]="!showReturnToLive()"
|
||||
[loading]="isLoadingEpg()"
|
||||
[selectedDate]="selectedLiveEpgDate()"
|
||||
[collapsed]="usesEmbeddedPlayer() && isLiveEpgPanelCollapsed()"
|
||||
[summary]="liveEpgPanelSummary()"
|
||||
[summaryLabelKey]="liveEpgPanelSummaryLabelKey()"
|
||||
(programActivated)="onProgramActivated($event)"
|
||||
(returnToLive)="returnToLivePlayback()"
|
||||
(selectedDateChange)="onLiveEpgSelectedDateChange($event)"
|
||||
(collapsedChange)="onLiveEpgPanelCollapsedChange($event)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
+31
-1
@@ -24,6 +24,7 @@ import {
|
||||
XtreamUrlService,
|
||||
} from '@iptvnator/portal/xtream/data-access';
|
||||
import {
|
||||
EpgListViewComponent,
|
||||
EpgProgramActivationEvent,
|
||||
EpgTimelineComponent,
|
||||
EpgTimelineSummary,
|
||||
@@ -83,8 +84,10 @@ class StubWebPlayerViewComponent {
|
||||
readonly externalFallbackRequested = output<unknown>();
|
||||
}
|
||||
|
||||
// Matches both live-panel selectors so the host's timeline ↔ list swap can be
|
||||
// asserted by tag name; both branches share the identical contract.
|
||||
@Component({
|
||||
selector: 'app-epg-timeline',
|
||||
selector: 'app-epg-timeline, app-epg-list-view',
|
||||
standalone: true,
|
||||
template: `
|
||||
<div class="live-epg-panel-label">{{ summaryLabelKey() }}</div>
|
||||
@@ -195,6 +198,9 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
};
|
||||
const settingsStore = {
|
||||
openStreamOnDoubleClick: signal(false),
|
||||
// Reset in beforeEach: the store is module-scoped, so a test failure
|
||||
// before an in-test restore must not leak 'list' into siblings.
|
||||
resolvedEpgViewMode: signal<'timeline' | 'list'>('timeline'),
|
||||
};
|
||||
|
||||
const originalElectron = window.electron;
|
||||
@@ -202,6 +208,7 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
beforeEach(async () => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(fixedNow);
|
||||
settingsStore.resolvedEpgViewMode.set('timeline');
|
||||
localStorage.removeItem(LIVE_CHANNEL_SORT_STORAGE_KEY);
|
||||
localStorage.removeItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY);
|
||||
localStorage.removeItem(LIVE_SIDEBAR_STATE_STORAGE_KEY);
|
||||
@@ -301,6 +308,7 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
.overrideComponent(LiveStreamLayoutComponent, {
|
||||
remove: {
|
||||
imports: [
|
||||
EpgListViewComponent,
|
||||
EpgTimelineComponent,
|
||||
GridListComponent,
|
||||
PortalChannelsListComponent,
|
||||
@@ -359,6 +367,28 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('swaps the timeline for the list view when epgViewMode is "list"', () => {
|
||||
settingsStore.resolvedEpgViewMode.set('list');
|
||||
|
||||
component.playLive(sampleChannel);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('app-epg-list-view')
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('app-epg-timeline')
|
||||
).toBeNull();
|
||||
// Taller inline panel for the list view (see _portal-layout.scss).
|
||||
expect(
|
||||
fixture.nativeElement
|
||||
.querySelector('.epg')
|
||||
?.classList.contains('epg--list')
|
||||
).toBe(true);
|
||||
|
||||
settingsStore.resolvedEpgViewMode.set('timeline'); // restore for sibling tests
|
||||
});
|
||||
|
||||
it('hides the EPG panel in browser/PWA playback', () => {
|
||||
fixture.destroy();
|
||||
window.electron = undefined as unknown as typeof window.electron;
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
} from '@iptvnator/portal/xtream/data-access';
|
||||
import {
|
||||
EpgDateNavigationDirection,
|
||||
EpgListViewComponent,
|
||||
EpgProgramActivationEvent,
|
||||
EpgTimelineComponent,
|
||||
getTodayEpgDateKey,
|
||||
@@ -91,6 +92,7 @@ interface XtreamLiveChannelItem {
|
||||
styleUrls: ['./live-stream-layout.component.scss'],
|
||||
providers: [LiveStreamAutoOpenStateService],
|
||||
imports: [
|
||||
EpgListViewComponent,
|
||||
EpgTimelineComponent,
|
||||
MatButtonModule,
|
||||
MatIcon,
|
||||
@@ -208,6 +210,8 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
|
||||
readonly isLiveEpgPanelCollapsed = computed(
|
||||
() => this.liveEpgPanelState() === 'collapsed'
|
||||
);
|
||||
/** Live EPG panel layout chosen in settings; hosts swap timeline ↔ list. */
|
||||
readonly epgViewMode = this.settingsStore.resolvedEpgViewMode;
|
||||
readonly isSidebarCollapsed = this.liveSidebarStateService.isCollapsed;
|
||||
readonly liveEpgPanelSummary = computed(() =>
|
||||
this.toLiveEpgPanelSummary(
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
|
||||
@Component({
|
||||
selector: 'app-loading-overlay',
|
||||
imports: [MatProgressBarModule],
|
||||
template: `
|
||||
<div class="overlay">
|
||||
<div class="progress-container">
|
||||
<h3>Loading playlist...</h3>
|
||||
@if (current() !== 0 && total() !== 0) {
|
||||
<mat-progress-bar
|
||||
mode="determinate"
|
||||
[value]="(current() / total()) * 100"
|
||||
/>
|
||||
<p>{{ current() }} / {{ total() }}</p>
|
||||
} @else {
|
||||
<mat-progress-bar mode="indeterminate" />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
padding: 2rem;
|
||||
border-radius: 8px;
|
||||
min-width: 300px;
|
||||
text-align: center;
|
||||
|
||||
h3 {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class LoadingOverlayComponent {
|
||||
current = input(0);
|
||||
total = input(0);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { computed, inject } from '@angular/core';
|
||||
import {
|
||||
patchState,
|
||||
signalStore,
|
||||
withComputed,
|
||||
withHooks,
|
||||
withMethods,
|
||||
withState,
|
||||
@@ -11,6 +12,7 @@ import { firstValueFrom } from 'rxjs';
|
||||
import {
|
||||
DEFAULT_DASHBOARD_RAILS_SETTINGS,
|
||||
ElectronBridgeTrustOptions,
|
||||
EpgViewMode,
|
||||
Language,
|
||||
Settings,
|
||||
StartupBehavior,
|
||||
@@ -43,6 +45,7 @@ const DEFAULT_SETTINGS: Settings = {
|
||||
downloadFolder: '',
|
||||
recordingFolder: '',
|
||||
coverSize: 'medium',
|
||||
epgViewMode: 'timeline',
|
||||
dashboardRails: DEFAULT_DASHBOARD_RAILS_SETTINGS,
|
||||
preferUploadedEpgOverXtream: false,
|
||||
trustedPrivateNetworkEpgUrls: [],
|
||||
@@ -91,6 +94,16 @@ function scheduleEmbeddedMpvPrepare(): void {
|
||||
export const SettingsStore = signalStore(
|
||||
{ providedIn: 'root' },
|
||||
withState<Settings>(DEFAULT_SETTINGS),
|
||||
withComputed((store) => ({
|
||||
/**
|
||||
* Live EPG panel layout with the `'timeline'` default applied — the
|
||||
* single source of truth for the four live hosts, so the fallback is
|
||||
* not duplicated per call-site.
|
||||
*/
|
||||
resolvedEpgViewMode: computed<EpgViewMode>(
|
||||
() => store.epgViewMode?.() ?? 'timeline'
|
||||
),
|
||||
})),
|
||||
withMethods((store, storage = inject(StorageMap)) => ({
|
||||
async loadSettings() {
|
||||
try {
|
||||
@@ -173,6 +186,8 @@ export const SettingsStore = signalStore(
|
||||
store.recordingFolder?.() ??
|
||||
DEFAULT_SETTINGS.recordingFolder,
|
||||
coverSize: store.coverSize?.() ?? DEFAULT_SETTINGS.coverSize,
|
||||
epgViewMode:
|
||||
store.epgViewMode?.() ?? DEFAULT_SETTINGS.epgViewMode,
|
||||
dashboardRails: normalizeDashboardRailsSettings(
|
||||
store.dashboardRails?.()
|
||||
),
|
||||
|
||||
@@ -21,6 +21,9 @@ export enum StartupBehavior {
|
||||
|
||||
export type CoverSize = 'small' | 'medium' | 'large';
|
||||
|
||||
/** Rendering of the live EPG panel under the player. */
|
||||
export type EpgViewMode = 'timeline' | 'list';
|
||||
|
||||
export interface DashboardRailsSettings {
|
||||
hero: boolean;
|
||||
continueWatching: boolean;
|
||||
@@ -103,6 +106,8 @@ export interface Settings {
|
||||
recordingFolder?: string;
|
||||
/** Cover/poster sizing preset applied across grids and rails */
|
||||
coverSize?: CoverSize;
|
||||
/** Live EPG panel layout: horizontal timeline (default) or vertical list */
|
||||
epgViewMode?: EpgViewMode;
|
||||
/** Per-rail dashboard visibility preferences. Missing keys default on. */
|
||||
dashboardRails?: DashboardRailsSettings;
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
export * from './lib/epg-list/epg-list.component';
|
||||
export * from './lib/epg-list/epg-item-description/epg-item-description.component';
|
||||
export * from './lib/epg-item-description/epg-item-description.component';
|
||||
export * from './lib/epg-program-activation-event';
|
||||
export * from './lib/epg-list-view/epg-list-view.component';
|
||||
export * from './lib/epg-list-view/epg-list-view-row/epg-list-view-row.component';
|
||||
export * from './lib/epg-list-view/epg-list-view.utils';
|
||||
export * from './lib/epg-date';
|
||||
export * from './lib/epg-timeline/epg-timeline.component';
|
||||
export * from './lib/epg-timeline/epg-timeline-empty-state.component';
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,218 @@
|
||||
import { EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import { EpgListScrollController } from './epg-list-scroll.controller';
|
||||
|
||||
function programAt(
|
||||
startOffsetMin: number,
|
||||
durationMin: number,
|
||||
channel = 'ch'
|
||||
): EpgProgram {
|
||||
const start = new Date(Date.now() + startOffsetMin * 60_000);
|
||||
const stop = new Date(start.getTime() + durationMin * 60_000);
|
||||
return {
|
||||
start: start.toISOString(),
|
||||
stop: stop.toISOString(),
|
||||
channel,
|
||||
title: 'P',
|
||||
desc: null,
|
||||
category: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('EpgListScrollController (channel-select auto-scroll)', () => {
|
||||
let controller: EpgListScrollController;
|
||||
let scrollSpy: jest.SpyInstance;
|
||||
let rafSpy: jest.SpyInstance;
|
||||
let hasProgramsToday: jest.Mock;
|
||||
let commitToday: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
rafSpy = jest
|
||||
.spyOn(window, 'requestAnimationFrame')
|
||||
.mockImplementation((cb: FrameRequestCallback) => {
|
||||
cb(0);
|
||||
return 1;
|
||||
});
|
||||
hasProgramsToday = jest.fn(() => true);
|
||||
commitToday = jest.fn();
|
||||
controller = new EpgListScrollController({
|
||||
list: () => undefined,
|
||||
isViewToday: () => true,
|
||||
setNowStripVisible: () => undefined,
|
||||
hasProgramsToday,
|
||||
commitToday,
|
||||
});
|
||||
scrollSpy = jest
|
||||
.spyOn(controller, 'scrollNowIntoView')
|
||||
.mockImplementation(() => undefined);
|
||||
jest.spyOn(controller, 'focusNowAfterRender');
|
||||
jest.spyOn(controller, 'updateNowStrip').mockImplementation(
|
||||
() => undefined
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rafSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('scrolls the now row into view instantly on first load', () => {
|
||||
controller.maybeAutoScroll(
|
||||
{} as HTMLElement,
|
||||
[programAt(0, 120)],
|
||||
true,
|
||||
'ch'
|
||||
);
|
||||
|
||||
expect(scrollSpy).toHaveBeenCalledTimes(1);
|
||||
expect(scrollSpy).toHaveBeenCalledWith(false); // instant, no animation
|
||||
});
|
||||
|
||||
it('does not re-scroll while the same set stays loaded (now-tick / rollover)', () => {
|
||||
// The 30s tick reclassifies past/now/future at every programme
|
||||
// boundary, but the programme SET is unchanged — the viewport must
|
||||
// stay put.
|
||||
const list = {} as HTMLElement;
|
||||
const programs = [programAt(-30, 60), programAt(30, 60)];
|
||||
controller.maybeAutoScroll(list, programs, true, 'ch');
|
||||
controller.maybeAutoScroll(list, programs, true, 'ch');
|
||||
|
||||
expect(scrollSpy).toHaveBeenCalledTimes(1);
|
||||
// The dedup path still refreshes the now-strip — layout can change
|
||||
// without scroll events (e.g. the panel grew and nothing scrolls).
|
||||
expect(controller.updateNowStrip).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('restores the now row when the same channel list remounts (collapse then expand)', () => {
|
||||
const programs = [programAt(0, 120)];
|
||||
controller.maybeAutoScroll({} as HTMLElement, programs, true, 'ch'); // mount
|
||||
controller.maybeAutoScroll(undefined, programs, true, 'ch'); // collapsed
|
||||
controller.maybeAutoScroll({} as HTMLElement, programs, true, 'ch'); // expand
|
||||
|
||||
expect(scrollSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('re-scrolls when the channel changes', () => {
|
||||
const list = {} as HTMLElement;
|
||||
controller.maybeAutoScroll(list, [programAt(0, 120, 'a')], true, 'a');
|
||||
controller.maybeAutoScroll(list, [programAt(0, 120, 'b')], true, 'b');
|
||||
|
||||
expect(scrollSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('returns to today when a new channel arrives while parked on another day', () => {
|
||||
// Channel switch while the user navigated to yesterday: the new set
|
||||
// must reset the view to today (when today has data) — otherwise the
|
||||
// new channel opens on the stale day (timeline parity).
|
||||
controller.maybeAutoScroll(
|
||||
{} as HTMLElement,
|
||||
[programAt(0, 120, 'b')],
|
||||
false,
|
||||
'b'
|
||||
);
|
||||
|
||||
expect(commitToday).toHaveBeenCalledTimes(1);
|
||||
expect(scrollSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('leaves day navigation alone while the set is unchanged', () => {
|
||||
// Same channel, user steps to yesterday: same set key → no snap back.
|
||||
const list = {} as HTMLElement;
|
||||
const programs = [programAt(0, 120)];
|
||||
controller.maybeAutoScroll(list, programs, true, 'ch');
|
||||
controller.maybeAutoScroll(list, programs, false, 'ch');
|
||||
|
||||
expect(commitToday).not.toHaveBeenCalled();
|
||||
expect(scrollSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not take over when the new set has nothing airing today', () => {
|
||||
hasProgramsToday.mockReturnValue(false);
|
||||
const programs = [programAt(3 * 1440, 60)];
|
||||
controller.maybeAutoScroll({} as HTMLElement, programs, false, 'ch');
|
||||
|
||||
expect(commitToday).not.toHaveBeenCalled();
|
||||
expect(scrollSpy).not.toHaveBeenCalled();
|
||||
|
||||
// The key was not stored — a later, fuller load retries the focus.
|
||||
hasProgramsToday.mockReturnValue(true);
|
||||
controller.maybeAutoScroll({} as HTMLElement, programs, true, 'ch');
|
||||
expect(scrollSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does nothing for an empty programme set', () => {
|
||||
controller.maybeAutoScroll({} as HTMLElement, [], true, 'ch');
|
||||
|
||||
expect(scrollSpy).not.toHaveBeenCalled();
|
||||
expect(commitToday).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('EpgListScrollController (now-strip visibility)', () => {
|
||||
function stripStateFor(list: Partial<HTMLElement>): boolean | null {
|
||||
let visible: boolean | null = null;
|
||||
const controller = new EpgListScrollController({
|
||||
list: () => list as HTMLElement,
|
||||
isViewToday: () => true,
|
||||
setNowStripVisible: (value) => (visible = value),
|
||||
hasProgramsToday: () => true,
|
||||
commitToday: () => undefined,
|
||||
});
|
||||
controller.updateNowStrip();
|
||||
return visible;
|
||||
}
|
||||
|
||||
/** Row 500–560px below the list's viewport top (out of a 0–200 view). */
|
||||
const offscreenRow = {
|
||||
getBoundingClientRect: () => ({ top: 500, bottom: 560, height: 60 }),
|
||||
};
|
||||
|
||||
it('hides the strip when the list does not scroll (nothing can be scrolled away)', () => {
|
||||
expect(
|
||||
stripStateFor({
|
||||
scrollHeight: 200,
|
||||
clientHeight: 200,
|
||||
getBoundingClientRect: () => ({ top: 0, bottom: 200 }),
|
||||
querySelector: () => offscreenRow,
|
||||
} as unknown as HTMLElement)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('shows the strip when the on-air row is scrolled out of view', () => {
|
||||
expect(
|
||||
stripStateFor({
|
||||
scrollHeight: 600,
|
||||
clientHeight: 200,
|
||||
getBoundingClientRect: () => ({ top: 0, bottom: 200 }),
|
||||
querySelector: () => offscreenRow,
|
||||
} as unknown as HTMLElement)
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('EpgListScrollController (scroll target maths)', () => {
|
||||
it('scrolls relative to the list scroller, not the offset parent', () => {
|
||||
// Regression: the rows have no positioned ancestor inside the list, so
|
||||
// offsetTop-based maths measured against a far ancestor (including the
|
||||
// player above) and scrolled to a wildly wrong position.
|
||||
const scrollTo = jest.fn();
|
||||
const list = {
|
||||
scrollTop: 100,
|
||||
scrollTo,
|
||||
getBoundingClientRect: () => ({ top: 50 }),
|
||||
querySelector: () => ({
|
||||
getBoundingClientRect: () => ({ top: 350 }),
|
||||
}),
|
||||
} as unknown as HTMLElement;
|
||||
const controller = new EpgListScrollController({
|
||||
list: () => list,
|
||||
isViewToday: () => true,
|
||||
setNowStripVisible: () => undefined,
|
||||
hasProgramsToday: () => true,
|
||||
commitToday: () => undefined,
|
||||
});
|
||||
|
||||
controller.scrollNowIntoView(false);
|
||||
|
||||
// 100 (current scroll) + (350 − 50) (row offset within view) − 8
|
||||
expect(scrollTo).toHaveBeenCalledWith({ top: 392, behavior: 'auto' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import { EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import { programsFocusKey } from '../epg-timeline/epg-timeline-scroll.controller';
|
||||
|
||||
export interface EpgListScrollDeps {
|
||||
/** The scrollable `.g-list` element (undefined before first render). */
|
||||
readonly list: () => HTMLElement | undefined;
|
||||
/** Whether the viewed day is today (auto-focus/now-strip are today-only). */
|
||||
readonly isViewToday: () => boolean;
|
||||
/** Toggle the sticky now-strip's visibility. */
|
||||
readonly setNowStripVisible: (visible: boolean) => void;
|
||||
/** Whether the loaded window has any programme airing today. */
|
||||
readonly hasProgramsToday: () => boolean;
|
||||
/** Commit today as the viewed day (emits `selectedDateChange`). */
|
||||
readonly commitToday: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vertical-list scroll behaviour extracted from the component: auto-focus the
|
||||
* on-air row when a channel's EPG (re)loads, keep the sticky "now" strip in sync
|
||||
* with scroll position, and reset scroll on day changes. The vertical analogue
|
||||
* of the timeline's `TimelineScrollController`.
|
||||
*/
|
||||
export class EpgListScrollController {
|
||||
private autoScrollKey: string | null = null;
|
||||
private lastList: HTMLElement | null = null;
|
||||
|
||||
constructor(private readonly deps: EpgListScrollDeps) {}
|
||||
|
||||
/**
|
||||
* Scroll the on-air row into view once per channel/EPG (re)load. Keyed by
|
||||
* the FULL programme-set identity (`programsFocusKey`, like the timeline) —
|
||||
* stable across day navigation, 30s now-ticks, and programme rollovers, so
|
||||
* the viewport is never yanked out from under the user. A *new* list
|
||||
* element under an unchanged key means the body was unmounted and
|
||||
* remounted (the inline panel was collapsed and re-expanded), which resets
|
||||
* scrollTop to 0 — restore the now-row instead of stranding the user at
|
||||
* the top of the day.
|
||||
*
|
||||
* When a NEW programme set arrives while the user is parked on another day
|
||||
* (a channel switch), commit today first — otherwise the new channel opens
|
||||
* on the stale day, possibly with nothing to show (timeline parity).
|
||||
*/
|
||||
maybeAutoScroll(
|
||||
list: HTMLElement | undefined,
|
||||
programs: readonly EpgProgram[],
|
||||
today: boolean,
|
||||
channel: string
|
||||
): void {
|
||||
const setKey = programsFocusKey(programs);
|
||||
if (!setKey) {
|
||||
return;
|
||||
}
|
||||
const key = `${channel}|${setKey}`;
|
||||
if (key === this.autoScrollKey) {
|
||||
if (!list) {
|
||||
return;
|
||||
}
|
||||
if (list !== this.lastList) {
|
||||
this.lastList = list;
|
||||
this.focusNowAfterRender();
|
||||
} else {
|
||||
// No scroll — but keep the now-strip honest: layout can change
|
||||
// without scroll events (panel resize, rows re-render), and a
|
||||
// non-scrollable list never fires scroll to self-correct.
|
||||
this.updateNowStrip();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// New programme set. Only take over when today actually has
|
||||
// programmes; otherwise leave the user's day navigation alone (and
|
||||
// don't store the key, so a later fuller load retries).
|
||||
if (!this.deps.hasProgramsToday()) {
|
||||
return;
|
||||
}
|
||||
this.autoScrollKey = key;
|
||||
this.lastList = list ?? null;
|
||||
if (!today) {
|
||||
this.deps.commitToday();
|
||||
}
|
||||
this.focusNowAfterRender();
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll to the now-row after the next render. Public for the toolbar
|
||||
* "Now" jump: committing today re-renders the rows, so a synchronous DOM
|
||||
* query would still see the previous day's rows and find no now-row —
|
||||
* the deferral lets change detection paint today first.
|
||||
*/
|
||||
focusNowAfterRender(animate = false): void {
|
||||
requestAnimationFrame(() =>
|
||||
requestAnimationFrame(() => {
|
||||
this.scrollNowIntoView(animate);
|
||||
this.updateNowStrip();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
scrollNowIntoView(animate: boolean): void {
|
||||
const list = this.deps.list();
|
||||
const nowRow = this.nowRowElement(list);
|
||||
if (list && nowRow) {
|
||||
// Rect-based, relative to the scroller itself: `offsetTop` is
|
||||
// measured against the nearest *positioned* ancestor, which is
|
||||
// outside the list (the rows/list are not positioned), so it would
|
||||
// include the player above and scroll to a wildly wrong spot.
|
||||
const listRect = list.getBoundingClientRect();
|
||||
const rowRect = nowRow.getBoundingClientRect();
|
||||
const top = list.scrollTop + (rowRect.top - listRect.top) - 8;
|
||||
list.scrollTo({
|
||||
top: Math.max(0, top),
|
||||
behavior: animate ? 'smooth' : 'auto',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
resetListScroll(): void {
|
||||
const list = this.deps.list();
|
||||
if (list) {
|
||||
list.scrollTop = 0;
|
||||
}
|
||||
this.deps.setNowStripVisible(false);
|
||||
}
|
||||
|
||||
/** Show the condensed now-strip only when the on-air row is scrolled away. */
|
||||
updateNowStrip(): void {
|
||||
const list = this.deps.list();
|
||||
const nowRow = this.nowRowElement(list);
|
||||
if (!list || !nowRow || !this.deps.isViewToday()) {
|
||||
this.deps.setNowStripVisible(false);
|
||||
return;
|
||||
}
|
||||
// A list that doesn't scroll can't have scrolled the row away — and it
|
||||
// also never fires scroll events to clear a stale strip.
|
||||
if (list.scrollHeight <= list.clientHeight) {
|
||||
this.deps.setNowStripVisible(false);
|
||||
return;
|
||||
}
|
||||
// Rect-based for the same reason as scrollNowIntoView.
|
||||
const listRect = list.getBoundingClientRect();
|
||||
const rowRect = nowRow.getBoundingClientRect();
|
||||
if (rowRect.height <= 0) {
|
||||
this.deps.setNowStripVisible(false);
|
||||
return;
|
||||
}
|
||||
const visible =
|
||||
Math.min(rowRect.bottom, listRect.bottom) -
|
||||
Math.max(rowRect.top, listRect.top);
|
||||
this.deps.setNowStripVisible(visible / rowRect.height < 0.5);
|
||||
}
|
||||
|
||||
private nowRowElement(
|
||||
list: HTMLElement | undefined
|
||||
): HTMLElement | null {
|
||||
return (
|
||||
list?.querySelector<HTMLElement>('.g-row[data-when="now"]') ?? null
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
@let r = row();
|
||||
|
||||
<div class="time">
|
||||
<span class="time-range"
|
||||
>{{ r.startMs | date: 'HH:mm' }} – {{ r.stopMs | date: 'HH:mm' }}</span
|
||||
>
|
||||
@if (r.when === 'now') {
|
||||
<span class="time-now">{{ 'EPG.TIMELINE.ON_NOW' | translate }}</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="body">
|
||||
<div class="title">{{ r.program.title }}</div>
|
||||
@if (r.program.desc) {
|
||||
<div class="desc">{{ r.program.desc }}</div>
|
||||
}
|
||||
|
||||
@if (r.when === 'now' && r.progress !== null) {
|
||||
<div class="g-now-bar" aria-hidden="true">
|
||||
<i [style.width.%]="r.progress"></i>
|
||||
</div>
|
||||
}
|
||||
@if (isPlaying()) {
|
||||
<div class="playtag">
|
||||
<mat-icon>play_arrow</mat-icon>
|
||||
{{ 'EPG.TIMELINE.FROM_ARCHIVE' | translate }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="aux">
|
||||
@if (r.when === 'now') {
|
||||
@if (minutesLeft() !== null) {
|
||||
<span class="nowtag"
|
||||
>{{ minutesLeft() }}
|
||||
{{ 'EPG.TIMELINE.MINUTES_LEFT' | translate }}</span
|
||||
>
|
||||
}
|
||||
} @else if (r.canCatchUp) {
|
||||
<button
|
||||
type="button"
|
||||
class="watch"
|
||||
(click)="onWatch($event)"
|
||||
[attr.aria-label]="'EPG.TIMELINE.WATCH_FROM_START' | translate"
|
||||
>
|
||||
<mat-icon>replay</mat-icon>
|
||||
{{ 'EPG.TIMELINE.WATCH' | translate }}
|
||||
</button>
|
||||
}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="icbtn"
|
||||
(click)="onInfo($event)"
|
||||
[attr.aria-label]="
|
||||
'EPG.PROGRAM_DIALOG.SHOW_PROGRAM_DETAILS' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>info_outline</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
@@ -0,0 +1,224 @@
|
||||
$line-strong: var(--line-strong, rgba(255, 255, 255, 0.1));
|
||||
$surface-1: var(--surface-1, #0f141c);
|
||||
$surface-2: var(--surface-2, #161d28);
|
||||
$surface-3: var(--surface-3, #1d2533);
|
||||
$text-primary: var(--text-primary, #e7ecf3);
|
||||
$text-secondary: var(--text-secondary, #9aa3b2);
|
||||
$text-tertiary: var(--text-tertiary, #6b7384);
|
||||
$accent-blue: var(--accent-blue, #4f8eff);
|
||||
$accent-cyan: var(--accent-cyan, #5cd6ff);
|
||||
$accent-live: var(--accent-live, #ff5b6e);
|
||||
$font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
|
||||
|
||||
:host {
|
||||
box-sizing: border-box;
|
||||
display: grid;
|
||||
grid-template-columns: 92px 1fr auto;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
padding: 11px 18px 11px 22px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
border-left: 3px solid transparent;
|
||||
transition: background 120ms ease;
|
||||
outline: none;
|
||||
}
|
||||
:host *,
|
||||
:host *::before {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
:host(:hover) {
|
||||
background: $surface-1;
|
||||
}
|
||||
|
||||
.time {
|
||||
font-family: $font-mono;
|
||||
font-size: 12px;
|
||||
color: $text-tertiary;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.time-now {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin-top: 7px;
|
||||
font-family: $font-mono;
|
||||
font-size: 9.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: $accent-live;
|
||||
}
|
||||
.time-now::before {
|
||||
content: '';
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: $accent-live;
|
||||
box-shadow: 0 0 6px $accent-live;
|
||||
}
|
||||
|
||||
.body {
|
||||
min-width: 0;
|
||||
}
|
||||
.title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: $text-primary;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.desc {
|
||||
font-size: 12.5px;
|
||||
color: $text-tertiary;
|
||||
margin-top: 3px;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 54ch;
|
||||
}
|
||||
.g-now-bar {
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
margin-top: 8px;
|
||||
max-width: 320px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.g-now-bar > i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: $accent-cyan;
|
||||
}
|
||||
.playtag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin-top: 8px;
|
||||
font-family: $font-mono;
|
||||
font-size: 9.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: $accent-blue;
|
||||
}
|
||||
.playtag mat-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.aux {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.nowtag {
|
||||
font-size: 12px;
|
||||
color: $text-tertiary;
|
||||
padding-right: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.icbtn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 8px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: $text-secondary;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 120ms,
|
||||
background 120ms,
|
||||
color 120ms;
|
||||
}
|
||||
.icbtn mat-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
font-size: 18px;
|
||||
}
|
||||
:host(:hover) .icbtn,
|
||||
:host(:focus-within) .icbtn {
|
||||
opacity: 1;
|
||||
}
|
||||
.icbtn:hover {
|
||||
background: $surface-3;
|
||||
color: $text-primary;
|
||||
}
|
||||
.watch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
height: 30px;
|
||||
padding: 0 13px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid $line-strong;
|
||||
background: $surface-2;
|
||||
color: $text-primary;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.watch mat-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.watch:hover {
|
||||
border-color: $accent-blue;
|
||||
color: $accent-blue;
|
||||
}
|
||||
|
||||
/* ── PAST — dimmed ── */
|
||||
:host([data-when='past']) .time,
|
||||
:host([data-when='past']) .title {
|
||||
color: $text-secondary;
|
||||
}
|
||||
:host([data-when='past']) .title {
|
||||
font-weight: 400;
|
||||
}
|
||||
:host([data-when='past']:hover) .time,
|
||||
:host([data-when='past']:hover) .title {
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
/* ── NOW — accent bar + fill ── */
|
||||
:host([data-when='now']) {
|
||||
align-items: start;
|
||||
background: rgba(79, 142, 255, 0.08);
|
||||
border-left-color: #4f8eff;
|
||||
}
|
||||
:host([data-when='now']) .time {
|
||||
color: $accent-blue;
|
||||
}
|
||||
:host([data-when='now']) .title {
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
:host([data-when='now']) .desc {
|
||||
color: $text-secondary;
|
||||
}
|
||||
|
||||
/* ── PLAYING from archive — distinct from now ── */
|
||||
:host(.playing) {
|
||||
background: rgba(79, 142, 255, 0.13);
|
||||
border-left-color: #4f8eff;
|
||||
}
|
||||
:host(.playing) .title {
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
:host(.playing) .watch {
|
||||
border-color: $accent-blue;
|
||||
background: rgba(79, 142, 255, 0.18);
|
||||
color: $accent-blue;
|
||||
}
|
||||
|
||||
/* ── SELECTED (focus) — ring only, independent of temporal/playing state ── */
|
||||
:host(.sel) {
|
||||
box-shadow: inset 0 0 0 1px rgba(79, 142, 255, 0.55);
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import { EpgListRow } from '../epg-list-view.utils';
|
||||
import { EpgListViewRowComponent } from './epg-list-view-row.component';
|
||||
|
||||
function program(title = 'P', desc: string | null = null): EpgProgram {
|
||||
return {
|
||||
start: new Date().toISOString(),
|
||||
stop: new Date().toISOString(),
|
||||
channel: 'ch',
|
||||
title,
|
||||
desc,
|
||||
category: null,
|
||||
};
|
||||
}
|
||||
|
||||
function row(overrides: Partial<EpgListRow> = {}): EpgListRow {
|
||||
return {
|
||||
program: program(),
|
||||
key: 'k',
|
||||
startMs: Date.now(),
|
||||
stopMs: Date.now() + 60_000,
|
||||
when: 'future',
|
||||
progress: null,
|
||||
isActive: false,
|
||||
canCatchUp: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('EpgListViewRowComponent', () => {
|
||||
let fixture: ComponentFixture<EpgListViewRowComponent>;
|
||||
let component: EpgListViewRowComponent;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [EpgListViewRowComponent, TranslateModule.forRoot()],
|
||||
});
|
||||
fixture = TestBed.createComponent(EpgListViewRowComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it('marks an active past programme as playing (archive), not a now row', () => {
|
||||
fixture.componentRef.setInput('row', row({ when: 'past', isActive: true }));
|
||||
expect(component.isPlaying()).toBe(true);
|
||||
|
||||
fixture.componentRef.setInput('row', row({ when: 'now', isActive: true }));
|
||||
expect(component.isPlaying()).toBe(false);
|
||||
});
|
||||
|
||||
it('computes minutes left for a now row from the tick', () => {
|
||||
const stopMs = Date.now() + 25 * 60_000;
|
||||
fixture.componentRef.setInput('row', row({ when: 'now', stopMs }));
|
||||
fixture.componentRef.setInput('nowMs', Date.now());
|
||||
expect(component.minutesLeft()).toBeGreaterThanOrEqual(24);
|
||||
expect(component.minutesLeft()).toBeLessThanOrEqual(25);
|
||||
});
|
||||
|
||||
it('activates on keyboard only when the row itself is the target', () => {
|
||||
fixture.componentRef.setInput('row', row());
|
||||
const events: string[] = [];
|
||||
component.activate.subscribe(() => events.push('activate'));
|
||||
|
||||
const host = fixture.nativeElement as HTMLElement;
|
||||
const nested = document.createElement('button');
|
||||
|
||||
// Enter bubbling up from a nested watch/info button must NOT activate
|
||||
// the row (and must not preventDefault, which would suppress the
|
||||
// button's own native click).
|
||||
const fromButton = {
|
||||
target: nested,
|
||||
currentTarget: host,
|
||||
preventDefault: jest.fn(),
|
||||
} as unknown as Event;
|
||||
component.onKeydown(fromButton);
|
||||
expect(events).toEqual([]);
|
||||
expect(
|
||||
(fromButton as unknown as { preventDefault: jest.Mock })
|
||||
.preventDefault
|
||||
).not.toHaveBeenCalled();
|
||||
|
||||
// Enter on the focused row itself activates it.
|
||||
const fromRow = {
|
||||
target: host,
|
||||
currentTarget: host,
|
||||
preventDefault: jest.fn(),
|
||||
} as unknown as Event;
|
||||
component.onKeydown(fromRow);
|
||||
expect(events).toEqual(['activate']);
|
||||
});
|
||||
|
||||
it('emits activate on row click and stops propagation on watch/info', () => {
|
||||
fixture.componentRef.setInput('row', row());
|
||||
const events: string[] = [];
|
||||
component.activate.subscribe(() => events.push('activate'));
|
||||
component.watch.subscribe(() => events.push('watch'));
|
||||
component.info.subscribe(() => events.push('info'));
|
||||
|
||||
component.onRowClick();
|
||||
const stop = jest.fn();
|
||||
component.onWatch({ stopPropagation: stop } as unknown as Event);
|
||||
component.onInfo({ stopPropagation: stop } as unknown as Event);
|
||||
|
||||
expect(events).toEqual(['activate', 'watch', 'info']);
|
||||
expect(stop).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('renders the temporal state and catch-up Watch affordance', () => {
|
||||
fixture.componentRef.setInput(
|
||||
'row',
|
||||
row({ when: 'past', canCatchUp: true })
|
||||
);
|
||||
fixture.detectChanges();
|
||||
const host = fixture.nativeElement as HTMLElement;
|
||||
expect(host.getAttribute('data-when')).toBe('past');
|
||||
expect(host.querySelector('.watch')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('renders the live progress bar for a now row and hides Watch', () => {
|
||||
fixture.componentRef.setInput(
|
||||
'row',
|
||||
row({ when: 'now', progress: 40, canCatchUp: false })
|
||||
);
|
||||
fixture.detectChanges();
|
||||
const host = fixture.nativeElement as HTMLElement;
|
||||
expect(host.getAttribute('data-when')).toBe('now');
|
||||
expect(host.querySelector('.g-now-bar')).not.toBeNull();
|
||||
expect(host.querySelector('.watch')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { DatePipe } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { EpgListRow } from '../epg-list-view.utils';
|
||||
|
||||
/**
|
||||
* One programme row in the vertical EPG list. Dumb/presentational: it renders
|
||||
* the precomputed `EpgListRow` and emits semantic intents; the list component
|
||||
* owns activation policy (live vs timeshift vs open-details). Mirrors the
|
||||
* mockup's `.g-row[data-when]` template.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-epg-list-view-row',
|
||||
templateUrl: './epg-list-view-row.component.html',
|
||||
styleUrl: './epg-list-view-row.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [DatePipe, MatIcon, TranslatePipe],
|
||||
host: {
|
||||
class: 'g-row',
|
||||
role: 'button',
|
||||
tabindex: '0',
|
||||
'[attr.data-when]': 'row().when',
|
||||
'[class.sel]': 'selected()',
|
||||
'[class.playing]': 'isPlaying()',
|
||||
'(click)': 'onRowClick()',
|
||||
'(keydown.enter)': 'onKeydown($event)',
|
||||
'(keydown.space)': 'onKeydown($event)',
|
||||
},
|
||||
})
|
||||
export class EpgListViewRowComponent {
|
||||
readonly row = input.required<EpgListRow>();
|
||||
readonly selected = input(false);
|
||||
readonly currentLocale = input('en');
|
||||
/** Current wall-clock ms from the list's 30s tick (drives minutes-left). */
|
||||
readonly nowMs = input(0);
|
||||
|
||||
readonly activate = output<void>();
|
||||
readonly watch = output<void>();
|
||||
readonly info = output<void>();
|
||||
|
||||
/** Archive-playback highlight: the active programme is a past one. */
|
||||
readonly isPlaying = computed(() => {
|
||||
const row = this.row();
|
||||
return row.isActive && row.when === 'past';
|
||||
});
|
||||
|
||||
/** Minutes remaining on the on-air programme (for the `now` row tag). */
|
||||
readonly minutesLeft = computed(() => {
|
||||
const row = this.row();
|
||||
if (row.when !== 'now') {
|
||||
return null;
|
||||
}
|
||||
return Math.max(0, Math.round((row.stopMs - this.nowMs()) / 60_000));
|
||||
});
|
||||
|
||||
onRowClick(): void {
|
||||
this.activate.emit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Keyboard Enter/Space activates the row only when the event target is the
|
||||
* row itself — keydown from the nested watch/info buttons bubbles up here,
|
||||
* and hijacking it would swap the button's action for row activation (the
|
||||
* `preventDefault()` would also suppress the button's native click).
|
||||
*/
|
||||
onKeydown(event: Event): void {
|
||||
if (event.target !== event.currentTarget) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
this.activate.emit();
|
||||
}
|
||||
|
||||
onWatch(event: Event): void {
|
||||
event.stopPropagation();
|
||||
this.watch.emit();
|
||||
}
|
||||
|
||||
onInfo(event: Event): void {
|
||||
event.stopPropagation();
|
||||
this.info.emit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
@let state = renderState();
|
||||
|
||||
<div
|
||||
class="guide"
|
||||
[class.guide--no-archive]="!archivePlaybackAvailable()"
|
||||
[class.is-collapsed]="collapsed()"
|
||||
>
|
||||
<!-- toolbar: collapse + channel on the left, live/jump/stepper on the right -->
|
||||
<div class="g-head">
|
||||
<button
|
||||
type="button"
|
||||
class="g-collapse"
|
||||
(click)="toggleCollapsed()"
|
||||
[attr.aria-expanded]="!collapsed()"
|
||||
[attr.aria-label]="
|
||||
(collapsed() ? 'EPG.EXPAND_PANEL' : 'EPG.COLLAPSE_PANEL')
|
||||
| translate
|
||||
"
|
||||
>
|
||||
<span class="cc"><mat-icon>keyboard_arrow_down</mat-icon></span>
|
||||
<span class="gt">
|
||||
<b>{{ channelName() }}</b>
|
||||
<span class="src">{{ sourceLabel() }}</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<span class="g-spacer"></span>
|
||||
|
||||
@if (collapsed()) {
|
||||
<!-- collapsed inline summary lives IN the toolbar row: hosts clamp
|
||||
the collapsed panel to 56px, so a block below it would be
|
||||
clipped (mirrors the timeline's collapsed layout). -->
|
||||
@if (loading()) {
|
||||
<span class="cn-title cn-title--muted">{{
|
||||
'EPG.LOADING' | translate
|
||||
}}</span>
|
||||
} @else if (hasSummary()) {
|
||||
@if (isLivePlayback()) {
|
||||
<span class="cn-tag live">{{
|
||||
'EPG.TIMELINE.ON_NOW' | translate
|
||||
}}</span>
|
||||
<span class="cn-title">{{ summary()?.title }}</span>
|
||||
@if (progress() !== null) {
|
||||
<span class="cn-bar" aria-hidden="true"
|
||||
><i [style.width.%]="progress()"></i
|
||||
></span>
|
||||
}
|
||||
@if (minutesLeft() !== null) {
|
||||
<span class="cn-meta"
|
||||
>{{ minutesLeft() }}
|
||||
{{ 'EPG.TIMELINE.MINUTES_LEFT' | translate }}</span
|
||||
>
|
||||
}
|
||||
} @else {
|
||||
<span class="cn-tag arch">
|
||||
<mat-icon>play_arrow</mat-icon>
|
||||
{{ 'EPG.ARCHIVE_PLAYBACK' | translate }}
|
||||
</span>
|
||||
<span class="cn-title">{{ summary()?.title }}</span>
|
||||
@if (hasTimeRange()) {
|
||||
<span class="cn-meta"
|
||||
>{{ summary()?.start | date: 'HH:mm' }} –
|
||||
{{ summary()?.stop | date: 'HH:mm' }}</span
|
||||
>
|
||||
}
|
||||
}
|
||||
} @else {
|
||||
<span class="cn-title cn-title--muted">{{
|
||||
'EPG.NO_PROGRAM_INFO' | translate
|
||||
}}</span>
|
||||
}
|
||||
} @else {
|
||||
@if (!isLivePlayback()) {
|
||||
<button
|
||||
type="button"
|
||||
class="g-livebtn"
|
||||
(click)="returnToLive.emit()"
|
||||
>
|
||||
{{ 'EPG.TIMELINE.RETURN_TO_LIVE' | translate }}
|
||||
</button>
|
||||
}
|
||||
@if (showJump()) {
|
||||
<button
|
||||
type="button"
|
||||
class="g-jump"
|
||||
(click)="jumpToNow()"
|
||||
[matTooltip]="'EPG.TIMELINE.JUMP_NOW' | translate"
|
||||
>
|
||||
<mat-icon>my_location</mat-icon>
|
||||
{{ 'EPG.TIMELINE.NOW' | translate }}
|
||||
</button>
|
||||
}
|
||||
@if (showDateStepper()) {
|
||||
<div class="g-date">
|
||||
<button
|
||||
type="button"
|
||||
class="nav"
|
||||
[attr.aria-label]="'EPG.PREVIOUS_DAY' | translate"
|
||||
(click)="stepDay('prev')"
|
||||
>
|
||||
<mat-icon>chevron_left</mat-icon>
|
||||
</button>
|
||||
<div class="d">
|
||||
<span>
|
||||
@if (isViewToday()) {
|
||||
{{ 'EPG.TIMELINE.TODAY' | translate }}
|
||||
} @else {
|
||||
{{
|
||||
viewDate()
|
||||
| date: 'EEE' : '' : currentLocale()
|
||||
}}
|
||||
}
|
||||
</span>
|
||||
<small>{{
|
||||
viewDate() | date: 'EEE, d MMM' : '' : currentLocale()
|
||||
}}</small>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="nav"
|
||||
[attr.aria-label]="'EPG.NEXT_DAY' | translate"
|
||||
(click)="stepDay('next')"
|
||||
>
|
||||
<mat-icon>chevron_right</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (!collapsed()) {
|
||||
<!-- schedule-only notice (provider exposes history but no catch-up) -->
|
||||
@if (state === 'list' && !archivePlaybackAvailable()) {
|
||||
<div class="g-notice" role="note">
|
||||
<mat-icon>info</mat-icon>
|
||||
<span>{{ 'EPG.TIMELINE.SCHEDULE_ONLY_NOTICE' | translate }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@switch (state) {
|
||||
@case ('loading') {
|
||||
<div class="g-list g-list--skeleton">
|
||||
@for (row of skeletonRows; track $index) {
|
||||
<div class="sk-row">
|
||||
<span class="sk-time"></span>
|
||||
<span class="sk-title"></span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@case ('list') {
|
||||
<div class="g-listwrap">
|
||||
<!-- In normal flow (not an overlay): when it appears it
|
||||
pushes the list down instead of covering the topmost
|
||||
programme row. -->
|
||||
@if (nowStripVisible() && nowRow(); as now) {
|
||||
<button
|
||||
type="button"
|
||||
class="g-nowstrip"
|
||||
(click)="jumpToNow()"
|
||||
>
|
||||
<span class="ns-dot" aria-hidden="true"></span>
|
||||
<span class="ns-lbl">{{
|
||||
'EPG.TIMELINE.ON_NOW' | translate
|
||||
}}</span>
|
||||
<span class="ns-title">{{ now.program.title }}</span>
|
||||
@if (now.progress !== null) {
|
||||
<span class="ns-bar" aria-hidden="true"
|
||||
><i [style.width.%]="now.progress"></i
|
||||
></span>
|
||||
}
|
||||
@if (nowRowMinutesLeft() !== null) {
|
||||
<span class="ns-left"
|
||||
>{{ nowRowMinutesLeft() }}
|
||||
{{
|
||||
'EPG.TIMELINE.MINUTES_SHORT' | translate
|
||||
}}</span
|
||||
>
|
||||
}
|
||||
<span class="ns-jump">
|
||||
<mat-icon>arrow_upward</mat-icon>
|
||||
{{ 'EPG.TIMELINE.NOW' | translate }}
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
<div
|
||||
#list
|
||||
class="g-list app-scrollbar"
|
||||
(scroll)="onListScroll()"
|
||||
>
|
||||
@for (row of rows(); track row.key) {
|
||||
<app-epg-list-view-row
|
||||
[row]="row"
|
||||
[selected]="row.key === selectedKey()"
|
||||
[currentLocale]="currentLocale()"
|
||||
[nowMs]="nowMs()"
|
||||
(activate)="onRowActivate(row)"
|
||||
(watch)="onWatch(row)"
|
||||
(info)="openDetails(row)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@default {
|
||||
<app-epg-timeline-empty-state
|
||||
[reason]="emptyStateReason()"
|
||||
[hasOtherDays]="hasOtherDays()"
|
||||
(jumpToday)="jumpToNow()"
|
||||
(jumpNearest)="jumpToNearestDay()"
|
||||
(openSettings)="openEpgSettings.emit()"
|
||||
(retry)="retry.emit()"
|
||||
/>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,440 @@
|
||||
$line: var(--line, rgba(255, 255, 255, 0.06));
|
||||
$line-strong: var(--line-strong, rgba(255, 255, 255, 0.1));
|
||||
$surface-1: var(--surface-1, #0f141c);
|
||||
$surface-2: var(--surface-2, #161d28);
|
||||
$surface-3: var(--surface-3, #1d2533);
|
||||
$text-primary: var(--text-primary, #e7ecf3);
|
||||
$text-secondary: var(--text-secondary, #9aa3b2);
|
||||
$text-tertiary: var(--text-tertiary, #6b7384);
|
||||
$accent-blue: var(--accent-blue, #4f8eff);
|
||||
$accent-cyan: var(--accent-cyan, #5cd6ff);
|
||||
$accent-live: var(--accent-live, #ff5b6e);
|
||||
$font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
|
||||
|
||||
/* This app has no global border-box reset; the list relies on it. */
|
||||
.guide,
|
||||
.guide * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
background: #0a0e15;
|
||||
}
|
||||
|
||||
.guide {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ── toolbar ── */
|
||||
.g-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 11px 16px;
|
||||
border-bottom: 1px solid $line;
|
||||
background: $surface-1;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.g-collapse {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: $text-primary;
|
||||
cursor: pointer;
|
||||
padding: 4px 6px;
|
||||
border-radius: 7px;
|
||||
min-width: 0;
|
||||
}
|
||||
.g-collapse:hover {
|
||||
background: $surface-2;
|
||||
}
|
||||
.cc {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: $text-secondary;
|
||||
transition: transform 200ms ease;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.cc mat-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
font-size: 20px;
|
||||
}
|
||||
.is-collapsed .cc {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
.gt {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.15;
|
||||
text-align: left;
|
||||
min-width: 0;
|
||||
}
|
||||
.gt b {
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: $text-primary;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.gt .src {
|
||||
font-family: $font-mono;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: $text-tertiary;
|
||||
}
|
||||
.g-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.g-livebtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
height: 34px;
|
||||
padding: 0 15px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 91, 110, 0.14);
|
||||
border: 1px solid rgba(255, 91, 110, 0.32);
|
||||
color: $accent-live;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.g-livebtn::before {
|
||||
content: '';
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: $accent-live;
|
||||
box-shadow: 0 0 7px $accent-live;
|
||||
}
|
||||
|
||||
.g-jump {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 34px;
|
||||
padding: 0 13px;
|
||||
border-radius: 999px;
|
||||
background: $surface-2;
|
||||
border: 1px solid $line-strong;
|
||||
color: $text-secondary;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.g-jump:hover {
|
||||
color: $text-primary;
|
||||
border-color: $accent-blue;
|
||||
}
|
||||
.g-jump mat-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.g-date {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 34px;
|
||||
background: $surface-2;
|
||||
border: 1px solid $line;
|
||||
border-radius: 999px;
|
||||
padding: 2px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.g-date .nav {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: $text-secondary;
|
||||
cursor: pointer;
|
||||
}
|
||||
.g-date .nav:hover {
|
||||
background: $surface-3;
|
||||
color: $text-primary;
|
||||
}
|
||||
.g-date .nav mat-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
font-size: 20px;
|
||||
}
|
||||
.g-date .d {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: $text-primary;
|
||||
padding: 0 8px;
|
||||
min-width: 116px;
|
||||
text-align: center;
|
||||
}
|
||||
.g-date .d span {
|
||||
display: block;
|
||||
}
|
||||
.g-date .d small {
|
||||
display: block;
|
||||
font-family: $font-mono;
|
||||
font-size: 9.5px;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: $text-tertiary;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
/* ── schedule-only notice ── */
|
||||
.g-notice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
padding: 10px 18px;
|
||||
background: rgba(79, 142, 255, 0.07);
|
||||
border-bottom: 1px solid $line;
|
||||
color: $text-secondary;
|
||||
font-size: 12.5px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.g-notice mat-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 16px;
|
||||
color: $accent-blue;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* ── collapsed inline summary (rendered inside the .g-head toolbar row:
|
||||
hosts clamp the collapsed panel to 56px, so a separate block below the
|
||||
toolbar would be clipped — mirrors the timeline's collapsed layout) ── */
|
||||
.cn-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: $font-mono;
|
||||
font-size: 9.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.cn-tag.live {
|
||||
color: $accent-live;
|
||||
}
|
||||
.cn-tag.live::before {
|
||||
content: '';
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: $accent-live;
|
||||
box-shadow: 0 0 6px $accent-live;
|
||||
}
|
||||
.cn-tag.arch {
|
||||
color: $accent-blue;
|
||||
}
|
||||
.cn-tag mat-icon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.cn-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: $text-primary;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.cn-title--muted {
|
||||
color: $text-tertiary;
|
||||
font-weight: 500;
|
||||
}
|
||||
.cn-bar {
|
||||
width: 84px;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
overflow: hidden;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.cn-bar > i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: $accent-cyan;
|
||||
}
|
||||
.cn-meta {
|
||||
font-family: $font-mono;
|
||||
font-size: 11px;
|
||||
color: $text-tertiary;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* ── list ── */
|
||||
.g-listwrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.g-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
/* ── condensed now-strip: in normal flow above the list, so appearing pushes
|
||||
the rows down instead of overlaying the topmost programme ── */
|
||||
.g-nowstrip {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
padding: 9px 18px;
|
||||
border: none;
|
||||
border-bottom: 1px solid $line-strong;
|
||||
background: rgba(13, 18, 26, 0.93);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
animation: epg-nowstrip-in 180ms ease;
|
||||
}
|
||||
|
||||
@keyframes epg-nowstrip-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.g-nowstrip {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
.ns-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: $accent-live;
|
||||
box-shadow: 0 0 7px $accent-live;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.ns-lbl {
|
||||
font-family: $font-mono;
|
||||
font-size: 9.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: $accent-live;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.ns-title {
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: $text-primary;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.ns-bar {
|
||||
width: 84px;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
overflow: hidden;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.ns-bar > i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: $accent-cyan;
|
||||
}
|
||||
.ns-left {
|
||||
font-family: $font-mono;
|
||||
font-size: 11px;
|
||||
color: $text-tertiary;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.ns-jump {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
color: $accent-blue;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.ns-jump mat-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ── loading skeleton ── */
|
||||
.g-list--skeleton {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 12px 22px;
|
||||
}
|
||||
.sk-row {
|
||||
display: grid;
|
||||
grid-template-columns: 92px 1fr;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
padding: 11px 0;
|
||||
}
|
||||
.sk-time,
|
||||
.sk-title {
|
||||
height: 12px;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 255, 255, 0.06),
|
||||
rgba(255, 255, 255, 0.12),
|
||||
rgba(255, 255, 255, 0.06)
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: epg-list-shimmer 1.4s linear infinite;
|
||||
}
|
||||
.sk-title {
|
||||
max-width: 60%;
|
||||
}
|
||||
|
||||
@keyframes epg-list-shimmer {
|
||||
from {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
to {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sk-time,
|
||||
.sk-title {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { EpgListViewComponent } from './epg-list-view.component';
|
||||
import { EpgListRow } from './epg-list-view.utils';
|
||||
|
||||
function programAt(
|
||||
startOffsetMin: number,
|
||||
durationMin: number,
|
||||
title = 'P'
|
||||
): EpgProgram {
|
||||
const start = new Date(Date.now() + startOffsetMin * 60_000);
|
||||
const stop = new Date(start.getTime() + durationMin * 60_000);
|
||||
return {
|
||||
start: start.toISOString(),
|
||||
stop: stop.toISOString(),
|
||||
channel: 'ch',
|
||||
title,
|
||||
desc: null,
|
||||
category: null,
|
||||
};
|
||||
}
|
||||
|
||||
function localDateKey(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
}
|
||||
|
||||
describe('EpgListViewComponent', () => {
|
||||
let fixture: ComponentFixture<EpgListViewComponent>;
|
||||
let component: EpgListViewComponent;
|
||||
let dialogResult: BehaviorSubject<'live' | 'timeshift' | undefined>;
|
||||
|
||||
beforeEach(() => {
|
||||
dialogResult = new BehaviorSubject<'live' | 'timeshift' | undefined>(
|
||||
undefined
|
||||
);
|
||||
TestBed.configureTestingModule({
|
||||
imports: [EpgListViewComponent],
|
||||
providers: [
|
||||
{
|
||||
provide: MatDialog,
|
||||
useValue: {
|
||||
open: () => ({ afterClosed: () => dialogResult }),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
currentLang: 'en',
|
||||
defaultLang: 'en',
|
||||
onLangChange: new BehaviorSubject(null),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
fixture = TestBed.createComponent(EpgListViewComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
function setInputs(inputs: Record<string, unknown>): void {
|
||||
for (const [key, value] of Object.entries(inputs)) {
|
||||
fixture.componentRef.setInput(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
function rowWhen(when: EpgListRow['when']): EpgListRow {
|
||||
const row = component.rows().find((r) => r.when === when);
|
||||
expect(row).toBeDefined();
|
||||
return row as EpgListRow;
|
||||
}
|
||||
|
||||
it('renders the list when programmes exist for the current day', () => {
|
||||
setInputs({ programs: [programAt(0, 120, 'Now')] });
|
||||
expect(component.renderState()).toBe('list');
|
||||
expect(component.emptyStateReason()).toBe('none');
|
||||
});
|
||||
|
||||
it('shows the loading state regardless of programmes', () => {
|
||||
setInputs({ programs: [programAt(0, 60)], loading: true });
|
||||
expect(component.renderState()).toBe('loading');
|
||||
});
|
||||
|
||||
it('falls back to channel-unmapped when there are no programmes', () => {
|
||||
setInputs({ programs: [] });
|
||||
expect(component.renderState()).toBe('channel-unmapped');
|
||||
});
|
||||
|
||||
it('honours an explicit empty reason from the host', () => {
|
||||
setInputs({ programs: [], emptyReason: 'provider-no-epg' });
|
||||
expect(component.renderState()).toBe('provider-no-epg');
|
||||
});
|
||||
|
||||
it('reports empty-day when programmes exist but not for the viewed day', () => {
|
||||
setInputs({ programs: [programAt(3 * 1440, 60)] });
|
||||
expect(component.renderState()).toBe('empty-day');
|
||||
});
|
||||
|
||||
it('honours a controlled non-today selectedDate', () => {
|
||||
const future = programAt(3 * 1440, 60);
|
||||
setInputs({
|
||||
programs: [future],
|
||||
selectedDate: localDateKey(future.start),
|
||||
});
|
||||
expect(component.renderState()).toBe('list');
|
||||
});
|
||||
|
||||
it('follows a programmatic selectedDate change from the host', () => {
|
||||
const future = programAt(3 * 1440, 60);
|
||||
setInputs({ programs: [future] });
|
||||
expect(component.renderState()).toBe('empty-day');
|
||||
setInputs({ selectedDate: localDateKey(future.start) });
|
||||
expect(component.renderState()).toBe('list');
|
||||
});
|
||||
|
||||
it('shows the date stepper for list + empty-day, hides it with no EPG anywhere', () => {
|
||||
setInputs({ programs: [programAt(0, 120)] });
|
||||
expect(component.showDateStepper()).toBe(true);
|
||||
|
||||
setInputs({ programs: [programAt(3 * 1440, 60)] });
|
||||
expect(component.renderState()).toBe('empty-day');
|
||||
expect(component.showDateStepper()).toBe(true);
|
||||
|
||||
setInputs({ programs: [] });
|
||||
expect(component.showDateStepper()).toBe(false);
|
||||
|
||||
setInputs({ programs: [], emptyReason: 'provider-no-epg' });
|
||||
expect(component.showDateStepper()).toBe(false);
|
||||
});
|
||||
|
||||
it('shows the Now jump only off-today or while watching archive', () => {
|
||||
setInputs({ programs: [programAt(0, 120)], isLivePlayback: true });
|
||||
expect(component.showJump()).toBe(false); // today + live
|
||||
|
||||
setInputs({ isLivePlayback: false });
|
||||
expect(component.showJump()).toBe(true); // watching archive
|
||||
});
|
||||
|
||||
it('emits returnToLive when the on-now row is activated', () => {
|
||||
setInputs({ programs: [programAt(-30, 60, 'Live')] });
|
||||
let returned = false;
|
||||
component.returnToLive.subscribe(() => (returned = true));
|
||||
|
||||
component.onRowActivate(rowWhen('now'));
|
||||
expect(returned).toBe(true);
|
||||
});
|
||||
|
||||
it('emits a timeshift activation when a catch-up row is activated', () => {
|
||||
// Anchor the viewed day to the fixture's own day: a "3 hours ago"
|
||||
// programme belongs to *yesterday* when the suite runs 00:00–02:00
|
||||
// local, and today's row filter would drop it (midnight flakiness).
|
||||
const past = programAt(-180, 60, 'Earlier');
|
||||
setInputs({
|
||||
programs: [past],
|
||||
selectedDate: localDateKey(past.start),
|
||||
archivePlaybackAvailable: true,
|
||||
archiveDays: 7,
|
||||
});
|
||||
const events: string[] = [];
|
||||
component.programActivated.subscribe((e) => events.push(e.type));
|
||||
|
||||
component.onRowActivate(rowWhen('past'));
|
||||
expect(events).toEqual(['timeshift']);
|
||||
});
|
||||
|
||||
it('does not activate a past row when catch-up is unavailable', () => {
|
||||
const past = programAt(-180, 60, 'Earlier');
|
||||
setInputs({
|
||||
programs: [past],
|
||||
selectedDate: localDateKey(past.start),
|
||||
archivePlaybackAvailable: false,
|
||||
});
|
||||
const events: string[] = [];
|
||||
component.programActivated.subscribe((e) => events.push(e.type));
|
||||
let returned = false;
|
||||
component.returnToLive.subscribe(() => (returned = true));
|
||||
|
||||
component.onRowActivate(rowWhen('past'));
|
||||
expect(events).toEqual([]);
|
||||
expect(returned).toBe(false);
|
||||
});
|
||||
|
||||
it('emits timeshift from the explicit Watch affordance', () => {
|
||||
const past = programAt(-180, 60, 'Earlier');
|
||||
setInputs({
|
||||
programs: [past],
|
||||
selectedDate: localDateKey(past.start),
|
||||
archivePlaybackAvailable: true,
|
||||
archiveDays: 7,
|
||||
});
|
||||
const events: string[] = [];
|
||||
component.programActivated.subscribe((e) => events.push(e.type));
|
||||
|
||||
component.onWatch(rowWhen('past'));
|
||||
expect(events).toEqual(['timeshift']);
|
||||
});
|
||||
|
||||
it('opens details and reacts to a timeshift dialog result', () => {
|
||||
const past = programAt(-180, 60, 'Earlier');
|
||||
setInputs({
|
||||
programs: [past],
|
||||
selectedDate: localDateKey(past.start),
|
||||
archivePlaybackAvailable: true,
|
||||
archiveDays: 7,
|
||||
});
|
||||
dialogResult.next('timeshift');
|
||||
const events: string[] = [];
|
||||
component.programActivated.subscribe((e) => events.push(e.type));
|
||||
|
||||
component.openDetails(rowWhen('past'));
|
||||
expect(events).toEqual(['timeshift']);
|
||||
});
|
||||
|
||||
it('emits the day when stepping forward', () => {
|
||||
setInputs({ programs: [programAt(-30, 60)] });
|
||||
const emitted: string[] = [];
|
||||
component.selectedDateChange.subscribe((d) => emitted.push(d));
|
||||
|
||||
component.stepDay('next');
|
||||
expect(emitted).toHaveLength(1);
|
||||
expect(emitted[0]).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
});
|
||||
|
||||
it('toggles the collapsed state through the output', () => {
|
||||
setInputs({ collapsed: false });
|
||||
const emitted: boolean[] = [];
|
||||
component.collapsedChange.subscribe((v) => emitted.push(v));
|
||||
|
||||
component.toggleCollapsed();
|
||||
expect(emitted).toEqual([true]);
|
||||
});
|
||||
|
||||
it('derives a live progress percentage from the summary when collapsed', () => {
|
||||
const start = new Date(Date.now() - 30 * 60_000).toISOString();
|
||||
const stop = new Date(Date.now() + 30 * 60_000).toISOString();
|
||||
setInputs({ collapsed: true, summary: { title: 'Now', start, stop } });
|
||||
|
||||
expect(component.hasSummary()).toBe(true);
|
||||
expect(component.progress()).toBeGreaterThan(40);
|
||||
expect(component.progress()).toBeLessThan(60);
|
||||
expect(component.minutesLeft()).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,290 @@
|
||||
import { DatePipe } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
ElementRef,
|
||||
inject,
|
||||
input,
|
||||
linkedSignal,
|
||||
output,
|
||||
signal,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { normalizeDateLocale } from '@iptvnator/pipes';
|
||||
import { EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { startWith } from 'rxjs';
|
||||
import {
|
||||
EpgDateNavigationDirection,
|
||||
getTodayEpgDateKey,
|
||||
parseEpgDateKey,
|
||||
shiftEpgDateKey,
|
||||
} from '../epg-date';
|
||||
import { EpgItemDialogAction } from '../epg-item-description/epg-item-description.component';
|
||||
import type { EpgProgramActivationEvent } from '../epg-program-activation-event';
|
||||
import { EpgProgrammeDialogService } from '../epg-programme-dialog.service';
|
||||
import { epgDialogActionFor } from '../epg-timeline/epg-archive.util';
|
||||
import {
|
||||
EpgTimelineSummary,
|
||||
summaryHasTimeRange,
|
||||
summaryHasTitle,
|
||||
summaryMinutesLeft,
|
||||
summaryProgress,
|
||||
} from '../epg-timeline/epg-summary.util';
|
||||
import {
|
||||
EpgTimelineEmptyReason,
|
||||
EpgTimelineEmptyStateComponent,
|
||||
} from '../epg-timeline/epg-timeline-empty-state.component';
|
||||
import {
|
||||
hasProgramsForDateKey,
|
||||
nearestDateKeyWithPrograms,
|
||||
} from '../epg-timeline/epg-timeline.utils';
|
||||
import { EpgListScrollController } from './epg-list-scroll.controller';
|
||||
import { EpgListViewRowComponent } from './epg-list-view-row/epg-list-view-row.component';
|
||||
import { registerEpgListViewEffects } from './epg-list-view.effects';
|
||||
import { buildEpgListRows, EpgListRow } from './epg-list-view.utils';
|
||||
|
||||
type RenderState = 'loading' | 'list' | EpgTimelineEmptyReason;
|
||||
|
||||
/**
|
||||
* Vertical, single-day EPG list — a drop-in alternative to `app-epg-timeline`
|
||||
* with an identical controlled input/output contract, so hosts swap the two
|
||||
* with a plain `@if`. Reuses the timeline's view-agnostic modules (archive
|
||||
* gating, summary maths, date helpers, empty-state, details dialog); drops all
|
||||
* ribbon geometry, zoom, and horizontal scroll.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-epg-list-view',
|
||||
templateUrl: './epg-list-view.component.html',
|
||||
styleUrl: './epg-list-view.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
DatePipe,
|
||||
EpgListViewRowComponent,
|
||||
EpgTimelineEmptyStateComponent,
|
||||
MatIcon,
|
||||
MatTooltip,
|
||||
TranslatePipe,
|
||||
],
|
||||
})
|
||||
export class EpgListViewComponent {
|
||||
readonly programs = input<EpgProgram[]>([]);
|
||||
readonly channelName = input('');
|
||||
readonly channelLogo = input('');
|
||||
/** Same input as the timeline's, but a list-appropriate default: hosts
|
||||
* that don't pass a label (M3U, unified tab) would otherwise show the
|
||||
* timeline's literal "Timeline" while in list mode. */
|
||||
readonly sourceLabel = input('Programme guide');
|
||||
readonly archivePlaybackAvailable = input(false);
|
||||
readonly archiveDays = input(0);
|
||||
readonly activeProgram = input<EpgProgram | null>(null);
|
||||
readonly isLivePlayback = input(true);
|
||||
readonly loading = input(false);
|
||||
readonly emptyReason = input<EpgTimelineEmptyReason>('none');
|
||||
readonly selectedDate = input<string | null>(null);
|
||||
readonly collapsed = input(false);
|
||||
readonly summary = input<EpgTimelineSummary | null>(null);
|
||||
readonly summaryLabelKey = input('EPG.CURRENT_PROGRAM');
|
||||
|
||||
readonly programActivated = output<EpgProgramActivationEvent>();
|
||||
readonly returnToLive = output<void>();
|
||||
readonly selectedDateChange = output<string>();
|
||||
readonly openEpgSettings = output<void>();
|
||||
readonly retry = output<void>();
|
||||
readonly collapsedChange = output<boolean>();
|
||||
|
||||
private readonly programmeDialog = inject(EpgProgrammeDialogService);
|
||||
private readonly translate = inject(TranslateService);
|
||||
|
||||
readonly list = viewChild<ElementRef<HTMLElement>>('list');
|
||||
readonly nowMs = signal(Date.now());
|
||||
readonly selectedKey = signal<string | null>(null);
|
||||
readonly nowStripVisible = signal(false);
|
||||
|
||||
/** Day shown, seeded from the controlled `selectedDate` so a non-today
|
||||
* date survives (re)mount and follows host changes — same as the timeline. */
|
||||
private readonly viewDayKey = linkedSignal(() => {
|
||||
const key = this.selectedDate()?.trim();
|
||||
return key ? key : getTodayEpgDateKey();
|
||||
});
|
||||
|
||||
private readonly languageTick = toSignal(
|
||||
this.translate.onLangChange.pipe(startWith(null)),
|
||||
{ initialValue: null }
|
||||
);
|
||||
readonly currentLocale = computed(() => {
|
||||
this.languageTick();
|
||||
return normalizeDateLocale(
|
||||
this.translate.currentLang || this.translate.defaultLang
|
||||
);
|
||||
});
|
||||
|
||||
readonly rows = computed<EpgListRow[]>(() =>
|
||||
buildEpgListRows(this.programs(), this.viewDayKey(), this.nowMs(), {
|
||||
archivePlaybackAvailable: this.archivePlaybackAvailable(),
|
||||
archiveDays: this.archiveDays(),
|
||||
activeProgram: this.activeProgram(),
|
||||
})
|
||||
);
|
||||
readonly nowRow = computed<EpgListRow | null>(
|
||||
() => this.rows().find((row) => row.when === 'now') ?? null
|
||||
);
|
||||
readonly nowRowMinutesLeft = computed(() => {
|
||||
const row = this.nowRow();
|
||||
return row ? Math.max(0, Math.round((row.stopMs - this.nowMs()) / 60_000)) : null;
|
||||
});
|
||||
|
||||
readonly viewDate = computed(() => parseEpgDateKey(this.viewDayKey()));
|
||||
readonly isViewToday = computed(
|
||||
() => this.viewDayKey() === getTodayEpgDateKey()
|
||||
);
|
||||
|
||||
readonly renderState = computed<RenderState>(() => {
|
||||
if (this.loading()) {
|
||||
return 'loading';
|
||||
}
|
||||
const reason = this.emptyReason();
|
||||
if (reason !== 'none') {
|
||||
return reason;
|
||||
}
|
||||
if (this.programs().length === 0) {
|
||||
return 'channel-unmapped';
|
||||
}
|
||||
if (!hasProgramsForDateKey(this.programs(), this.viewDayKey())) {
|
||||
return 'empty-day';
|
||||
}
|
||||
return 'list';
|
||||
});
|
||||
readonly emptyStateReason = computed<EpgTimelineEmptyReason>(() => {
|
||||
const state = this.renderState();
|
||||
return state === 'loading' || state === 'list' ? 'none' : state;
|
||||
});
|
||||
readonly hasOtherDays = computed(() => this.programs().length > 0);
|
||||
readonly showDateStepper = computed(() => {
|
||||
const state = this.renderState();
|
||||
return state === 'list' || state === 'empty-day';
|
||||
});
|
||||
/** "Now" jump: only useful off-today or while watching archive. */
|
||||
readonly showJump = computed(
|
||||
() =>
|
||||
this.renderState() === 'list' &&
|
||||
(!this.isViewToday() || !this.isLivePlayback())
|
||||
);
|
||||
readonly skeletonRows = [0, 1, 2, 3, 4, 5];
|
||||
|
||||
// ── collapsed-summary state (reused from the timeline) ──
|
||||
readonly hasSummary = computed(() => summaryHasTitle(this.summary()));
|
||||
readonly hasTimeRange = computed(() => summaryHasTimeRange(this.summary()));
|
||||
readonly progress = computed(() =>
|
||||
summaryProgress(this.summary(), this.nowMs())
|
||||
);
|
||||
readonly minutesLeft = computed(() =>
|
||||
summaryMinutesLeft(this.summary(), this.nowMs())
|
||||
);
|
||||
|
||||
private readonly scroll = new EpgListScrollController({
|
||||
list: () => this.list()?.nativeElement,
|
||||
isViewToday: () => this.isViewToday(),
|
||||
setNowStripVisible: (visible) => this.nowStripVisible.set(visible),
|
||||
hasProgramsToday: () =>
|
||||
hasProgramsForDateKey(this.programs(), getTodayEpgDateKey()),
|
||||
commitToday: () => this.commitDay(getTodayEpgDateKey()),
|
||||
});
|
||||
|
||||
constructor() {
|
||||
registerEpgListViewEffects({
|
||||
nowMs: this.nowMs,
|
||||
list: () => this.list()?.nativeElement,
|
||||
rows: () => this.rows(),
|
||||
programs: () => this.programs(),
|
||||
isViewToday: () => this.isViewToday(),
|
||||
channelName: () => this.channelName(),
|
||||
scroll: this.scroll,
|
||||
});
|
||||
}
|
||||
|
||||
toggleCollapsed(): void {
|
||||
this.collapsedChange.emit(!this.collapsed());
|
||||
}
|
||||
|
||||
stepDay(direction: EpgDateNavigationDirection): void {
|
||||
this.commitDay(shiftEpgDateKey(this.viewDayKey(), direction));
|
||||
this.scroll.resetListScroll();
|
||||
}
|
||||
|
||||
jumpToNow(): void {
|
||||
this.commitDay(getTodayEpgDateKey());
|
||||
// Deferred: when jumping from another day, today's rows only exist
|
||||
// after the next render. Deliberate user action → animate.
|
||||
this.scroll.focusNowAfterRender(true);
|
||||
}
|
||||
|
||||
jumpToNearestDay(): void {
|
||||
const nearest = nearestDateKeyWithPrograms(
|
||||
this.programs(),
|
||||
this.nowMs()
|
||||
);
|
||||
if (nearest) {
|
||||
this.commitDay(nearest);
|
||||
this.scroll.resetListScroll();
|
||||
}
|
||||
}
|
||||
|
||||
onRowActivate(row: EpgListRow): void {
|
||||
this.selectedKey.set(row.key);
|
||||
if (row.when === 'now') {
|
||||
this.returnToLive.emit();
|
||||
return;
|
||||
}
|
||||
if (row.canCatchUp) {
|
||||
this.programActivated.emit({
|
||||
program: row.program,
|
||||
type: 'timeshift',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onWatch(row: EpgListRow): void {
|
||||
this.selectedKey.set(row.key);
|
||||
this.programActivated.emit({ program: row.program, type: 'timeshift' });
|
||||
}
|
||||
|
||||
openDetails(row: EpgListRow): void {
|
||||
this.programmeDialog
|
||||
.open({
|
||||
...row.program,
|
||||
channelName: this.channelName(),
|
||||
channelLogo: this.channelLogo(),
|
||||
primaryAction: epgDialogActionFor(row.when, row.canCatchUp),
|
||||
archiveUnavailableNote:
|
||||
row.when === 'past' && !this.archivePlaybackAvailable(),
|
||||
})
|
||||
.subscribe((result: EpgItemDialogAction | undefined) => {
|
||||
if (result === 'live') {
|
||||
this.returnToLive.emit();
|
||||
} else if (result === 'timeshift') {
|
||||
this.selectedKey.set(row.key);
|
||||
this.programActivated.emit({
|
||||
program: row.program,
|
||||
type: 'timeshift',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onListScroll(): void {
|
||||
this.scroll.updateNowStrip();
|
||||
}
|
||||
|
||||
private commitDay(dayKey: string): void {
|
||||
if (dayKey === this.viewDayKey()) {
|
||||
return;
|
||||
}
|
||||
this.viewDayKey.set(dayKey);
|
||||
this.selectedDateChange.emit(dayKey);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { effect, untracked, WritableSignal } from '@angular/core';
|
||||
import { EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import { EpgListScrollController } from './epg-list-scroll.controller';
|
||||
import { EpgListRow } from './epg-list-view.utils';
|
||||
|
||||
export interface EpgListViewEffectsContext {
|
||||
/** Wall-clock signal driving past/now/future classification + progress. */
|
||||
readonly nowMs: WritableSignal<number>;
|
||||
readonly list: () => HTMLElement | undefined;
|
||||
readonly rows: () => readonly EpgListRow[];
|
||||
readonly programs: () => readonly EpgProgram[];
|
||||
readonly isViewToday: () => boolean;
|
||||
readonly channelName: () => string;
|
||||
readonly scroll: EpgListScrollController;
|
||||
}
|
||||
|
||||
/**
|
||||
* The list view's reactive plumbing, kept out of the component so it stays
|
||||
* within the file-size guideline. Must be called from the component's
|
||||
* constructor (an injection context — `effect()` requires one).
|
||||
*/
|
||||
export function registerEpgListViewEffects(
|
||||
ctx: EpgListViewEffectsContext
|
||||
): void {
|
||||
// 30s tick reclassifies past/now/future and refreshes progress. The list
|
||||
// is a controlled component (activeProgram/isLivePlayback come from the
|
||||
// host), so the tick never clobbers active archive playback.
|
||||
effect((onCleanup) => {
|
||||
const intervalId = window.setInterval(
|
||||
() => ctx.nowMs.set(Date.now()),
|
||||
30_000
|
||||
);
|
||||
onCleanup(() => clearInterval(intervalId));
|
||||
});
|
||||
|
||||
// Auto-focus the on-air row when a channel's EPG (re)loads or the list
|
||||
// (re)mounts (collapse → expand) — the vertical analogue of the ribbon's
|
||||
// auto-focus. Tracks the `list` viewChild so a remount re-triggers, and
|
||||
// `rows` so day changes and 30s ticks refresh the now-strip; the
|
||||
// controller dedupes by programme-set identity so neither re-jumps the
|
||||
// viewport.
|
||||
effect(() => {
|
||||
const list = ctx.list();
|
||||
ctx.rows();
|
||||
const programs = ctx.programs();
|
||||
const today = ctx.isViewToday();
|
||||
const channel = ctx.channelName();
|
||||
untracked(() =>
|
||||
ctx.scroll.maybeAutoScroll(list, programs, today, channel)
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import { buildEpgListRows } from './epg-list-view.utils';
|
||||
|
||||
// Anchor "now" at local noon so ±3h fixtures stay inside the same local day,
|
||||
// keeping the overlap-based day filter deterministic regardless of run time.
|
||||
const NOON = new Date();
|
||||
NOON.setHours(12, 0, 0, 0);
|
||||
const NOW = NOON.getTime();
|
||||
|
||||
function programAt(
|
||||
startOffsetMin: number,
|
||||
durationMin: number,
|
||||
title = 'P',
|
||||
extra: Partial<EpgProgram> = {}
|
||||
): EpgProgram {
|
||||
const start = new Date(NOW + startOffsetMin * 60_000);
|
||||
const stop = new Date(start.getTime() + durationMin * 60_000);
|
||||
return {
|
||||
start: start.toISOString(),
|
||||
stop: stop.toISOString(),
|
||||
channel: 'ch',
|
||||
title,
|
||||
desc: null,
|
||||
category: null,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function dayKey(ms: number): string {
|
||||
const d = new Date(ms);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
}
|
||||
|
||||
const TODAY = dayKey(NOW);
|
||||
const DEFAULT_OPTS = {
|
||||
archivePlaybackAvailable: false,
|
||||
archiveDays: 0,
|
||||
activeProgram: null,
|
||||
};
|
||||
|
||||
describe('buildEpgListRows', () => {
|
||||
it('keeps only programmes overlapping the selected day, sorted by start', () => {
|
||||
const rows = buildEpgListRows(
|
||||
[
|
||||
programAt(90, 60, 'Later'),
|
||||
programAt(-30, 60, 'Now'),
|
||||
programAt(3 * 1440, 60, 'ThreeDaysOut'),
|
||||
],
|
||||
TODAY,
|
||||
NOW,
|
||||
DEFAULT_OPTS
|
||||
);
|
||||
|
||||
expect(rows.map((r) => r.program.title)).toEqual(['Now', 'Later']);
|
||||
});
|
||||
|
||||
it('classifies past / now / future relative to nowMs', () => {
|
||||
const rows = buildEpgListRows(
|
||||
[
|
||||
programAt(-180, 60, 'Past'),
|
||||
programAt(-30, 60, 'Now'),
|
||||
programAt(120, 60, 'Future'),
|
||||
],
|
||||
TODAY,
|
||||
NOW,
|
||||
DEFAULT_OPTS
|
||||
);
|
||||
|
||||
expect(rows.map((r) => r.when)).toEqual(['past', 'now', 'future']);
|
||||
});
|
||||
|
||||
it('computes live progress only for the now row', () => {
|
||||
const rows = buildEpgListRows(
|
||||
[programAt(-30, 60, 'Now'), programAt(120, 60, 'Future')],
|
||||
TODAY,
|
||||
NOW,
|
||||
DEFAULT_OPTS
|
||||
);
|
||||
|
||||
const now = rows.find((r) => r.when === 'now');
|
||||
const future = rows.find((r) => r.when === 'future');
|
||||
expect(now?.progress).toBeGreaterThan(40);
|
||||
expect(now?.progress).toBeLessThan(60);
|
||||
expect(future?.progress).toBeNull();
|
||||
});
|
||||
|
||||
it('gates catch-up on archive availability + window', () => {
|
||||
const past = [programAt(-180, 60, 'Past')];
|
||||
|
||||
const withArchive = buildEpgListRows(past, TODAY, NOW, {
|
||||
archivePlaybackAvailable: true,
|
||||
archiveDays: 7,
|
||||
activeProgram: null,
|
||||
});
|
||||
expect(withArchive[0].canCatchUp).toBe(true);
|
||||
|
||||
const noArchive = buildEpgListRows(past, TODAY, NOW, DEFAULT_OPTS);
|
||||
expect(noArchive[0].canCatchUp).toBe(false);
|
||||
});
|
||||
|
||||
it('marks the active programme', () => {
|
||||
const active = programAt(-180, 60, 'Past');
|
||||
const rows = buildEpgListRows(
|
||||
[active, programAt(-30, 60, 'Now')],
|
||||
TODAY,
|
||||
NOW,
|
||||
{
|
||||
archivePlaybackAvailable: true,
|
||||
archiveDays: 7,
|
||||
activeProgram: active,
|
||||
}
|
||||
);
|
||||
|
||||
expect(rows.find((r) => r.program.title === 'Past')?.isActive).toBe(
|
||||
true
|
||||
);
|
||||
expect(rows.find((r) => r.program.title === 'Now')?.isActive).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('drops malformed programmes whose stop is not after their start', () => {
|
||||
// Same as the timeline's buildTimelineBlocks: zero/negative durations
|
||||
// from bad provider data must not render (or become catch-up playable).
|
||||
const zeroDuration = programAt(-60, 0, 'Zero');
|
||||
const negativeDuration = {
|
||||
...programAt(-60, 60, 'Negative'),
|
||||
stop: new Date(NOW - 120 * 60_000).toISOString(),
|
||||
};
|
||||
const rows = buildEpgListRows(
|
||||
[zeroDuration, negativeDuration, programAt(-30, 60, 'Now')],
|
||||
TODAY,
|
||||
NOW,
|
||||
DEFAULT_OPTS
|
||||
);
|
||||
|
||||
expect(rows.map((r) => r.program.title)).toEqual(['Now']);
|
||||
});
|
||||
|
||||
it('deduplicates programmes sharing a time slot, keeping the richer one', () => {
|
||||
const rows = buildEpgListRows(
|
||||
[
|
||||
programAt(-30, 60, 'Now'),
|
||||
programAt(-30, 60, 'Now', { desc: 'With description' }),
|
||||
],
|
||||
TODAY,
|
||||
NOW,
|
||||
DEFAULT_OPTS
|
||||
);
|
||||
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].program.desc).toBe('With description');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import { addDays } from 'date-fns';
|
||||
import { parseEpgDateKey } from '../epg-date';
|
||||
import {
|
||||
areProgramsSame,
|
||||
deduplicateProgramsByTimeSlot,
|
||||
getProgramTimeMs,
|
||||
} from '../epg-program.utils';
|
||||
import { canCatchUpProgramme } from '../epg-timeline/epg-archive.util';
|
||||
import {
|
||||
classifyTimelineWhen,
|
||||
TimelineWhen,
|
||||
} from '../epg-timeline/epg-timeline.utils';
|
||||
|
||||
/**
|
||||
* One vertical list row. View-agnostic classification/gating is precomputed so
|
||||
* the row component stays dumb and the list re-derives everything off the 30s
|
||||
* `nowMs` tick. Mirrors the timeline's `TimelineBlock` semantics without the
|
||||
* ribbon geometry (offset/duration in px).
|
||||
*/
|
||||
export interface EpgListRow {
|
||||
readonly program: EpgProgram;
|
||||
readonly key: string;
|
||||
readonly startMs: number;
|
||||
readonly stopMs: number;
|
||||
readonly when: TimelineWhen;
|
||||
/** Live progress 0–100 for the `now` row, else null. */
|
||||
readonly progress: number | null;
|
||||
/** Matches the host's active programme (archive playback highlight). */
|
||||
readonly isActive: boolean;
|
||||
/** Past programme playable from the catch-up archive. */
|
||||
readonly canCatchUp: boolean;
|
||||
}
|
||||
|
||||
export interface BuildEpgListRowsOptions {
|
||||
readonly archivePlaybackAvailable: boolean;
|
||||
readonly archiveDays: number;
|
||||
readonly activeProgram: EpgProgram | null;
|
||||
}
|
||||
|
||||
function clampPercent(value: number): number {
|
||||
return Math.min(100, Math.max(0, value));
|
||||
}
|
||||
|
||||
function liveProgress(
|
||||
when: TimelineWhen,
|
||||
startMs: number,
|
||||
stopMs: number,
|
||||
nowMs: number
|
||||
): number | null {
|
||||
if (when !== 'now' || stopMs <= startMs) {
|
||||
return null;
|
||||
}
|
||||
return clampPercent(((nowMs - startMs) / (stopMs - startMs)) * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Programmes overlapping the given local day, sorted by start and deduplicated.
|
||||
* Overlap-based (not start-date-only) so a cross-midnight programme airing now
|
||||
* still shows for *today* — matching `hasProgramsForDateKey`, which the list's
|
||||
* render-state uses. A start-date filter would drop the on-air programme and
|
||||
* leave an empty list while the state says `list`.
|
||||
*/
|
||||
export function buildEpgListRows(
|
||||
programs: readonly EpgProgram[],
|
||||
selectedDateKey: string,
|
||||
nowMs: number,
|
||||
options: BuildEpgListRowsOptions
|
||||
): EpgListRow[] {
|
||||
const dayStart = parseEpgDateKey(selectedDateKey);
|
||||
const dayStartMs = dayStart.getTime();
|
||||
const dayEndMs = addDays(dayStart, 1).getTime(); // exclusive
|
||||
|
||||
const forDay = programs.filter((program) => {
|
||||
const startMs = getProgramTimeMs(program.start, program.startTimestamp);
|
||||
const stopMs = getProgramTimeMs(program.stop, program.stopTimestamp);
|
||||
if (!Number.isFinite(startMs) || !Number.isFinite(stopMs)) {
|
||||
return false;
|
||||
}
|
||||
// Drop malformed programmes (stop not after start) — same as the
|
||||
// timeline's buildTimelineBlocks; they would render impossible time
|
||||
// ranges and could even be offered as catch-up playable.
|
||||
if (stopMs <= startMs) {
|
||||
return false;
|
||||
}
|
||||
return startMs < dayEndMs && stopMs > dayStartMs;
|
||||
});
|
||||
|
||||
const deduped = deduplicateProgramsByTimeSlot(
|
||||
[...forDay].sort(
|
||||
(left, right) =>
|
||||
getProgramTimeMs(left.start, left.startTimestamp) -
|
||||
getProgramTimeMs(right.start, right.startTimestamp)
|
||||
)
|
||||
);
|
||||
|
||||
const activeProgram = options.activeProgram;
|
||||
|
||||
return deduped.map((program, index) => {
|
||||
const startMs = getProgramTimeMs(program.start, program.startTimestamp);
|
||||
const stopMs = getProgramTimeMs(program.stop, program.stopTimestamp);
|
||||
const when = classifyTimelineWhen(startMs, stopMs, nowMs);
|
||||
|
||||
return {
|
||||
program,
|
||||
key: `${startMs}-${stopMs}-${index}`,
|
||||
startMs,
|
||||
stopMs,
|
||||
when,
|
||||
progress: liveProgress(when, startMs, stopMs, nowMs),
|
||||
isActive: activeProgram
|
||||
? areProgramsSame(program, activeProgram)
|
||||
: false,
|
||||
canCatchUp: canCatchUpProgramme(
|
||||
when,
|
||||
startMs,
|
||||
options.archivePlaybackAvailable,
|
||||
options.archiveDays,
|
||||
nowMs
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
<span class="time"
|
||||
>{{ (item.startTimestamp ? item.startTimestamp * 1000 : item.start) | date: 'HH:mm' }}–{{ (item.stopTimestamp ? item.stopTimestamp * 1000 : item.stop) | date: 'HH:mm' }}</span
|
||||
>
|
||||
@if (isLive) {
|
||||
<span class="live badge" [matTooltip]="'EPG.LIVE_NOW' | translate"
|
||||
>live</span
|
||||
>
|
||||
}
|
||||
|
||||
@if (showArchiveBadge) {
|
||||
<span
|
||||
class="archive-playback badge"
|
||||
[class.archive-playback--active]="isActive"
|
||||
[matTooltip]="'EPG.TIMESHIFT_AVAILABLE' | translate"
|
||||
>
|
||||
<mat-icon class="archive-playback__icon" aria-hidden="true"
|
||||
>replay</mat-icon
|
||||
>
|
||||
<span class="archive-playback__label"
|
||||
>{{ 'XTREAM.PLAY' | translate }}</span
|
||||
>
|
||||
</span>
|
||||
}
|
||||
|
||||
@if (item.desc) {
|
||||
<span
|
||||
(click)="$event.stopPropagation(); showDescription(item)"
|
||||
matListItemMeta
|
||||
class="description-icon"
|
||||
[matTooltip]="'EPG.PROGRAM_DIALOG.SHOW_PROGRAM_DETAILS' | translate"
|
||||
><mat-icon>info</mat-icon></span
|
||||
>
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
:host {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: 0.78rem;
|
||||
line-height: 1;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
color: var(--app-muted-color);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
min-width: 42px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.68rem;
|
||||
line-height: 1.35;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
border: 1px solid transparent;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.live {
|
||||
color: var(--app-live-color);
|
||||
background: color-mix(in srgb, var(--app-live-color) 14%, transparent);
|
||||
border-color: color-mix(in srgb, var(--app-live-color) 32%, transparent);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.archive-playback {
|
||||
color: color-mix(in srgb, var(--app-selection-color) 92%, white);
|
||||
background:
|
||||
linear-gradient(
|
||||
135deg,
|
||||
color-mix(in srgb, var(--app-selection-color) 16%, transparent),
|
||||
color-mix(in srgb, var(--app-selection-color) 9%, transparent)
|
||||
),
|
||||
color-mix(in srgb, var(--app-selection-surface) 42%, transparent);
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--app-selection-border) 78%,
|
||||
transparent
|
||||
);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08),
|
||||
0 6px 14px -12px var(--app-selection-glow);
|
||||
padding-inline: 7px 10px;
|
||||
}
|
||||
|
||||
.archive-playback--active {
|
||||
color: var(--app-selection-on-color);
|
||||
background: var(--app-selection-color);
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--app-selection-color) 88%,
|
||||
transparent
|
||||
);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.18),
|
||||
0 10px 18px -14px var(--app-selection-glow);
|
||||
}
|
||||
|
||||
.archive-playback__icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
font-size: 14px;
|
||||
color: inherit;
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.archive-playback__label {
|
||||
font-size: 0.69rem;
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.description-icon {
|
||||
margin-inline-start: auto;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
transition:
|
||||
opacity 0.15s ease,
|
||||
background-color 0.15s ease;
|
||||
|
||||
mat-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
background: color-mix(in srgb, currentColor 10%, transparent);
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatListModule } from '@angular/material/list';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import { MockModule } from 'ng-mocks';
|
||||
import { EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import { EpgItemDescriptionComponent } from '../epg-item-description/epg-item-description.component';
|
||||
import { EpgListItemComponent } from './epg-list-item.component';
|
||||
|
||||
const EPG_PROGRAM_ITEM: EpgProgram = {
|
||||
start: '2026-04-05T11:30:00.000Z',
|
||||
stop: '2026-04-05T12:30:00.000Z',
|
||||
channel: '12345',
|
||||
title: 'NOW on PBS',
|
||||
desc: "Jordan's Queen Rania has made job creation a priority to help curb the staggering unemployment rates among youths in the Middle East.",
|
||||
category: 'Newsmagazine',
|
||||
episodeNum: '427',
|
||||
rating: 'TV-G',
|
||||
iconUrl:
|
||||
'http://imageswoapi.whatsonindia.com/WhatsOnTV/images/ProgramImages/xlarge/38B4DE4E9A7132257749051B6C8B4F699DB264F4V.jpg',
|
||||
};
|
||||
|
||||
describe('EpgListItemComponent', () => {
|
||||
let component: EpgListItemComponent;
|
||||
let fixture: ComponentFixture<EpgListItemComponent>;
|
||||
let dialog: MatDialog;
|
||||
let translate: TranslateService;
|
||||
|
||||
beforeEach(waitForAsync(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
EpgListItemComponent,
|
||||
MockModule(MatDialogModule),
|
||||
MockModule(MatListModule),
|
||||
MockModule(MatIconModule),
|
||||
MockModule(MatTooltipModule),
|
||||
TranslateModule.forRoot(),
|
||||
],
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(EpgListItemComponent);
|
||||
dialog = TestBed.inject(MatDialog);
|
||||
translate = TestBed.inject(TranslateService);
|
||||
translate.setTranslation(
|
||||
'en',
|
||||
{
|
||||
EPG: {
|
||||
TIMESHIFT_AVAILABLE: 'Archive playback is available',
|
||||
},
|
||||
XTREAM: {
|
||||
PLAY: 'Play',
|
||||
},
|
||||
},
|
||||
true
|
||||
);
|
||||
translate.use('en');
|
||||
component = fixture.componentInstance;
|
||||
component.item = EPG_PROGRAM_ITEM;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should trigger the function to open the details dialog', () => {
|
||||
jest.spyOn(dialog, 'open');
|
||||
component.showDescription({} as EpgProgram);
|
||||
expect(dialog.open).toHaveBeenCalledTimes(1);
|
||||
expect(dialog.open).toHaveBeenCalledWith(EpgItemDescriptionComponent, {
|
||||
data: {},
|
||||
width: '800px',
|
||||
});
|
||||
});
|
||||
|
||||
it('renders an archive playback chip with replay icon and label', () => {
|
||||
fixture = TestBed.createComponent(EpgListItemComponent);
|
||||
component = fixture.componentInstance;
|
||||
component.item = EPG_PROGRAM_ITEM;
|
||||
component.showArchiveBadge = true;
|
||||
fixture.detectChanges();
|
||||
|
||||
const chip = fixture.nativeElement.querySelector('.archive-playback');
|
||||
const icon = fixture.nativeElement.querySelector(
|
||||
'.archive-playback__icon'
|
||||
);
|
||||
|
||||
expect(chip).not.toBeNull();
|
||||
expect(chip.textContent).toContain('Play');
|
||||
expect(icon.textContent.trim()).toBe('replay');
|
||||
});
|
||||
|
||||
it('marks the archive playback chip as active when the program is active', () => {
|
||||
fixture = TestBed.createComponent(EpgListItemComponent);
|
||||
component = fixture.componentInstance;
|
||||
component.item = EPG_PROGRAM_ITEM;
|
||||
component.showArchiveBadge = true;
|
||||
component.isActive = true;
|
||||
fixture.detectChanges();
|
||||
|
||||
const chip = fixture.nativeElement.querySelector('.archive-playback');
|
||||
|
||||
expect(chip.classList).toContain('archive-playback--active');
|
||||
});
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { Component, inject, Input } from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import { EpgItemDescriptionComponent } from '../epg-item-description/epg-item-description.component';
|
||||
|
||||
@Component({
|
||||
imports: [DatePipe, MatIcon, MatTooltip, TranslatePipe],
|
||||
selector: 'app-epg-list-item',
|
||||
templateUrl: './epg-list-item.component.html',
|
||||
styleUrls: ['./epg-list-item.component.scss'],
|
||||
})
|
||||
export class EpgListItemComponent {
|
||||
private dialog = inject(MatDialog);
|
||||
|
||||
/** EPG Program to render */
|
||||
@Input() item!: EpgProgram;
|
||||
|
||||
/** Whether the program is currently live */
|
||||
@Input() isLive = false;
|
||||
|
||||
/** Whether the program is the active playback target */
|
||||
@Input() isActive = false;
|
||||
|
||||
/** Whether archive playback is available for the program */
|
||||
@Input() showArchiveBadge = false;
|
||||
|
||||
/**
|
||||
* Opens the dialog with details about the selected program
|
||||
* @param program selected epg program
|
||||
*/
|
||||
showDescription(program: EpgProgram): void {
|
||||
this.dialog.open(EpgItemDescriptionComponent, {
|
||||
width: '800px',
|
||||
data: program,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
@let items = filteredItems();
|
||||
@let timeshiftLimit = timeshiftUntil();
|
||||
|
||||
@if (showDateNavigator()) {
|
||||
<!-- channel info panel -->
|
||||
<div id="channel-header" color="primary">
|
||||
<div id="date-navigator">
|
||||
<button
|
||||
class="previous-day"
|
||||
mat-icon-button
|
||||
(click)="changeDate('prev')"
|
||||
[matTooltip]="'EPG.PREVIOUS_DAY' | translate"
|
||||
>
|
||||
<mat-icon>chevron_left</mat-icon>
|
||||
</button>
|
||||
<span class="selected-date">
|
||||
{{
|
||||
selectedDateKey()
|
||||
| date: 'MMMM d, EEEE' : undefined : currentLocale()
|
||||
}}</span
|
||||
>
|
||||
<button
|
||||
class="next-day"
|
||||
mat-icon-button
|
||||
(click)="changeDate('next')"
|
||||
[matTooltip]="'EPG.NEXT_DAY' | translate"
|
||||
>
|
||||
<mat-icon>chevron_right</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (timeshiftLimit) {
|
||||
<!-- program list -->
|
||||
<div id="program-list" class="app-scrollbar" #programList>
|
||||
@if (items.length > 0) {
|
||||
@for (program of items; track trackProgram($index, program)) {
|
||||
@if (!canActivateProgram(program)) {
|
||||
<div
|
||||
class="program-item"
|
||||
[class.current-program]="isProgramPlaying(program)"
|
||||
>
|
||||
<div class="program-meta">
|
||||
<app-epg-list-item
|
||||
[item]="program"
|
||||
[isLive]="isProgramPlaying(program)"
|
||||
[isActive]="isProgramActive(program)"
|
||||
[showArchiveBadge]="
|
||||
canPlayArchivedProgram(program)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="program-title">
|
||||
{{ program?.title }}
|
||||
</div>
|
||||
<div class="epg-item__description">
|
||||
{{ program?.desc }}
|
||||
</div>
|
||||
@if (isProgramPlaying(program)) {
|
||||
<div class="program-progress">
|
||||
<div class="epg-progress-track">
|
||||
<div
|
||||
class="epg-progress-fill"
|
||||
[style.width.%]="
|
||||
calculateProgress(program)
|
||||
"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div
|
||||
class="program-item clickable"
|
||||
[class.current-program]="isProgramPlaying(program)"
|
||||
[class.archive-program]="
|
||||
canPlayArchivedProgram(program)
|
||||
"
|
||||
[class.active]="isProgramActive(program)"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
(click)="activateProgram(program)"
|
||||
(keydown.enter)="activateProgram(program)"
|
||||
(keydown.space)="
|
||||
activateProgramFromKeyboard($event, program)
|
||||
"
|
||||
>
|
||||
<div class="program-meta">
|
||||
<app-epg-list-item
|
||||
[item]="program"
|
||||
[isLive]="isProgramPlaying(program)"
|
||||
[isActive]="isProgramActive(program)"
|
||||
[showArchiveBadge]="
|
||||
canPlayArchivedProgram(program)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="program-title">{{ program.title }}</div>
|
||||
@if (isProgramPlaying(program)) {
|
||||
<div class="program-progress">
|
||||
<div class="epg-progress-track">
|
||||
<div
|
||||
class="epg-progress-fill"
|
||||
[style.width.%]="
|
||||
calculateProgress(program)
|
||||
"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
} @else {
|
||||
<div class="epg-empty-state">
|
||||
<mat-icon class="empty-state-icon">event_busy</mat-icon>
|
||||
<div class="empty-state-title">
|
||||
{{ 'EPG.EPG_NOT_AVAILABLE_DATE' | translate }}
|
||||
</div>
|
||||
<div class="empty-state-hint">
|
||||
{{ 'EPG.NO_GUIDE_HINT' | translate }}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -1,287 +0,0 @@
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow-x: hidden;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
#date-navigator {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 10px 10px;
|
||||
gap: 8px;
|
||||
|
||||
button {
|
||||
border-radius: 10px;
|
||||
opacity: 0.84;
|
||||
transition:
|
||||
opacity 0.15s ease,
|
||||
background-color 0.15s ease,
|
||||
transform 0.15s ease;
|
||||
|
||||
&:hover:not([disabled]) {
|
||||
opacity: 1;
|
||||
background: color-mix(in srgb, currentColor 10%, transparent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&[disabled] {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.selected-date {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-size: 0.83rem;
|
||||
line-height: 1.2;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
#program-list {
|
||||
flex: 1;
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
padding: 4px 8px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.program-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
border-color 0.15s ease,
|
||||
box-shadow 0.18s ease,
|
||||
color 0.15s ease;
|
||||
cursor: default;
|
||||
|
||||
& + .program-item {
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
height: 1px;
|
||||
background: var(--app-separator);
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
&.current-program {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-selection-color) 6%,
|
||||
transparent
|
||||
);
|
||||
|
||||
&::before,
|
||||
& + .program-item::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.clickable {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: color-mix(in srgb, currentColor 6%, transparent);
|
||||
|
||||
&::before,
|
||||
& + .program-item::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.current-program:hover {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-selection-color) 10%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--app-selection-border);
|
||||
outline-offset: 2px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-selection-color) 8%,
|
||||
transparent
|
||||
);
|
||||
|
||||
&::before,
|
||||
& + .program-item::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.archive-program {
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset-block: 12px;
|
||||
inset-inline-start: 0;
|
||||
width: 3px;
|
||||
border-radius: 999px;
|
||||
background: var(--app-selection-color);
|
||||
opacity: 0.48;
|
||||
transform: scaleY(0.68);
|
||||
transition:
|
||||
opacity 0.15s ease,
|
||||
transform 0.15s ease,
|
||||
box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
&:hover::after,
|
||||
&:focus-visible::after,
|
||||
&.active::after {
|
||||
opacity: 1;
|
||||
transform: scaleY(1);
|
||||
box-shadow: 0 0 12px -4px var(--app-selection-glow);
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--app-selection-surface-strong),
|
||||
var(--app-selection-surface)
|
||||
);
|
||||
border-color: var(--app-selection-border);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px var(--app-selection-border),
|
||||
0 12px 20px -18px var(--app-selection-glow);
|
||||
|
||||
.program-title {
|
||||
color: var(--app-selection-color);
|
||||
}
|
||||
|
||||
&::before,
|
||||
& + .program-item::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.program-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.program-title {
|
||||
font-size: 0.94rem;
|
||||
line-height: 1.3;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
text-wrap: balance;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.epg-item__description {
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.4;
|
||||
color: var(--app-body-color);
|
||||
text-wrap: pretty;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.program-progress {
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.epg-progress-track {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background-color: rgba(127, 127, 127, 0.28);
|
||||
background-image: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.12),
|
||||
rgba(255, 255, 255, 0)
|
||||
);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.12),
|
||||
inset 0 1px 1px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.epg-progress-fill {
|
||||
position: absolute;
|
||||
inset-block: 0;
|
||||
inset-inline-start: 0;
|
||||
border-radius: inherit;
|
||||
background-color: var(--app-selection-color);
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 255, 255, 0.18),
|
||||
rgba(255, 255, 255, 0)
|
||||
);
|
||||
box-shadow:
|
||||
0 0 0 1px color-mix(in srgb, var(--app-selection-border) 82%, transparent),
|
||||
0 0 10px -2px var(--app-selection-glow);
|
||||
transition: width 0.35s ease;
|
||||
}
|
||||
|
||||
.epg-empty-state {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 24px;
|
||||
text-align: center;
|
||||
color: var(--app-muted-color);
|
||||
|
||||
.empty-state-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
font-size: 48px;
|
||||
opacity: 0.5;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-state-title {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 520;
|
||||
letter-spacing: -0.01em;
|
||||
margin-bottom: 8px;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.empty-state-hint {
|
||||
opacity: 0.65;
|
||||
font-size: 0.85rem;
|
||||
max-width: 240px;
|
||||
line-height: 1.4;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
}
|
||||
@@ -1,508 +0,0 @@
|
||||
import { registerLocaleData } from '@angular/common';
|
||||
import { Component, input } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import localeDe from '@angular/common/locales/de';
|
||||
import { MockPipe } from 'ng-mocks';
|
||||
import { BehaviorSubject, of } from 'rxjs';
|
||||
import { EpgService } from '@iptvnator/epg/data-access';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { Channel, EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import { EpgListItemComponent } from './epg-list-item/epg-list-item.component';
|
||||
import { EpgListComponent } from './epg-list.component';
|
||||
|
||||
let requestAnimationFrameQueue: FrameRequestCallback[] = [];
|
||||
|
||||
registerLocaleData(localeDe, 'de');
|
||||
|
||||
@Component({
|
||||
selector: 'app-epg-list-item',
|
||||
standalone: true,
|
||||
template: '',
|
||||
})
|
||||
class StubEpgListItemComponent {
|
||||
readonly item = input<EpgProgram>();
|
||||
readonly isLive = input(false);
|
||||
readonly isActive = input(false);
|
||||
readonly showArchiveBadge = input(false);
|
||||
}
|
||||
|
||||
describe('EpgListComponent', () => {
|
||||
let fixture: ComponentFixture<EpgListComponent>;
|
||||
let component: EpgListComponent;
|
||||
let store: { select: jest.Mock; dispatch: jest.Mock };
|
||||
let epgService: { currentEpgPrograms$: BehaviorSubject<EpgProgram[]> };
|
||||
let translateService: {
|
||||
currentLang: string;
|
||||
defaultLang: string;
|
||||
onLangChange: BehaviorSubject<unknown>;
|
||||
};
|
||||
let originalRequestAnimationFrame: typeof window.requestAnimationFrame;
|
||||
|
||||
const fixedNow = new Date('2026-04-05T12:00:00.000Z');
|
||||
const controlledChannel: Channel = {
|
||||
id: 'channel-101',
|
||||
name: 'Channel 101',
|
||||
url: 'https://example.com/live.m3u8',
|
||||
group: { title: 'News' },
|
||||
tvg: {
|
||||
id: 'channel-101',
|
||||
name: 'Channel 101',
|
||||
url: '',
|
||||
logo: 'channel-101.png',
|
||||
rec: '3',
|
||||
},
|
||||
http: { referrer: '', 'user-agent': '', origin: '' },
|
||||
radio: 'false',
|
||||
epgParams: '',
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(fixedNow);
|
||||
|
||||
originalRequestAnimationFrame = window.requestAnimationFrame;
|
||||
requestAnimationFrameQueue = [];
|
||||
window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
|
||||
requestAnimationFrameQueue.push(callback);
|
||||
return requestAnimationFrameQueue.length;
|
||||
}) as typeof window.requestAnimationFrame;
|
||||
|
||||
store = {
|
||||
select: jest.fn().mockReturnValue(of(null)),
|
||||
dispatch: jest.fn(),
|
||||
};
|
||||
epgService = {
|
||||
currentEpgPrograms$: new BehaviorSubject<EpgProgram[]>([]),
|
||||
};
|
||||
translateService = {
|
||||
currentLang: 'en',
|
||||
defaultLang: 'en',
|
||||
onLangChange: new BehaviorSubject<unknown>(null),
|
||||
};
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [EpgListComponent, NoopAnimationsModule],
|
||||
providers: [
|
||||
{ provide: Store, useValue: store },
|
||||
{ provide: EpgService, useValue: epgService },
|
||||
{ provide: TranslateService, useValue: translateService },
|
||||
],
|
||||
})
|
||||
.overrideComponent(EpgListComponent, {
|
||||
remove: {
|
||||
imports: [EpgListItemComponent, TranslatePipe],
|
||||
},
|
||||
add: {
|
||||
imports: [
|
||||
StubEpgListItemComponent,
|
||||
MockPipe(
|
||||
TranslatePipe,
|
||||
(value: string | null | undefined) => value ?? ''
|
||||
),
|
||||
],
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(EpgListComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.componentRef.setInput('controlledChannel', controlledChannel);
|
||||
fixture.componentRef.setInput('controlledArchiveDays', 3);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fixture.destroy();
|
||||
window.requestAnimationFrame = originalRequestAnimationFrame;
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('auto-scrolls once on the initial current-day load', () => {
|
||||
fixture.componentRef.setInput('controlledPrograms', buildPrograms());
|
||||
|
||||
fixture.detectChanges();
|
||||
const scrollToSpy = configureScrollableProgramList(fixture);
|
||||
|
||||
flushAnimationFrames();
|
||||
|
||||
expect(scrollToSpy).toHaveBeenCalledTimes(1);
|
||||
expect(scrollToSpy).toHaveBeenCalledWith({ top: 172 });
|
||||
});
|
||||
|
||||
it('does not re-trigger auto-scroll on timer-driven updates', () => {
|
||||
fixture.componentRef.setInput('controlledPrograms', buildPrograms());
|
||||
|
||||
fixture.detectChanges();
|
||||
const scrollToSpy = configureScrollableProgramList(fixture);
|
||||
flushAnimationFrames();
|
||||
|
||||
scrollToSpy.mockClear();
|
||||
jest.advanceTimersByTime(30_000);
|
||||
fixture.detectChanges();
|
||||
flushAnimationFrames();
|
||||
|
||||
expect(scrollToSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps older same-day rows reachable after the initial scroll', () => {
|
||||
fixture.componentRef.setInput('controlledPrograms', buildPrograms());
|
||||
|
||||
fixture.detectChanges();
|
||||
const scrollToSpy = configureScrollableProgramList(fixture);
|
||||
const programList = fixture.nativeElement.querySelector(
|
||||
'#program-list'
|
||||
) as HTMLElement;
|
||||
flushAnimationFrames();
|
||||
|
||||
scrollToSpy.mockClear();
|
||||
programList.scrollTop = 0;
|
||||
jest.advanceTimersByTime(30_000);
|
||||
fixture.detectChanges();
|
||||
flushAnimationFrames();
|
||||
|
||||
expect(programList.scrollTop).toBe(0);
|
||||
expect(scrollToSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not re-arm auto-scroll when changing dates for the same selection', () => {
|
||||
fixture.componentRef.setInput('controlledPrograms', buildPrograms());
|
||||
|
||||
fixture.detectChanges();
|
||||
const scrollToSpy = configureScrollableProgramList(fixture);
|
||||
flushAnimationFrames();
|
||||
|
||||
scrollToSpy.mockClear();
|
||||
component.changeDate('prev');
|
||||
fixture.detectChanges();
|
||||
component.changeDate('next');
|
||||
fixture.detectChanges();
|
||||
flushAnimationFrames();
|
||||
|
||||
expect(scrollToSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('re-arms auto-scroll when the effective selection changes', () => {
|
||||
fixture.componentRef.setInput('controlledPrograms', buildPrograms());
|
||||
|
||||
fixture.detectChanges();
|
||||
let scrollToSpy = configureScrollableProgramList(fixture);
|
||||
flushAnimationFrames();
|
||||
scrollToSpy.mockClear();
|
||||
|
||||
fixture.componentRef.setInput(
|
||||
'controlledPrograms',
|
||||
buildPrograms({
|
||||
currentStart: '2026-04-05T11:45:00.000Z',
|
||||
currentStop: '2026-04-05T12:45:00.000Z',
|
||||
futureStart: '2026-04-05T12:45:00.000Z',
|
||||
futureStop: '2026-04-05T13:45:00.000Z',
|
||||
})
|
||||
);
|
||||
fixture.detectChanges();
|
||||
scrollToSpy = configureScrollableProgramList(fixture);
|
||||
flushAnimationFrames();
|
||||
|
||||
expect(scrollToSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows archived rows as clickable only when archive playback is supported', () => {
|
||||
const programs = buildPrograms();
|
||||
fixture.componentRef.setInput('controlledPrograms', programs);
|
||||
fixture.componentRef.setInput('archivePlaybackAvailable', false);
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.canActivateProgram(programs[0])).toBe(false);
|
||||
expect(component.canActivateProgram(programs[1])).toBe(true);
|
||||
expect(
|
||||
fixture.nativeElement.querySelectorAll('.program-item.clickable')
|
||||
.length
|
||||
).toBe(1);
|
||||
|
||||
fixture.componentRef.setInput('archivePlaybackAvailable', true);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.canActivateProgram(programs[0])).toBe(true);
|
||||
expect(
|
||||
fixture.nativeElement.querySelectorAll('.program-item.clickable')
|
||||
.length
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
it('highlights an externally active archived program without marking the live program active', () => {
|
||||
const programs = buildPrograms();
|
||||
fixture.componentRef.setInput('controlledPrograms', programs);
|
||||
fixture.componentRef.setInput('archivePlaybackAvailable', true);
|
||||
fixture.componentRef.setInput('activeProgram', programs[0]);
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
const rows = fixture.nativeElement.querySelectorAll('.program-item');
|
||||
|
||||
expect(rows[0].classList).toContain('active');
|
||||
expect(rows[1].classList).toContain('current-program');
|
||||
expect(rows[1].classList).not.toContain('active');
|
||||
});
|
||||
|
||||
it('exposes playable rows as keyboard-activatable buttons', () => {
|
||||
const programs = buildPrograms();
|
||||
const emitted: unknown[] = [];
|
||||
component.programActivated.subscribe((event) => emitted.push(event));
|
||||
fixture.componentRef.setInput('controlledPrograms', programs);
|
||||
fixture.componentRef.setInput('archivePlaybackAvailable', true);
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
const archivedRow = fixture.nativeElement.querySelector(
|
||||
'.program-item.clickable'
|
||||
) as HTMLElement;
|
||||
archivedRow.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })
|
||||
);
|
||||
|
||||
expect(archivedRow.getAttribute('role')).toBe('button');
|
||||
expect(archivedRow.getAttribute('tabindex')).toBe('0');
|
||||
expect(emitted).toEqual([{ program: programs[0], type: 'timeshift' }]);
|
||||
});
|
||||
|
||||
it('renders duplicate-start programs without duplicate track-key errors and keeps clicks bound to each row', () => {
|
||||
const programs = [
|
||||
buildProgram(
|
||||
'duplicate-a',
|
||||
'Duplicate Start A',
|
||||
'2026-04-05T09:00:00.000Z',
|
||||
'2026-04-05T09:30:00.000Z'
|
||||
),
|
||||
buildProgram(
|
||||
'duplicate-b',
|
||||
'Duplicate Start B',
|
||||
'2026-04-05T09:00:00.000Z',
|
||||
'2026-04-05T10:00:00.000Z'
|
||||
),
|
||||
buildProgram(
|
||||
'current',
|
||||
'Current Show',
|
||||
'2026-04-05T11:30:00.000Z',
|
||||
'2026-04-05T12:30:00.000Z'
|
||||
),
|
||||
];
|
||||
const emitted: unknown[] = [];
|
||||
const consoleErrorSpy = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
component.programActivated.subscribe((event) => emitted.push(event));
|
||||
fixture.componentRef.setInput('controlledPrograms', programs);
|
||||
fixture.componentRef.setInput('archivePlaybackAvailable', true);
|
||||
|
||||
try {
|
||||
expect(component.trackProgram(0, programs[0])).not.toBe(
|
||||
component.trackProgram(1, programs[1])
|
||||
);
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
const duplicateTrackKeyError = consoleErrorSpy.mock.calls.some(
|
||||
(args) => args.some((arg) => String(arg).includes('NG0955'))
|
||||
);
|
||||
const rows =
|
||||
fixture.nativeElement.querySelectorAll<HTMLElement>(
|
||||
'.program-item.clickable'
|
||||
);
|
||||
rows[1].click();
|
||||
|
||||
expect(duplicateTrackKeyError).toBe(false);
|
||||
expect(rows).toHaveLength(3);
|
||||
expect(emitted).toEqual([
|
||||
{ program: programs[1], type: 'timeshift' },
|
||||
]);
|
||||
} finally {
|
||||
consoleErrorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('shows one row per duplicated time slot and keeps the richer program entry', () => {
|
||||
const sparseProgram = buildProgram(
|
||||
'duplicate-a',
|
||||
'World Cup Breakfast',
|
||||
'2026-04-05T09:00:00.000Z',
|
||||
'2026-04-05T10:00:00.000Z',
|
||||
{
|
||||
channel: 'channel-101',
|
||||
desc: null,
|
||||
}
|
||||
);
|
||||
const richProgram = buildProgram(
|
||||
'duplicate-b',
|
||||
'World Cup Breakfast',
|
||||
'2026-04-05T09:00:00.000Z',
|
||||
'2026-04-05T10:00:00.000Z',
|
||||
{
|
||||
channel: 'channel-101',
|
||||
desc: 'A preview of the day ahead.',
|
||||
category: 'Sports',
|
||||
}
|
||||
);
|
||||
const nextProgram = buildProgram(
|
||||
'next',
|
||||
'World Cup Matchday',
|
||||
'2026-04-05T10:00:00.000Z',
|
||||
'2026-04-05T11:00:00.000Z',
|
||||
{
|
||||
channel: 'channel-101',
|
||||
}
|
||||
);
|
||||
const emitted: unknown[] = [];
|
||||
component.programActivated.subscribe((event) => emitted.push(event));
|
||||
fixture.componentRef.setInput('controlledPrograms', [
|
||||
sparseProgram,
|
||||
richProgram,
|
||||
nextProgram,
|
||||
]);
|
||||
fixture.componentRef.setInput('archivePlaybackAvailable', true);
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
const rows =
|
||||
fixture.nativeElement.querySelectorAll<HTMLElement>(
|
||||
'.program-item'
|
||||
);
|
||||
rows[0].click();
|
||||
|
||||
expect(component.filteredItems()).toEqual([
|
||||
richProgram,
|
||||
nextProgram,
|
||||
]);
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(emitted).toEqual([{ program: richProgram, type: 'timeshift' }]);
|
||||
});
|
||||
|
||||
it('updates the selected-day header when the app language changes', () => {
|
||||
fixture.componentRef.setInput('controlledPrograms', buildPrograms());
|
||||
fixture.detectChanges();
|
||||
|
||||
translateService.currentLang = 'de';
|
||||
translateService.onLangChange.next({ lang: 'de' });
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement
|
||||
.querySelector('.selected-date')
|
||||
.textContent.trim()
|
||||
).toContain('Sonntag');
|
||||
});
|
||||
|
||||
it('can hide its internal date navigator for a parent-owned toolbar', () => {
|
||||
fixture.componentRef.setInput('controlledPrograms', buildPrograms());
|
||||
fixture.componentRef.setInput('showDateNavigator', false);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('#channel-header')
|
||||
).toBeNull();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('#program-list')
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('emits date changes when the selected date is controlled', () => {
|
||||
const emittedDates: string[] = [];
|
||||
component.selectedDateChange.subscribe((date) =>
|
||||
emittedDates.push(date)
|
||||
);
|
||||
fixture.componentRef.setInput('selectedDate', '2026-04-04');
|
||||
fixture.componentRef.setInput('controlledPrograms', buildPrograms());
|
||||
fixture.detectChanges();
|
||||
|
||||
component.changeDate('next');
|
||||
|
||||
expect(emittedDates).toEqual(['2026-04-05']);
|
||||
expect(component.selectedDateKey()).toBe('2026-04-04');
|
||||
});
|
||||
});
|
||||
|
||||
function buildPrograms(overrides?: {
|
||||
currentStart?: string;
|
||||
currentStop?: string;
|
||||
futureStart?: string;
|
||||
futureStop?: string;
|
||||
}): EpgProgram[] {
|
||||
const pastStart = '2026-04-05T09:00:00.000Z';
|
||||
const pastStop = '2026-04-05T10:00:00.000Z';
|
||||
const currentStart = overrides?.currentStart ?? '2026-04-05T11:30:00.000Z';
|
||||
const currentStop = overrides?.currentStop ?? '2026-04-05T12:30:00.000Z';
|
||||
const futureStart = overrides?.futureStart ?? '2026-04-05T12:30:00.000Z';
|
||||
const futureStop = overrides?.futureStop ?? '2026-04-05T13:30:00.000Z';
|
||||
|
||||
return [
|
||||
buildProgram('past', 'Past Show', pastStart, pastStop),
|
||||
buildProgram('current', 'Current Show', currentStart, currentStop),
|
||||
buildProgram('future', 'Future Show', futureStart, futureStop),
|
||||
];
|
||||
}
|
||||
|
||||
function buildProgram(
|
||||
channelSuffix: string,
|
||||
title: string,
|
||||
start: string,
|
||||
stop: string,
|
||||
overrides: Partial<EpgProgram> = {}
|
||||
): EpgProgram {
|
||||
return {
|
||||
start,
|
||||
stop,
|
||||
channel: `channel-${channelSuffix}`,
|
||||
title,
|
||||
desc: null,
|
||||
category: null,
|
||||
startTimestamp: Math.floor(Date.parse(start) / 1000),
|
||||
stopTimestamp: Math.floor(Date.parse(stop) / 1000),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function configureScrollableProgramList(
|
||||
fixture: ComponentFixture<EpgListComponent>
|
||||
): jest.Mock {
|
||||
const programList = fixture.nativeElement.querySelector(
|
||||
'#program-list'
|
||||
) as HTMLElement;
|
||||
const currentProgram = fixture.nativeElement.querySelector(
|
||||
'.program-item.current-program'
|
||||
) as HTMLElement;
|
||||
|
||||
let scrollTop = 0;
|
||||
Object.defineProperty(programList, 'scrollTop', {
|
||||
configurable: true,
|
||||
get: () => scrollTop,
|
||||
set: (value: number) => {
|
||||
scrollTop = value;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(programList, 'clientHeight', {
|
||||
configurable: true,
|
||||
value: 120,
|
||||
});
|
||||
Object.defineProperty(currentProgram, 'offsetTop', {
|
||||
configurable: true,
|
||||
value: 240,
|
||||
});
|
||||
Object.defineProperty(currentProgram, 'offsetHeight', {
|
||||
configurable: true,
|
||||
value: 36,
|
||||
});
|
||||
|
||||
const scrollToSpy = jest.fn(({ top }: { top: number }) => {
|
||||
scrollTop = top;
|
||||
});
|
||||
programList.scrollTo = scrollToSpy as typeof programList.scrollTo;
|
||||
|
||||
return scrollToSpy;
|
||||
}
|
||||
|
||||
function flushAnimationFrames(): void {
|
||||
while (requestAnimationFrameQueue.length > 0) {
|
||||
const callback = requestAnimationFrameQueue.shift();
|
||||
callback?.(0);
|
||||
}
|
||||
}
|
||||
@@ -1,383 +0,0 @@
|
||||
import { DatePipe } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
ElementRef,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { MatIconButton } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { EpgService } from '@iptvnator/epg/data-access';
|
||||
import { normalizeDateLocale } from '@iptvnator/pipes';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { EpgActions, selectActive } from '@iptvnator/m3u-state';
|
||||
import { subDays } from 'date-fns';
|
||||
import { startWith } from 'rxjs';
|
||||
import { Channel, EpgChannel, EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
EpgDateNavigationDirection,
|
||||
getTodayEpgDateKey,
|
||||
shiftEpgDateKey,
|
||||
} from '../epg-date';
|
||||
import { EpgListItemComponent } from './epg-list-item/epg-list-item.component';
|
||||
import {
|
||||
areProgramsSame,
|
||||
buildScrollContextKey,
|
||||
deduplicateProgramsByTimeSlot,
|
||||
getProgramDateKey,
|
||||
getProgramTimeMs,
|
||||
trackProgram,
|
||||
} from './epg-list.utils';
|
||||
|
||||
export interface EpgProgramActivationEvent {
|
||||
program: EpgProgram;
|
||||
type: 'live' | 'timeshift';
|
||||
}
|
||||
|
||||
@Component({
|
||||
imports: [
|
||||
DatePipe,
|
||||
EpgListItemComponent,
|
||||
MatIcon,
|
||||
MatIconButton,
|
||||
MatTooltip,
|
||||
TranslatePipe,
|
||||
],
|
||||
selector: 'app-epg-list',
|
||||
templateUrl: './epg-list.component.html',
|
||||
styleUrls: ['./epg-list.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class EpgListComponent {
|
||||
readonly controlledChannel = input<Channel | null>(null);
|
||||
readonly controlledPrograms = input<EpgProgram[] | null>(null);
|
||||
readonly controlledArchiveDays = input<number | null>(null);
|
||||
readonly archivePlaybackAvailable = input<boolean | null>(null);
|
||||
readonly activeProgram = input<EpgProgram | null>(null);
|
||||
readonly selectedDateInput = input<string | null>(null, {
|
||||
alias: 'selectedDate',
|
||||
});
|
||||
readonly showDateNavigator = input(true);
|
||||
readonly programActivated = output<EpgProgramActivationEvent>();
|
||||
readonly selectedDateChange = output<string>();
|
||||
readonly trackProgram = trackProgram;
|
||||
|
||||
private readonly store = inject(Store);
|
||||
private readonly epgService = inject(EpgService);
|
||||
private readonly translate = inject(TranslateService);
|
||||
|
||||
private readonly activeChannel = toSignal(this.store.select(selectActive), {
|
||||
initialValue: null,
|
||||
});
|
||||
private readonly servicePrograms = toSignal(
|
||||
this.epgService.currentEpgPrograms$,
|
||||
{ initialValue: [] as EpgProgram[] }
|
||||
);
|
||||
private readonly languageTick = toSignal(
|
||||
this.translate.onLangChange.pipe(startWith(null)),
|
||||
{ initialValue: null }
|
||||
);
|
||||
|
||||
readonly programList = viewChild<ElementRef<HTMLElement>>('programList');
|
||||
readonly internalSelectedDate = signal(getTodayEpgDateKey());
|
||||
readonly selectedDateKey = computed(
|
||||
() => this.selectedDateInput() ?? this.internalSelectedDate()
|
||||
);
|
||||
readonly timeNow = signal(new Date().toISOString());
|
||||
readonly currentTimeMs = computed(() => Date.parse(this.timeNow()));
|
||||
readonly currentLocale = computed(() => {
|
||||
this.languageTick();
|
||||
return normalizeDateLocale(
|
||||
this.translate.currentLang || this.translate.defaultLang
|
||||
);
|
||||
});
|
||||
|
||||
readonly isControlled = computed(
|
||||
() =>
|
||||
this.controlledChannel() !== null ||
|
||||
this.controlledPrograms() !== null
|
||||
);
|
||||
readonly displayChannel = computed<Channel | null>(
|
||||
() => this.controlledChannel() ?? this.activeChannel() ?? null
|
||||
);
|
||||
readonly channel = computed<EpgChannel | null>(() => {
|
||||
const channel = this.displayChannel();
|
||||
if (!channel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: channel.tvg?.id || '',
|
||||
displayName: channel.name
|
||||
? [{ lang: '', value: channel.name }]
|
||||
: [],
|
||||
url: channel.url ? [channel.url] : [],
|
||||
icon: channel.tvg?.logo ? [{ src: channel.tvg.logo }] : [],
|
||||
};
|
||||
});
|
||||
readonly items = computed(
|
||||
() => this.controlledPrograms() ?? this.servicePrograms() ?? []
|
||||
);
|
||||
readonly archiveDays = computed(() => {
|
||||
const controlledArchiveDays = this.controlledArchiveDays();
|
||||
if (controlledArchiveDays !== null) {
|
||||
return Math.max(0, controlledArchiveDays);
|
||||
}
|
||||
|
||||
const channel = this.displayChannel();
|
||||
const value =
|
||||
channel?.tvg?.rec || channel?.timeshift || channel?.catchup?.days;
|
||||
return Math.max(0, Number(value ?? 0) || 0);
|
||||
});
|
||||
readonly timeshiftUntil = computed(() =>
|
||||
subDays(new Date(), this.archiveDays()).toISOString()
|
||||
);
|
||||
readonly archivePlaybackEnabled = computed(
|
||||
() => this.archivePlaybackAvailable() ?? this.archiveDays() > 0
|
||||
);
|
||||
readonly filteredItems = computed(() =>
|
||||
deduplicateProgramsByTimeSlot(
|
||||
[...this.items()]
|
||||
.filter(
|
||||
(item) =>
|
||||
getProgramDateKey(item.start, item.startTimestamp) ===
|
||||
this.selectedDateKey()
|
||||
)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
getProgramTimeMs(left.start, left.startTimestamp) -
|
||||
getProgramTimeMs(right.start, right.startTimestamp)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
private scrollScheduled = false;
|
||||
private activeScrollContextKey: string | null = null;
|
||||
private lastAutoScrolledContextKey: string | null = null;
|
||||
|
||||
constructor() {
|
||||
effect((onCleanup) => {
|
||||
const intervalId = window.setInterval(() => {
|
||||
this.timeNow.set(new Date().toISOString());
|
||||
}, 30_000);
|
||||
|
||||
onCleanup(() => clearInterval(intervalId));
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const programs = this.items();
|
||||
const channel = this.displayChannel();
|
||||
this.currentTimeMs();
|
||||
|
||||
if (!this.isControlled()) {
|
||||
this.store.dispatch(
|
||||
EpgActions.setEpgAvailableFlag({
|
||||
value: programs.length > 0,
|
||||
})
|
||||
);
|
||||
|
||||
if (programs.length > 0) {
|
||||
const currentProgram = this.findCurrentProgram(programs);
|
||||
if (currentProgram) {
|
||||
this.store.dispatch(
|
||||
EpgActions.setCurrentEpgProgram({
|
||||
program: currentProgram,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
this.store.dispatch(EpgActions.resetActiveEpgProgram());
|
||||
}
|
||||
} else {
|
||||
this.store.dispatch(EpgActions.resetActiveEpgProgram());
|
||||
}
|
||||
}
|
||||
|
||||
if (!channel && !this.isControlled()) {
|
||||
this.store.dispatch(EpgActions.resetActiveEpgProgram());
|
||||
}
|
||||
|
||||
this.updateScrollContext(channel, programs);
|
||||
this.scheduleScrollToCurrentProgram();
|
||||
});
|
||||
}
|
||||
|
||||
changeDate(direction: EpgDateNavigationDirection): void {
|
||||
const nextDate = shiftEpgDateKey(this.selectedDateKey(), direction);
|
||||
|
||||
if (this.selectedDateInput() == null) {
|
||||
this.internalSelectedDate.set(nextDate);
|
||||
}
|
||||
|
||||
this.selectedDateChange.emit(nextDate);
|
||||
}
|
||||
|
||||
activateProgram(program: EpgProgram): void {
|
||||
const isLive = this.isProgramPlaying(program);
|
||||
const isTimeshift = this.canPlayArchivedProgram(program);
|
||||
|
||||
if (!isLive && !isTimeshift) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isControlled()) {
|
||||
this.programActivated.emit({
|
||||
program,
|
||||
type: isLive ? 'live' : 'timeshift',
|
||||
});
|
||||
this.timeNow.set(new Date().toISOString());
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLive) {
|
||||
this.store.dispatch(EpgActions.returnToLivePlayback());
|
||||
} else {
|
||||
this.store.dispatch(EpgActions.setActiveEpgProgram({ program }));
|
||||
}
|
||||
|
||||
this.timeNow.set(new Date().toISOString());
|
||||
}
|
||||
|
||||
activateProgramFromKeyboard(event: Event, program: EpgProgram): void {
|
||||
event.preventDefault();
|
||||
this.activateProgram(program);
|
||||
}
|
||||
|
||||
canActivateProgram(program: EpgProgram): boolean {
|
||||
return (
|
||||
this.isProgramPlaying(program) ||
|
||||
this.canPlayArchivedProgram(program)
|
||||
);
|
||||
}
|
||||
|
||||
canPlayArchivedProgram(program: EpgProgram): boolean {
|
||||
return this.archivePlaybackEnabled() && this.isProgramArchived(program);
|
||||
}
|
||||
|
||||
calculateProgress(program: EpgProgram): number {
|
||||
const now = this.currentTimeMs();
|
||||
const start = getProgramTimeMs(program.start, program.startTimestamp);
|
||||
const stop = getProgramTimeMs(program.stop, program.stopTimestamp);
|
||||
const total = stop - start;
|
||||
const elapsed = now - start;
|
||||
|
||||
return total > 0
|
||||
? Math.min(100, Math.max(0, (elapsed / total) * 100))
|
||||
: 0;
|
||||
}
|
||||
|
||||
isProgramPlaying(program: EpgProgram): boolean {
|
||||
const now = this.currentTimeMs();
|
||||
const start = getProgramTimeMs(program.start, program.startTimestamp);
|
||||
const stop = getProgramTimeMs(program.stop, program.stopTimestamp);
|
||||
return now >= start && now <= stop;
|
||||
}
|
||||
|
||||
isProgramArchived(program: EpgProgram): boolean {
|
||||
if (this.archiveDays() <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const now = this.currentTimeMs();
|
||||
const start = getProgramTimeMs(program.start, program.startTimestamp);
|
||||
const stop = getProgramTimeMs(program.stop, program.stopTimestamp);
|
||||
const archiveLimit = Date.parse(this.timeshiftUntil());
|
||||
|
||||
return stop < now && start >= archiveLimit;
|
||||
}
|
||||
|
||||
isProgramActive(program: EpgProgram): boolean {
|
||||
const activeProgram = this.activeProgram();
|
||||
return activeProgram
|
||||
? areProgramsSame(program, activeProgram)
|
||||
: this.isProgramPlaying(program);
|
||||
}
|
||||
|
||||
private findCurrentProgram(programs: EpgProgram[]): EpgProgram | undefined {
|
||||
const now = this.currentTimeMs();
|
||||
return programs.find((program) => {
|
||||
const start = getProgramTimeMs(
|
||||
program.start,
|
||||
program.startTimestamp
|
||||
);
|
||||
const stop = getProgramTimeMs(program.stop, program.stopTimestamp);
|
||||
return now >= start && now <= stop;
|
||||
});
|
||||
}
|
||||
|
||||
private updateScrollContext(
|
||||
channel: Channel | null,
|
||||
programs: EpgProgram[]
|
||||
): void {
|
||||
const nextContextKey = buildScrollContextKey(channel, programs);
|
||||
if (nextContextKey === this.activeScrollContextKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeScrollContextKey = nextContextKey;
|
||||
this.scrollScheduled = false;
|
||||
}
|
||||
|
||||
private scheduleScrollToCurrentProgram(): void {
|
||||
if (this.selectedDateKey() !== getTodayEpgDateKey()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollContextKey = this.activeScrollContextKey;
|
||||
if (
|
||||
!scrollContextKey ||
|
||||
this.lastAutoScrolledContextKey === scrollContextKey
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.scrollScheduled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.scrollScheduled = true;
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
this.scrollScheduled = false;
|
||||
this.scrollCurrentProgramIntoView(scrollContextKey);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private scrollCurrentProgramIntoView(scrollContextKey: string): void {
|
||||
const container = this.programList()?.nativeElement;
|
||||
const currentProgram = container?.querySelector<HTMLElement>(
|
||||
'.program-item.current-program'
|
||||
);
|
||||
|
||||
if (!container || !currentProgram) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastAutoScrolledContextKey = scrollContextKey;
|
||||
|
||||
const viewTop = container.scrollTop;
|
||||
const viewBottom = viewTop + container.clientHeight;
|
||||
const elementTop = currentProgram.offsetTop;
|
||||
const elementBottom = elementTop + currentProgram.offsetHeight;
|
||||
|
||||
if (elementTop < viewTop) {
|
||||
container.scrollTo({ top: Math.max(elementTop - 16, 0) });
|
||||
return;
|
||||
}
|
||||
|
||||
if (elementBottom > viewBottom) {
|
||||
container.scrollTo({
|
||||
top: elementBottom - container.clientHeight + 16,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
|
||||
export interface EpgProgramActivationEvent {
|
||||
program: EpgProgram;
|
||||
type: 'live' | 'timeshift';
|
||||
}
|
||||
+22
-55
@@ -1,59 +1,6 @@
|
||||
import { format } from 'date-fns';
|
||||
import type { Channel, EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import { EPG_DATE_KEY_FORMAT } from '../epg-date';
|
||||
|
||||
export function trackProgram(index: number, program: EpgProgram): string {
|
||||
const start = getProgramTimeMs(program.start, program.startTimestamp);
|
||||
const stop = getProgramTimeMs(program.stop, program.stopTimestamp);
|
||||
|
||||
return [
|
||||
program.channel ?? '',
|
||||
Number.isFinite(start) ? start : program.start,
|
||||
Number.isFinite(stop) ? stop : program.stop,
|
||||
program.title ?? '',
|
||||
index,
|
||||
].join('|');
|
||||
}
|
||||
|
||||
export function deduplicateProgramsByTimeSlot(
|
||||
programs: EpgProgram[]
|
||||
): EpgProgram[] {
|
||||
const programsByTimeSlot = new Map<string, EpgProgram>();
|
||||
|
||||
for (const program of programs) {
|
||||
const timeSlotKey = buildProgramTimeSlotKey(program);
|
||||
const existingProgram = programsByTimeSlot.get(timeSlotKey);
|
||||
|
||||
programsByTimeSlot.set(
|
||||
timeSlotKey,
|
||||
existingProgram
|
||||
? selectMoreInformativeProgram(existingProgram, program)
|
||||
: program
|
||||
);
|
||||
}
|
||||
|
||||
return Array.from(programsByTimeSlot.values());
|
||||
}
|
||||
|
||||
export function buildScrollContextKey(
|
||||
channel: Channel | null,
|
||||
programs: EpgProgram[]
|
||||
): string | null {
|
||||
if (!channel && programs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const channelKey =
|
||||
channel?.tvg?.id || channel?.name || channel?.url || 'unknown-channel';
|
||||
const programKey = programs
|
||||
.map(
|
||||
(program) =>
|
||||
`${getProgramTimeMs(program.start, program.startTimestamp)}-${getProgramTimeMs(program.stop, program.stopTimestamp)}`
|
||||
)
|
||||
.join('|');
|
||||
|
||||
return `${channelKey}:${programKey}`;
|
||||
}
|
||||
import type { EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import { EPG_DATE_KEY_FORMAT } from './epg-date';
|
||||
|
||||
export function getProgramTimeMs(
|
||||
isoValue: string,
|
||||
@@ -79,6 +26,26 @@ export function getProgramDateKey(
|
||||
return format(new Date(programTimeMs), EPG_DATE_KEY_FORMAT);
|
||||
}
|
||||
|
||||
export function deduplicateProgramsByTimeSlot(
|
||||
programs: EpgProgram[]
|
||||
): EpgProgram[] {
|
||||
const programsByTimeSlot = new Map<string, EpgProgram>();
|
||||
|
||||
for (const program of programs) {
|
||||
const timeSlotKey = buildProgramTimeSlotKey(program);
|
||||
const existingProgram = programsByTimeSlot.get(timeSlotKey);
|
||||
|
||||
programsByTimeSlot.set(
|
||||
timeSlotKey,
|
||||
existingProgram
|
||||
? selectMoreInformativeProgram(existingProgram, program)
|
||||
: program
|
||||
);
|
||||
}
|
||||
|
||||
return Array.from(programsByTimeSlot.values());
|
||||
}
|
||||
|
||||
export function areProgramsSame(
|
||||
left: EpgProgram,
|
||||
right: EpgProgram
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
EpgItemDescriptionComponent,
|
||||
EpgItemDialogAction,
|
||||
EpgItemDialogData,
|
||||
} from './epg-list/epg-item-description/epg-item-description.component';
|
||||
} from './epg-item-description/epg-item-description.component';
|
||||
|
||||
/**
|
||||
* Opens the shared programme-details dialog and returns the chosen action.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { EpgItemDialogAction } from '../epg-list/epg-item-description/epg-item-description.component';
|
||||
import { EpgItemDialogAction } from '../epg-item-description/epg-item-description.component';
|
||||
import { TimelineWhen } from './epg-timeline.utils';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60_000;
|
||||
|
||||
Loaded 100 of 127 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user