mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
feat: add live epg panel to collections
Entire-Checkpoint: c2cea9c530e6
This commit is contained in:
1 parent
2db99769f9
commit
0babd92304
4 files changed
+381
-24
No files matched your search
@@ -129,14 +129,15 @@ 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
|
||||
EPG content.
|
||||
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.
|
||||
- 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
|
||||
line, an expand button, and a compact date chip.
|
||||
line and an expand button. Date controls stay out of the collapsed strip.
|
||||
- Do not render the collapsed strip for external MPV/VLC playback; those
|
||||
layouts keep the full EPG-only panel.
|
||||
- Keep the EPG content mounted while collapsed so current-program state can
|
||||
|
||||
+59
-15
@@ -23,7 +23,10 @@
|
||||
<div class="content-container">
|
||||
@if (activeDetail()) {
|
||||
@if (shouldUseInlinePlayer()) {
|
||||
<div class="video-player" [class.video-player--radio]="isRadioSelection()">
|
||||
<div
|
||||
class="video-player"
|
||||
[class.video-player--radio]="isRadioSelection()"
|
||||
>
|
||||
@if (isRadioSelection()) {
|
||||
<button
|
||||
mat-icon-button
|
||||
@@ -93,7 +96,12 @@
|
||||
}
|
||||
|
||||
@if (!isRadioSelection()) {
|
||||
<div class="epg">
|
||||
<div
|
||||
class="epg"
|
||||
[class.epg-collapsed]="
|
||||
shouldUseInlinePlayer() && isLiveEpgPanelCollapsed()
|
||||
"
|
||||
>
|
||||
@if (!shouldUseInlinePlayer()) {
|
||||
<div class="player-toolbar">
|
||||
<span class="player-title">{{
|
||||
@@ -108,19 +116,55 @@
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
<div class="epg-content">
|
||||
@if (isM3uSelection()) {
|
||||
<app-epg-list
|
||||
[controlledChannel]="currentM3uChannel()"
|
||||
[controlledPrograms]="currentM3uPrograms()"
|
||||
[archivePlaybackAvailable]="
|
||||
currentM3uArchivePlaybackAvailable()
|
||||
"
|
||||
/>
|
||||
} @else {
|
||||
<app-epg-view [epgItems]="currentPortalEpgItems()" />
|
||||
}
|
||||
</div>
|
||||
@if (shouldUseInlinePlayer()) {
|
||||
<app-live-epg-panel
|
||||
[collapsed]="isLiveEpgPanelCollapsed()"
|
||||
[summary]="liveEpgPanelSummary()"
|
||||
[showDateNavigator]="isM3uSelection()"
|
||||
[selectedDate]="selectedLiveEpgDate()"
|
||||
(collapsedChange)="
|
||||
onLiveEpgPanelCollapsedChange($event)
|
||||
"
|
||||
(dateNavigation)="onLiveEpgDateNavigation($event)"
|
||||
>
|
||||
<div class="epg-content">
|
||||
@if (isM3uSelection()) {
|
||||
<app-epg-list
|
||||
[controlledChannel]="currentM3uChannel()"
|
||||
[controlledPrograms]="currentM3uPrograms()"
|
||||
[archivePlaybackAvailable]="
|
||||
currentM3uArchivePlaybackAvailable()
|
||||
"
|
||||
[selectedDate]="selectedLiveEpgDate()"
|
||||
[showDateNavigator]="false"
|
||||
(selectedDateChange)="
|
||||
onLiveEpgSelectedDateChange($event)
|
||||
"
|
||||
/>
|
||||
} @else {
|
||||
<app-epg-view
|
||||
[epgItems]="currentPortalEpgItems()"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</app-live-epg-panel>
|
||||
} @else {
|
||||
<div class="epg-content">
|
||||
@if (isM3uSelection()) {
|
||||
<app-epg-list
|
||||
[controlledChannel]="currentM3uChannel()"
|
||||
[controlledPrograms]="currentM3uPrograms()"
|
||||
[archivePlaybackAvailable]="
|
||||
currentM3uArchivePlaybackAvailable()
|
||||
"
|
||||
/>
|
||||
} @else {
|
||||
<app-epg-view
|
||||
[epgItems]="currentPortalEpgItems()"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
|
||||
+144
-5
@@ -17,16 +17,24 @@ import { isM3uCatchupPlaybackSupported } from 'm3u-utils';
|
||||
import {
|
||||
DEFAULT_FAVORITES_CHANNEL_SORT_MODE,
|
||||
FavoritesChannelSortMode,
|
||||
LiveEpgPanelState,
|
||||
matchesOpenLiveCollectionItem,
|
||||
OpenLiveCollectionItemState,
|
||||
PORTAL_PLAYER,
|
||||
persistLiveEpgPanelState,
|
||||
ResolvedLiveCollectionDetail,
|
||||
restoreLiveEpgPanelState,
|
||||
StreamResolverService,
|
||||
UnifiedCollectionItem,
|
||||
UnifiedFavoriteChannel,
|
||||
UnifiedRecentDataService,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { EpgListComponent } from '@iptvnator/ui/epg';
|
||||
import {
|
||||
EpgDateNavigationDirection,
|
||||
EpgListComponent,
|
||||
getTodayEpgDateKey,
|
||||
shiftEpgDateKey,
|
||||
} from '@iptvnator/ui/epg';
|
||||
import { GlobalFavoritesListComponent } from '../global-favorites-list/global-favorites-list.component';
|
||||
import { PortalEmptyStateComponent } from '../portal-empty-state/portal-empty-state.component';
|
||||
import {
|
||||
@@ -37,8 +45,12 @@ import {
|
||||
} from '@iptvnator/ui/playback';
|
||||
import { ResizableDirective } from 'components';
|
||||
import { SettingsStore } from 'services';
|
||||
import { Channel, EpgProgram } from 'shared-interfaces';
|
||||
import { EpgViewComponent } from 'shared-portals';
|
||||
import { Channel, EpgItem, EpgProgram } from 'shared-interfaces';
|
||||
import {
|
||||
EpgViewComponent,
|
||||
LiveEpgPanelComponent,
|
||||
LiveEpgPanelSummary,
|
||||
} from 'shared-portals';
|
||||
|
||||
@Component({
|
||||
selector: 'app-unified-live-tab',
|
||||
@@ -52,6 +64,7 @@ import { EpgViewComponent } from 'shared-portals';
|
||||
EpgViewComponent,
|
||||
GlobalFavoritesListComponent,
|
||||
HtmlVideoPlayerComponent,
|
||||
LiveEpgPanelComponent,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatProgressSpinnerModule,
|
||||
@@ -91,6 +104,10 @@ export class UnifiedLiveTabComponent {
|
||||
readonly isSelecting = signal(false);
|
||||
readonly epgMap = signal<Map<string, EpgProgram | null>>(new Map());
|
||||
readonly progressTick = signal(0);
|
||||
readonly liveEpgPanelState = signal<LiveEpgPanelState>(
|
||||
restoreLiveEpgPanelState()
|
||||
);
|
||||
readonly selectedLiveEpgDate = signal(getTodayEpgDateKey());
|
||||
readonly currentStreamUrl = computed(
|
||||
() => this.activeDetail()?.playback.streamUrl ?? ''
|
||||
);
|
||||
@@ -123,10 +140,19 @@ export class UnifiedLiveTabComponent {
|
||||
const channel = this.currentM3uChannel();
|
||||
return channel?.radio === 'true' ? channel : null;
|
||||
});
|
||||
readonly isRadioSelection = computed(() => this.activeRadioChannel() !== null);
|
||||
readonly isRadioSelection = computed(
|
||||
() => this.activeRadioChannel() !== null
|
||||
);
|
||||
readonly shouldUseInlinePlayer = computed(() => {
|
||||
return this.isRadioSelection() || this.isEmbeddedPlayer();
|
||||
});
|
||||
readonly isLiveEpgPanelCollapsed = computed(
|
||||
() => this.liveEpgPanelState() === 'collapsed'
|
||||
);
|
||||
readonly liveEpgPanelSummary = computed(() => {
|
||||
this.progressTick();
|
||||
return this.getLiveEpgPanelSummary(this.activeDetail());
|
||||
});
|
||||
|
||||
readonly activeChannelForOverlay = computed((): Channel | undefined => {
|
||||
const detail = this.activeDetail();
|
||||
@@ -255,6 +281,22 @@ export class UnifiedLiveTabComponent {
|
||||
this.reorderItems.emit(reordered);
|
||||
}
|
||||
|
||||
onLiveEpgPanelCollapsedChange(collapsed: boolean): void {
|
||||
const state: LiveEpgPanelState = collapsed ? 'collapsed' : 'expanded';
|
||||
this.liveEpgPanelState.set(state);
|
||||
persistLiveEpgPanelState(state);
|
||||
}
|
||||
|
||||
onLiveEpgDateNavigation(direction: EpgDateNavigationDirection): void {
|
||||
this.selectedLiveEpgDate.set(
|
||||
shiftEpgDateKey(this.selectedLiveEpgDate(), direction)
|
||||
);
|
||||
}
|
||||
|
||||
onLiveEpgSelectedDateChange(selectedDate: string): void {
|
||||
this.selectedLiveEpgDate.set(selectedDate);
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.selectionRequestId += 1;
|
||||
this.isSelecting.set(false);
|
||||
@@ -330,7 +372,9 @@ export class UnifiedLiveTabComponent {
|
||||
private shouldOpenExternalPlayback(
|
||||
detail: ResolvedLiveCollectionDetail
|
||||
): boolean {
|
||||
return !this.isRadioDetail(detail) && !this.portalPlayer.isEmbeddedPlayer();
|
||||
return (
|
||||
!this.isRadioDetail(detail) && !this.portalPlayer.isEmbeddedPlayer()
|
||||
);
|
||||
}
|
||||
|
||||
private isRadioDetail(
|
||||
@@ -367,4 +411,99 @@ export class UnifiedLiveTabComponent {
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private getLiveEpgPanelSummary(
|
||||
detail: ResolvedLiveCollectionDetail | null
|
||||
): LiveEpgPanelSummary | null {
|
||||
if (!detail) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (detail.epgMode === 'm3u') {
|
||||
return this.toLiveEpgPanelSummary(
|
||||
this.findCurrentM3uProgram(detail.epgPrograms ?? [])
|
||||
);
|
||||
}
|
||||
|
||||
return this.toLiveEpgPanelSummary(
|
||||
this.findCurrentPortalProgram(detail.epgItems ?? [])
|
||||
);
|
||||
}
|
||||
|
||||
private findCurrentM3uProgram(
|
||||
programs: readonly EpgProgram[]
|
||||
): EpgProgram | null {
|
||||
const now = Date.now();
|
||||
return (
|
||||
programs.find((program) => {
|
||||
const start = this.getProgramTimeMs(
|
||||
program.start,
|
||||
program.startTimestamp
|
||||
);
|
||||
const stop = this.getProgramTimeMs(
|
||||
program.stop,
|
||||
program.stopTimestamp
|
||||
);
|
||||
|
||||
return (
|
||||
start !== null &&
|
||||
stop !== null &&
|
||||
now >= start &&
|
||||
now < stop
|
||||
);
|
||||
}) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
private findCurrentPortalProgram(
|
||||
programs: readonly EpgItem[]
|
||||
): EpgItem | null {
|
||||
const now = Date.now();
|
||||
return (
|
||||
programs.find((program) => {
|
||||
const start = this.getProgramTimeMs(
|
||||
program.start,
|
||||
program.start_timestamp
|
||||
);
|
||||
const stop = this.getProgramTimeMs(
|
||||
program.stop ?? program.end,
|
||||
program.stop_timestamp
|
||||
);
|
||||
|
||||
return (
|
||||
start !== null &&
|
||||
stop !== null &&
|
||||
now >= start &&
|
||||
now < stop
|
||||
);
|
||||
}) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
private toLiveEpgPanelSummary(
|
||||
program: EpgItem | EpgProgram | null | undefined
|
||||
): LiveEpgPanelSummary | null {
|
||||
if (!program) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
title: program.title,
|
||||
start: program.start,
|
||||
stop: program.stop ?? ('end' in program ? program.end : null),
|
||||
};
|
||||
}
|
||||
|
||||
private getProgramTimeMs(
|
||||
rawDate: string | null | undefined,
|
||||
rawTimestamp?: number | string | null
|
||||
): number | null {
|
||||
const timestamp = Number.parseInt(String(rawTimestamp ?? ''), 10);
|
||||
if (Number.isFinite(timestamp) && timestamp > 0) {
|
||||
return timestamp * 1000;
|
||||
}
|
||||
|
||||
const parsedDate = Date.parse(rawDate ?? '');
|
||||
return Number.isFinite(parsedDate) ? parsedDate : null;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import {
|
||||
GlobalFavoritesListComponent,
|
||||
@@ -18,11 +19,20 @@ import {
|
||||
HtmlVideoPlayerComponent,
|
||||
VjsPlayerComponent,
|
||||
} from '@iptvnator/ui/playback';
|
||||
import { EpgListComponent } from '@iptvnator/ui/epg';
|
||||
import {
|
||||
EpgDateNavigationDirection,
|
||||
EpgListComponent,
|
||||
getTodayEpgDateKey,
|
||||
shiftEpgDateKey,
|
||||
} from '@iptvnator/ui/epg';
|
||||
import { ResizableDirective } from 'components';
|
||||
import { SettingsStore } from 'services';
|
||||
import { Channel, EpgItem, EpgProgram } from 'shared-interfaces';
|
||||
import { EpgViewComponent } from 'shared-portals';
|
||||
import {
|
||||
EpgViewComponent,
|
||||
LiveEpgPanelComponent,
|
||||
LiveEpgPanelSummary,
|
||||
} from 'shared-portals';
|
||||
import {
|
||||
DEFAULT_FAVORITES_CHANNEL_SORT_MODE,
|
||||
PORTAL_PLAYER,
|
||||
@@ -73,6 +83,9 @@ class StubEpgListComponent {
|
||||
readonly controlledChannel = input<Channel | null>(null);
|
||||
readonly controlledPrograms = input<EpgProgram[] | null>(null);
|
||||
readonly archivePlaybackAvailable = input<boolean | null>(null);
|
||||
readonly selectedDate = input<string | null>(null);
|
||||
readonly showDateNavigator = input(true);
|
||||
readonly selectedDateChange = output<string>();
|
||||
}
|
||||
|
||||
@Component({
|
||||
@@ -84,6 +97,21 @@ class StubEpgViewComponent {
|
||||
readonly epgItems = input<EpgItem[]>([]);
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-live-epg-panel',
|
||||
template: '<section class="stub-live-epg-panel"><ng-content /></section>',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
class StubLiveEpgPanelComponent {
|
||||
readonly collapsed = input(false);
|
||||
readonly summary = input<LiveEpgPanelSummary | null>(null);
|
||||
readonly loading = input(false);
|
||||
readonly showDateNavigator = input(false);
|
||||
readonly selectedDate = input<string | null>(null);
|
||||
readonly collapsedChange = output<boolean>();
|
||||
readonly dateNavigation = output<EpgDateNavigationDirection>();
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-vjs-player',
|
||||
template: '',
|
||||
@@ -145,6 +173,8 @@ describe('UnifiedLiveTabComponent', () => {
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
localStorage.removeItem('live-epg-panel-state');
|
||||
|
||||
streamResolver = {
|
||||
resolveLiveDetail: jest.fn(),
|
||||
resolveM3uPlaybackDetail: jest.fn(),
|
||||
@@ -182,6 +212,7 @@ describe('UnifiedLiveTabComponent', () => {
|
||||
EpgViewComponent,
|
||||
GlobalFavoritesListComponent,
|
||||
HtmlVideoPlayerComponent,
|
||||
LiveEpgPanelComponent,
|
||||
ResizableDirective,
|
||||
VjsPlayerComponent,
|
||||
],
|
||||
@@ -194,6 +225,7 @@ describe('UnifiedLiveTabComponent', () => {
|
||||
StubEpgViewComponent,
|
||||
StubGlobalFavoritesListComponent,
|
||||
StubHtmlVideoPlayerComponent,
|
||||
StubLiveEpgPanelComponent,
|
||||
StubResizableDirective,
|
||||
StubVjsPlayerComponent,
|
||||
],
|
||||
@@ -261,6 +293,115 @@ describe('UnifiedLiveTabComponent', () => {
|
||||
expect(fixture.nativeElement.querySelector('app-epg-view')).toBeNull();
|
||||
});
|
||||
|
||||
it('wraps inline M3U EPG in the live panel with shared date navigation', async () => {
|
||||
portalPlayer.isEmbeddedPlayer.mockReturnValue(true);
|
||||
const item = buildLiveItem('m3u');
|
||||
const currentProgram = buildCurrentProgram('M3U Now');
|
||||
streamResolver.resolveM3uPlaybackDetail.mockResolvedValue({
|
||||
epgMode: 'm3u',
|
||||
playback: {
|
||||
streamUrl: 'https://example.com/m3u.m3u8',
|
||||
title: 'M3U Live',
|
||||
},
|
||||
channel: {
|
||||
id: 'm3u-channel',
|
||||
name: 'M3U Live',
|
||||
url: 'https://example.com/m3u.m3u8',
|
||||
group: { title: 'News' },
|
||||
tvg: {
|
||||
id: 'm3u-channel',
|
||||
name: 'M3U Live',
|
||||
url: '',
|
||||
logo: 'm3u.png',
|
||||
rec: '',
|
||||
},
|
||||
http: { referrer: '', 'user-agent': '', origin: '' },
|
||||
radio: 'false',
|
||||
epgParams: '',
|
||||
},
|
||||
epgPrograms: [currentProgram],
|
||||
});
|
||||
streamResolver.loadM3uProgramsForItem.mockResolvedValue([
|
||||
currentProgram,
|
||||
]);
|
||||
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();
|
||||
|
||||
const panel = fixture.debugElement.query(
|
||||
By.directive(StubLiveEpgPanelComponent)
|
||||
).componentInstance as StubLiveEpgPanelComponent;
|
||||
const epgList = fixture.debugElement.query(
|
||||
By.directive(StubEpgListComponent)
|
||||
).componentInstance as StubEpgListComponent;
|
||||
|
||||
expect(panel.showDateNavigator()).toBe(true);
|
||||
expect(panel.summary()).toEqual(
|
||||
expect.objectContaining({ title: 'M3U Now' })
|
||||
);
|
||||
expect(epgList.showDateNavigator()).toBe(false);
|
||||
expect(epgList.selectedDate()).toBe(getTodayEpgDateKey());
|
||||
|
||||
panel.dateNavigation.emit('next');
|
||||
fixture.detectChanges();
|
||||
|
||||
const nextDate = shiftEpgDateKey(getTodayEpgDateKey(), 'next');
|
||||
expect(component.selectedLiveEpgDate()).toBe(nextDate);
|
||||
expect(epgList.selectedDate()).toBe(nextDate);
|
||||
});
|
||||
|
||||
it('wraps inline portal EPG in the live panel without date navigation', async () => {
|
||||
portalPlayer.isEmbeddedPlayer.mockReturnValue(true);
|
||||
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();
|
||||
|
||||
const panel = fixture.debugElement.query(
|
||||
By.directive(StubLiveEpgPanelComponent)
|
||||
).componentInstance as StubLiveEpgPanelComponent;
|
||||
|
||||
expect(panel.showDateNavigator()).toBe(false);
|
||||
expect(panel.summary()).toEqual(
|
||||
expect.objectContaining({ title: 'Xtream Now' })
|
||||
);
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('app-epg-view')
|
||||
).not.toBeNull();
|
||||
|
||||
panel.collapsedChange.emit(true);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.isLiveEpgPanelCollapsed()).toBe(true);
|
||||
expect(localStorage.getItem('live-epg-panel-state')).toBe('collapsed');
|
||||
});
|
||||
|
||||
it('does not wait for M3U program lookup before opening playback', async () => {
|
||||
const item = buildLiveItem('m3u');
|
||||
const pendingPrograms = createDeferred<EpgProgram[]>();
|
||||
@@ -551,6 +692,18 @@ function buildProgram(title: string): EpgProgram {
|
||||
};
|
||||
}
|
||||
|
||||
function buildCurrentProgram(title: string): EpgProgram {
|
||||
const now = Date.now();
|
||||
return {
|
||||
start: new Date(now - 10 * 60 * 1000).toISOString(),
|
||||
stop: new Date(now + 10 * 60 * 1000).toISOString(),
|
||||
channel: 'test-channel',
|
||||
title,
|
||||
desc: `${title} description`,
|
||||
category: null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildEpgItem(title: string): EpgItem {
|
||||
return {
|
||||
id: '1',
|
||||
@@ -567,6 +720,26 @@ function buildEpgItem(title: string): EpgItem {
|
||||
};
|
||||
}
|
||||
|
||||
function buildCurrentEpgItem(title: string): EpgItem {
|
||||
const now = Date.now();
|
||||
const start = now - 10 * 60 * 1000;
|
||||
const stop = now + 10 * 60 * 1000;
|
||||
|
||||
return {
|
||||
id: '1',
|
||||
epg_id: '',
|
||||
title,
|
||||
description: `${title} description`,
|
||||
lang: '',
|
||||
start: new Date(start).toISOString(),
|
||||
end: new Date(stop).toISOString(),
|
||||
stop: new Date(stop).toISOString(),
|
||||
channel_id: '1',
|
||||
start_timestamp: String(Math.floor(start / 1000)),
|
||||
stop_timestamp: String(Math.floor(stop / 1000)),
|
||||
};
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
|
||||
Reference in new issue
Block a user