mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 11:06:16 -08:00
feat(epg): add the programme guide grid components
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
5f86631786
commit
44e421103e
18 files changed
+2549
-2
No files matched your search
@@ -753,7 +753,7 @@
|
||||
}
|
||||
},
|
||||
"TOP_MENU": {
|
||||
"OPEN_MULTI_EPG": "Open Multi-EPG view"
|
||||
"OPEN_EPG_GUIDE": "Programme guide"
|
||||
},
|
||||
"EPG": {
|
||||
"NEXT_DAY": "Next day",
|
||||
@@ -803,7 +803,42 @@
|
||||
"ARCHIVE_PLAYBACK": "Archive playback",
|
||||
"COLLAPSE_PANEL": "Collapse EPG panel",
|
||||
"EXPAND_PANEL": "Expand EPG panel",
|
||||
"GUIDE": {
|
||||
"TITLE": "Programme guide",
|
||||
"CLOSE": "Close guide",
|
||||
"CHANNEL": "Channel",
|
||||
"TODAY": "Today",
|
||||
"NOW": "Now",
|
||||
"JUMP_NOW": "Jump to now",
|
||||
"SCOPE": "Channels shown",
|
||||
"ONLY_WITH_EPG": "Only with EPG",
|
||||
"ONLY_WITH_EPG_HINT": "Hide channels without programme data",
|
||||
"DENSITY_COMFORTABLE": "Comfortable rows",
|
||||
"DENSITY_COMPACT": "Compact rows",
|
||||
"ZOOM": "Zoom",
|
||||
"FILTER_CHANNELS": "Filter channels…",
|
||||
"SEARCH_PROGRAMMES": "Search programmes…",
|
||||
"NO_RESULTS": "No programmes found",
|
||||
"NO_PROGRAMME_INFO": "No programme information",
|
||||
"NO_PROGRAMME_INFO_LONG": "No programme information for this channel",
|
||||
"PLAYING": "Playing",
|
||||
"NO_CHANNELS": "No channels match \"{{query}}\"",
|
||||
"CLEAR_FILTER": "Clear filter",
|
||||
"COLLAPSE_PLAYER": "Collapse player",
|
||||
"EXPAND_PLAYER": "Expand player",
|
||||
"KEYS": {
|
||||
"CHANNEL": "channel",
|
||||
"PROGRAMME": "programme",
|
||||
"PLAY": "play",
|
||||
"DETAILS": "details",
|
||||
"NOW": "now",
|
||||
"DAY": "day",
|
||||
"CLOSE": "close"
|
||||
}
|
||||
},
|
||||
"TIMELINE": {
|
||||
"GUIDE": "Guide",
|
||||
"OPEN_GUIDE": "Open the programme guide",
|
||||
"TODAY": "Today",
|
||||
"NOW": "Now",
|
||||
"JUMP_NOW": "Jump to now",
|
||||
@@ -1525,7 +1560,7 @@
|
||||
"ACCOUNT_INFO_DESCRIPTION": "Show Xtream server account details",
|
||||
"OPEN_VIEW_DESCRIPTION": "Navigate to {{view}}",
|
||||
"CLEAR_CURRENT_VIEW_DESCRIPTION": "Clear all visible {{type}} items in this view",
|
||||
"OPEN_MULTI_EPG_DESCRIPTION": "Open the multi-channel EPG view",
|
||||
"OPEN_EPG_GUIDE_DESCRIPTION": "Open the programme guide for this playlist",
|
||||
"GO_TO_VOD_LABEL": "Movies",
|
||||
"GO_TO_VOD_DESCRIPTION": "Jump to movies section",
|
||||
"GO_TO_LIVE_LABEL": "Live TV",
|
||||
|
||||
@@ -23,5 +23,10 @@ export {
|
||||
} from './lib/epg-timeline/epg-summary.util';
|
||||
export * from './lib/epg-progress-panel/epg-progress-panel.component';
|
||||
export * from './lib/epg-source-status/epg-source-status.component';
|
||||
export * from './lib/epg-guide/epg-guide-source';
|
||||
export * from './lib/epg-guide/epg-guide-layout.util';
|
||||
export * from './lib/epg-guide/epg-guide-preferences';
|
||||
export * from './lib/epg-guide/epg-guide.component';
|
||||
export * from './lib/epg-guide/epg-guide-now-playing.component';
|
||||
export * from './lib/multi-epg/multi-epg-container.component';
|
||||
export * from './lib/multi-epg/overlay-ref.token';
|
||||
@@ -0,0 +1,53 @@
|
||||
<div class="now-playing" [class.now-playing--collapsed]="collapsed()">
|
||||
<div class="now-playing__body">
|
||||
<div class="now-playing__eyebrow">
|
||||
<span>{{ channelName() }}</span>
|
||||
@if (isOnNow()) {
|
||||
<span class="now-playing__on">{{
|
||||
'EPG.TIMELINE.ON_NOW' | translate
|
||||
}}</span>
|
||||
}
|
||||
</div>
|
||||
@if (program(); as program) {
|
||||
<div class="now-playing__title">{{ program.title }}</div>
|
||||
@if (!collapsed() && program.desc) {
|
||||
<div class="now-playing__desc">{{ program.desc }}</div>
|
||||
}
|
||||
@if (!collapsed() && progress() !== null) {
|
||||
<div class="now-playing__progress">
|
||||
<span>{{ startMs() | date: 'HH:mm' }}</span>
|
||||
<i aria-hidden="true"
|
||||
><b [style.width.%]="progress()"></b
|
||||
></i>
|
||||
<span>{{ stopMs() | date: 'HH:mm' }}</span>
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<div class="now-playing__title now-playing__title--muted">
|
||||
{{ 'EPG.GUIDE.NO_PROGRAMME_INFO' | translate }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="now-playing__actions">
|
||||
<button
|
||||
type="button"
|
||||
mat-stroked-button
|
||||
(click)="closeRequested.emit()"
|
||||
>
|
||||
{{ 'EPG.GUIDE.CLOSE' | translate }} <kbd>Esc</kbd>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
mat-button
|
||||
(click)="collapsedChange.emit(!collapsed())"
|
||||
[attr.aria-expanded]="!collapsed()"
|
||||
>
|
||||
{{
|
||||
(collapsed()
|
||||
? 'EPG.GUIDE.EXPAND_PLAYER'
|
||||
: 'EPG.GUIDE.COLLAPSE_PLAYER'
|
||||
) | translate
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,116 @@
|
||||
@use '../epg-theme' as *;
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.now-playing {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
color: $text-primary;
|
||||
}
|
||||
.now-playing__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
.now-playing__eyebrow {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
font: 500 10px $font-mono;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: $text-secondary;
|
||||
|
||||
.now-playing__on {
|
||||
color: $live-text;
|
||||
}
|
||||
.now-playing__on::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: $accent-live;
|
||||
margin-right: 5px;
|
||||
vertical-align: 1px;
|
||||
}
|
||||
}
|
||||
.now-playing__title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.now-playing__title--muted {
|
||||
color: $text-tertiary;
|
||||
font-weight: 400;
|
||||
}
|
||||
.now-playing__desc {
|
||||
font-size: 12px;
|
||||
color: $text-secondary;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 60ch;
|
||||
}
|
||||
.now-playing__progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font: 11px $font-mono;
|
||||
color: $text-secondary;
|
||||
|
||||
i {
|
||||
flex: 1;
|
||||
max-width: 360px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: $surface-3;
|
||||
overflow: hidden;
|
||||
display: block;
|
||||
}
|
||||
b {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: $accent-blue;
|
||||
}
|
||||
}
|
||||
.now-playing__actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
.now-playing--collapsed {
|
||||
gap: 12px;
|
||||
|
||||
.now-playing__body {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.now-playing__title {
|
||||
font-size: 13px;
|
||||
}
|
||||
.now-playing__actions {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
kbd {
|
||||
font: 10px $font-mono;
|
||||
border: 1px solid $line-strong;
|
||||
border-radius: 4px;
|
||||
padding: 0 4px;
|
||||
margin-left: 6px;
|
||||
color: $text-secondary;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { DatePipe } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { getProgramTimeMs } from '../epg-program.utils';
|
||||
|
||||
/**
|
||||
* Info block of the docked player strip while the guide is open: channel,
|
||||
* current programme, progress, Close and Collapse. The host renders it next to
|
||||
* the player because the strip itself is host layout.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-epg-guide-now-playing',
|
||||
imports: [DatePipe, MatButtonModule, TranslatePipe],
|
||||
templateUrl: './epg-guide-now-playing.component.html',
|
||||
styleUrl: './epg-guide-now-playing.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class EpgGuideNowPlayingComponent implements OnInit, OnDestroy {
|
||||
readonly channelName = input('');
|
||||
readonly program = input<EpgProgram | null>(null);
|
||||
readonly offsetMinutes = input(0);
|
||||
readonly collapsed = input(false);
|
||||
|
||||
readonly closeRequested = output<void>();
|
||||
readonly collapsedChange = output<boolean>();
|
||||
|
||||
private readonly nowMs = signal(Date.now());
|
||||
private timer?: number;
|
||||
|
||||
readonly startMs = computed(() => this.boundary('start'));
|
||||
readonly stopMs = computed(() => this.boundary('stop'));
|
||||
readonly isOnNow = computed(() => {
|
||||
const start = this.startMs();
|
||||
const stop = this.stopMs();
|
||||
const now = this.nowMs();
|
||||
return start !== null && stop !== null && start <= now && now < stop;
|
||||
});
|
||||
readonly progress = computed(() => {
|
||||
const start = this.startMs();
|
||||
const stop = this.stopMs();
|
||||
if (start === null || stop === null || stop <= start) {
|
||||
return null;
|
||||
}
|
||||
const pct = ((this.nowMs() - start) / (stop - start)) * 100;
|
||||
return Math.min(100, Math.max(0, pct));
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
this.timer = window.setInterval(
|
||||
() => this.nowMs.set(Date.now()),
|
||||
30_000
|
||||
);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
window.clearInterval(this.timer);
|
||||
}
|
||||
|
||||
private boundary(edge: 'start' | 'stop'): number | null {
|
||||
const program = this.program();
|
||||
if (!program) {
|
||||
return null;
|
||||
}
|
||||
const ms = getProgramTimeMs(
|
||||
program[edge],
|
||||
edge === 'start' ? program.startTimestamp : program.stopTimestamp,
|
||||
this.offsetMinutes()
|
||||
);
|
||||
return Number.isFinite(ms) ? ms : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
<div
|
||||
class="epg-guide-row__channel"
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
[attr.aria-label]="channel().name"
|
||||
[attr.aria-pressed]="active()"
|
||||
(click)="channelActivated.emit()"
|
||||
(dblclick)="channelCommitted.emit()"
|
||||
>
|
||||
<span class="epg-guide-row__num">{{ channel().number }}</span>
|
||||
<span class="epg-guide-row__logo">
|
||||
@if (channel().logoUrl; as logo) {
|
||||
<img [src]="logo" alt="" loading="lazy" />
|
||||
} @else {
|
||||
<mat-icon>live_tv</mat-icon>
|
||||
}
|
||||
</span>
|
||||
<span class="epg-guide-row__name">
|
||||
<b>{{ channel().name }}</b>
|
||||
@if (density() === 'comfortable') {
|
||||
<small>
|
||||
@if (active()) {
|
||||
▶ {{ 'EPG.GUIDE.PLAYING' | translate }}
|
||||
} @else if (nowBlock(); as now) {
|
||||
{{ now.block.program.title }}
|
||||
} @else if (showEmpty()) {
|
||||
{{ 'EPG.GUIDE.NO_PROGRAMME_INFO' | translate }}
|
||||
}
|
||||
</small>
|
||||
}
|
||||
</span>
|
||||
@if (active()) {
|
||||
<mat-icon class="epg-guide-row__playing">equalizer</mat-icon>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="epg-guide-row__lane" [style.width.px]="trackWidthPx()">
|
||||
@if (showEmpty()) {
|
||||
<span class="epg-guide-row__empty">{{
|
||||
'EPG.GUIDE.NO_PROGRAMME_INFO_LONG' | translate
|
||||
}}</span>
|
||||
} @else if (isLoading()) {
|
||||
<span
|
||||
class="epg-guide-row__skeleton"
|
||||
style="left: 24px; width: 180px"
|
||||
></span>
|
||||
<span
|
||||
class="epg-guide-row__skeleton"
|
||||
style="left: 220px; width: 260px"
|
||||
></span>
|
||||
<span
|
||||
class="epg-guide-row__skeleton"
|
||||
style="left: 500px; width: 200px"
|
||||
></span>
|
||||
}
|
||||
@for (item of blocks(); track item.key; let index = $index) {
|
||||
<div
|
||||
class="epg-guide-row__block"
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
[attr.data-tier]="item.tier"
|
||||
[class.is-past]="item.block.when === 'past'"
|
||||
[class.is-now]="item.block.when === 'now'"
|
||||
[class.is-future]="item.block.when === 'future'"
|
||||
[class.is-playing]="active() && item.block.when === 'now'"
|
||||
[class.is-focused]="focusedBlock() === index"
|
||||
[style.left.px]="item.leftPx"
|
||||
[style.width.px]="item.widthPx"
|
||||
[matTooltip]="
|
||||
item.tier === 'micro' || item.tier === 'narrow'
|
||||
? item.block.program.title
|
||||
: ''
|
||||
"
|
||||
[attr.aria-label]="item.block.program.title"
|
||||
(click)="onBlockClick(item)"
|
||||
(dblclick)="onBlockDoubleClick(item)"
|
||||
>
|
||||
@if (item.block.when === 'now') {
|
||||
<span
|
||||
class="epg-guide-row__fill"
|
||||
[style.width.%]="item.nowFillPercent"
|
||||
></span>
|
||||
}
|
||||
<span class="epg-guide-row__time">
|
||||
{{ item.block.startMs | date: 'HH:mm' }}
|
||||
@if (density() === 'comfortable') {
|
||||
– {{ item.block.stopMs | date: 'HH:mm' }}
|
||||
}
|
||||
</span>
|
||||
<span class="epg-guide-row__title">{{
|
||||
item.block.program.title
|
||||
}}</span>
|
||||
@if (item.block.when === 'now') {
|
||||
<span class="epg-guide-row__tag">{{
|
||||
'EPG.TIMELINE.ON_NOW' | translate
|
||||
}}</span>
|
||||
}
|
||||
@if (item.canCatchUp && item.block.when === 'past') {
|
||||
<button
|
||||
type="button"
|
||||
class="epg-guide-row__watch"
|
||||
(click)="onWatchClick($event, item)"
|
||||
[attr.aria-label]="'EPG.TIMELINE.WATCH' | translate"
|
||||
>
|
||||
<mat-icon>replay</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,296 @@
|
||||
@use '../epg-theme' as *;
|
||||
|
||||
:host {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
border-bottom: 1px solid $line;
|
||||
background: var(--app-content-bg);
|
||||
|
||||
&:hover {
|
||||
background: $surface-3;
|
||||
}
|
||||
&.is-active {
|
||||
background: var(--app-selection-surface);
|
||||
}
|
||||
&.is-focused {
|
||||
box-shadow: inset 0 0 0 2px $accent-blue;
|
||||
}
|
||||
}
|
||||
|
||||
.epg-guide-row__channel {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
flex: 0 0 var(--epg-guide-channel-col, 232px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 12px 0 14px;
|
||||
box-sizing: border-box;
|
||||
background: inherit;
|
||||
border-right: 1px solid $line-strong;
|
||||
cursor: pointer;
|
||||
color: $text-primary;
|
||||
|
||||
:host(.is-active) &::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 8px;
|
||||
bottom: 8px;
|
||||
width: 3px;
|
||||
border-radius: 0 3px 3px 0;
|
||||
background: $accent-blue;
|
||||
}
|
||||
}
|
||||
|
||||
.epg-guide-row__num {
|
||||
width: 22px;
|
||||
text-align: right;
|
||||
font: 11px $font-mono;
|
||||
color: $text-tertiary;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.epg-guide-row__logo {
|
||||
flex: 0 0 auto;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: $surface-1;
|
||||
border: 1px solid $line;
|
||||
color: $text-tertiary;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
:host(.is-compact) & {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.epg-guide-row__name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
|
||||
b {
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
small {
|
||||
font-size: 10.5px;
|
||||
color: $text-tertiary;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
:host(.is-active) & small {
|
||||
color: $accent-text;
|
||||
}
|
||||
}
|
||||
|
||||
.epg-guide-row__playing {
|
||||
color: $accent-blue;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.epg-guide-row__lane {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.epg-guide-row__empty {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
color: $text-tertiary;
|
||||
}
|
||||
|
||||
.epg-guide-row__skeleton {
|
||||
position: absolute;
|
||||
top: 9px;
|
||||
bottom: 9px;
|
||||
border-radius: 8px;
|
||||
background: $surface-2;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.epg-guide-row__block {
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
bottom: 7px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 8px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid $line-strong;
|
||||
background: $surface-1;
|
||||
color: $text-primary;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
transition:
|
||||
border-color 120ms,
|
||||
transform 120ms;
|
||||
|
||||
&:hover {
|
||||
border-color: $accent-blue;
|
||||
transform: translateY(-1px);
|
||||
z-index: 3;
|
||||
}
|
||||
&.is-focused {
|
||||
outline: 2px solid $accent-blue;
|
||||
outline-offset: 1px;
|
||||
z-index: 3;
|
||||
}
|
||||
&.is-past {
|
||||
background: transparent;
|
||||
border-color: $line;
|
||||
|
||||
.epg-guide-row__title {
|
||||
color: $text-secondary;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
&.is-now {
|
||||
background: var(--app-selection-surface);
|
||||
border-color: $accent-blue;
|
||||
|
||||
.epg-guide-row__title {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
&.is-playing {
|
||||
background: var(--app-selection-surface-strong);
|
||||
}
|
||||
&[data-tier='narrow'] {
|
||||
padding: 6px 8px;
|
||||
|
||||
.epg-guide-row__time,
|
||||
.epg-guide-row__tag {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
&[data-tier='micro'] {
|
||||
padding: 0;
|
||||
border-radius: 4px;
|
||||
|
||||
.epg-guide-row__time,
|
||||
.epg-guide-row__title,
|
||||
.epg-guide-row__tag {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
:host(.is-compact) & {
|
||||
top: 5px;
|
||||
bottom: 5px;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border-radius: 6px;
|
||||
padding: 0 9px;
|
||||
}
|
||||
}
|
||||
|
||||
.epg-guide-row__fill {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background: color-mix(in srgb, $accent-blue 14%, transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.epg-guide-row__time {
|
||||
position: relative;
|
||||
font: 10.5px $font-mono;
|
||||
color: $text-secondary;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.epg-guide-row__title {
|
||||
position: relative;
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.epg-guide-row__tag {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 7px;
|
||||
font: 600 9px $font-mono;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: $accent-text;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: $accent-live;
|
||||
margin-right: 4px;
|
||||
vertical-align: 1px;
|
||||
}
|
||||
:host(.is-compact) & {
|
||||
position: static;
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.epg-guide-row__watch {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
bottom: 5px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: $text-secondary;
|
||||
cursor: pointer;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
&:hover {
|
||||
color: $accent-text;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { DatePipe } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { TimelineRenderBlock } from '../epg-timeline/epg-timeline-render.util';
|
||||
import {
|
||||
buildGuideRowBlocks,
|
||||
EPG_GUIDE_ZOOM_DEFAULT,
|
||||
EpgGuideDayAxis,
|
||||
EpgGuideDensity,
|
||||
guideTrackWidthPx,
|
||||
} from './epg-guide-layout.util';
|
||||
import { EpgGuideRowStatus } from './epg-guide-programs.service';
|
||||
import { EpgGuideChannel } from './epg-guide-source';
|
||||
|
||||
/**
|
||||
* One channel row: the sticky channel cell and the lane with positioned
|
||||
* programme cards. Layout is recomputed only when its own inputs change, so a
|
||||
* 2000-row guide re-lays-out one row per programme batch, not the grid.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-epg-guide-row',
|
||||
imports: [DatePipe, MatIconModule, MatTooltipModule, TranslatePipe],
|
||||
templateUrl: './epg-guide-row.component.html',
|
||||
styleUrl: './epg-guide-row.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: {
|
||||
class: 'epg-guide-row',
|
||||
'[class.is-active]': 'active()',
|
||||
'[class.is-focused]': 'rowFocused()',
|
||||
'[class.is-compact]': 'density() === "compact"',
|
||||
},
|
||||
})
|
||||
export class EpgGuideRowComponent {
|
||||
readonly channel = input.required<EpgGuideChannel>();
|
||||
readonly programs = input<readonly EpgProgram[]>([]);
|
||||
readonly status = input<EpgGuideRowStatus>('idle');
|
||||
readonly axis = input.required<EpgGuideDayAxis>();
|
||||
readonly hourWidthPx = input(EPG_GUIDE_ZOOM_DEFAULT);
|
||||
readonly nowMs = input(0);
|
||||
readonly offsetMinutes = input(0);
|
||||
readonly catchUpAvailable = input(false);
|
||||
readonly active = input(false);
|
||||
readonly density = input<EpgGuideDensity>('comfortable');
|
||||
readonly rowFocused = input(false);
|
||||
readonly focusedBlock = input<number | null>(null);
|
||||
|
||||
/** Single click on the channel cell or the on-now card. */
|
||||
readonly channelActivated = output<void>();
|
||||
/** Double click: activate and close the guide. */
|
||||
readonly channelCommitted = output<void>();
|
||||
readonly detailsRequested = output<TimelineRenderBlock>();
|
||||
readonly watchRequested = output<TimelineRenderBlock>();
|
||||
|
||||
readonly blocks = computed(() =>
|
||||
buildGuideRowBlocks(this.programs(), {
|
||||
axis: this.axis(),
|
||||
hourWidthPx: this.hourWidthPx(),
|
||||
nowMs: this.nowMs(),
|
||||
offsetMinutes: this.offsetMinutes(),
|
||||
catchUpAvailable: this.catchUpAvailable(),
|
||||
})
|
||||
);
|
||||
readonly nowBlock = computed(
|
||||
() => this.blocks().find((item) => item.block.when === 'now') ?? null
|
||||
);
|
||||
readonly trackWidthPx = computed(() =>
|
||||
guideTrackWidthPx(this.axis(), this.hourWidthPx())
|
||||
);
|
||||
readonly showEmpty = computed(
|
||||
() =>
|
||||
this.status() === 'none' ||
|
||||
(this.status() === 'loaded' && this.blocks().length === 0)
|
||||
);
|
||||
readonly isLoading = computed(() => this.status() === 'loading');
|
||||
|
||||
onBlockClick(item: TimelineRenderBlock): void {
|
||||
if (item.block.when === 'now') {
|
||||
this.channelActivated.emit();
|
||||
} else {
|
||||
this.detailsRequested.emit(item);
|
||||
}
|
||||
}
|
||||
|
||||
onBlockDoubleClick(item: TimelineRenderBlock): void {
|
||||
if (item.block.when === 'now') {
|
||||
this.channelCommitted.emit();
|
||||
}
|
||||
}
|
||||
|
||||
onWatchClick(event: Event, item: TimelineRenderBlock): void {
|
||||
event.stopPropagation();
|
||||
this.watchRequested.emit(item);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Pure scroll geometry for the guide viewport, extracted from the shell so the
|
||||
* component stays small and the maths can be unit-tested without a DOM.
|
||||
*/
|
||||
|
||||
/** Horizontal padding kept to the left of a revealed programme block. */
|
||||
const REVEAL_PADDING_PX = 40;
|
||||
|
||||
/** Puts the now-line a third into the visible lane, never past the start. */
|
||||
export function guideNowScrollLeft(
|
||||
clientWidth: number,
|
||||
nowLeftPx: number,
|
||||
channelColumnPx: number
|
||||
): number {
|
||||
const visibleTrack = Math.max(0, clientWidth - channelColumnPx);
|
||||
return Math.max(0, nowLeftPx - visibleTrack / 3);
|
||||
}
|
||||
|
||||
/** True when the row is (partly) outside the vertical viewport. */
|
||||
export function guideRowNeedsReveal(
|
||||
rowIndex: number,
|
||||
rowHeightPx: number,
|
||||
scrollTop: number,
|
||||
clientHeight: number
|
||||
): boolean {
|
||||
const rowTop = rowIndex * rowHeightPx;
|
||||
return (
|
||||
rowTop < scrollTop || rowTop + rowHeightPx > scrollTop + clientHeight
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Target scrollLeft that brings a block into the lane, or `null` when it is
|
||||
* already fully visible.
|
||||
*/
|
||||
export function guideBlockRevealScrollLeft(
|
||||
block: { readonly leftPx: number; readonly widthPx: number },
|
||||
scrollLeft: number,
|
||||
clientWidth: number,
|
||||
channelColumnPx: number
|
||||
): number | null {
|
||||
const visibleWidth = Math.max(0, clientWidth - channelColumnPx);
|
||||
const fitsLeft = block.leftPx >= scrollLeft;
|
||||
const fitsRight = block.leftPx + block.widthPx <= scrollLeft + visibleWidth;
|
||||
if (fitsLeft && fitsRight) {
|
||||
return null;
|
||||
}
|
||||
return Math.max(0, block.leftPx - REVEAL_PADDING_PX);
|
||||
}
|
||||
|
||||
/**
|
||||
* `Element.scrollTo` is not implemented everywhere the guide renders (jsdom in
|
||||
* unit tests), so fall back to assigning `scrollLeft` directly.
|
||||
*/
|
||||
export function scrollElementLeft(
|
||||
element: HTMLElement,
|
||||
left: number,
|
||||
animate: boolean
|
||||
): void {
|
||||
if (typeof element.scrollTo === 'function') {
|
||||
element.scrollTo({ left, behavior: animate ? 'smooth' : 'auto' });
|
||||
return;
|
||||
}
|
||||
element.scrollLeft = left;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { EpgGuideSearchHit } from './epg-guide-source';
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
const SEARCH_MAX_RESULTS = 20;
|
||||
const SEARCH_MIN_LENGTH = 2;
|
||||
|
||||
/**
|
||||
* Debounced programme search for the guide toolbar. The host's
|
||||
* `searchPrograms` is optional, so `enabled` decides whether the field is
|
||||
* rendered at all; a response is dropped when the query moved on while it was
|
||||
* in flight, and a failing search yields no results rather than an error.
|
||||
*/
|
||||
export class EpgGuideSearchController {
|
||||
readonly query = signal('');
|
||||
readonly results = signal<EpgGuideSearchHit[]>([]);
|
||||
readonly enabled: boolean;
|
||||
|
||||
private timer?: number;
|
||||
|
||||
constructor(
|
||||
private readonly search?: (
|
||||
query: string
|
||||
) => Promise<EpgGuideSearchHit[]>
|
||||
) {
|
||||
this.enabled = typeof search === 'function';
|
||||
}
|
||||
|
||||
setQuery(query: string): void {
|
||||
this.query.set(query);
|
||||
window.clearTimeout(this.timer);
|
||||
const term = query.trim();
|
||||
if (term.length < SEARCH_MIN_LENGTH || !this.search) {
|
||||
this.results.set([]);
|
||||
return;
|
||||
}
|
||||
this.timer = window.setTimeout(async () => {
|
||||
const hits = await this.search?.(term).catch(() => []);
|
||||
if (this.query() === query) {
|
||||
this.results.set((hits ?? []).slice(0, SEARCH_MAX_RESULTS));
|
||||
}
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
window.clearTimeout(this.timer);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
<div class="guide-toolbar">
|
||||
<div class="guide-toolbar__day">
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
(click)="stepDay.emit('prev')"
|
||||
[attr.aria-label]="'EPG.PREVIOUS_DAY' | translate"
|
||||
>
|
||||
<mat-icon>chevron_left</mat-icon>
|
||||
</button>
|
||||
<span class="guide-toolbar__date">
|
||||
{{ dayMs() | date: 'EEE, d MMM' : '' : currentLocale() }}
|
||||
@if (isToday()) {
|
||||
<small>{{ 'EPG.GUIDE.TODAY' | translate }}</small>
|
||||
}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
(click)="stepDay.emit('next')"
|
||||
[attr.aria-label]="'EPG.NEXT_DAY' | translate"
|
||||
>
|
||||
<mat-icon>chevron_right</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="guide-toolbar__now"
|
||||
(click)="jumpNow.emit()"
|
||||
[matTooltip]="'EPG.GUIDE.JUMP_NOW' | translate"
|
||||
>
|
||||
{{ 'EPG.GUIDE.NOW' | translate }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="guide-toolbar__scope"
|
||||
[matMenuTriggerFor]="scopeMenu"
|
||||
[attr.aria-label]="'EPG.GUIDE.SCOPE' | translate"
|
||||
>
|
||||
{{ scopeLabel() }} <mat-icon>arrow_drop_down</mat-icon>
|
||||
</button>
|
||||
<mat-menu #scopeMenu="matMenu" class="guide-toolbar__scope-menu">
|
||||
@for (scope of scopes(); track scope.id) {
|
||||
<button
|
||||
mat-menu-item
|
||||
[class.is-selected]="scope.id === scopeId()"
|
||||
(click)="scopeChange.emit(scope.id)"
|
||||
>
|
||||
{{ scope.label }}
|
||||
</button>
|
||||
}
|
||||
</mat-menu>
|
||||
|
||||
<label
|
||||
class="guide-toolbar__toggle"
|
||||
[class.is-on]="onlyWithEpg()"
|
||||
[matTooltip]="'EPG.GUIDE.ONLY_WITH_EPG_HINT' | translate"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="onlyWithEpg()"
|
||||
[disabled]="!coverageLoaded()"
|
||||
(change)="onlyWithEpgChange.emit(!onlyWithEpg())"
|
||||
/>
|
||||
<i aria-hidden="true"></i>
|
||||
<span>{{ 'EPG.GUIDE.ONLY_WITH_EPG' | translate }}</span>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
class="guide-toolbar__density"
|
||||
(click)="toggleDensity()"
|
||||
[matTooltip]="densityLabelKey() | translate"
|
||||
[attr.aria-label]="densityLabelKey() | translate"
|
||||
>
|
||||
<mat-icon>{{ densityIcon() }}</mat-icon>
|
||||
</button>
|
||||
|
||||
<label
|
||||
class="guide-toolbar__zoom"
|
||||
[matTooltip]="'EPG.GUIDE.ZOOM' | translate"
|
||||
>
|
||||
<mat-icon>zoom_in</mat-icon>
|
||||
<input
|
||||
type="range"
|
||||
[min]="zoomMin"
|
||||
[max]="zoomMax"
|
||||
[step]="zoomStep"
|
||||
[value]="zoom()"
|
||||
(input)="onZoomInput($event)"
|
||||
[attr.aria-label]="'EPG.GUIDE.ZOOM' | translate"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<span class="guide-toolbar__spacer"></span>
|
||||
|
||||
<label class="guide-toolbar__input">
|
||||
<mat-icon>search</mat-icon>
|
||||
<input
|
||||
type="text"
|
||||
[value]="filter()"
|
||||
(input)="onFilterInput($event)"
|
||||
(keydown.escape)="onFilterEscape($event)"
|
||||
[placeholder]="'EPG.GUIDE.FILTER_CHANNELS' | translate"
|
||||
autocomplete="off"
|
||||
/>
|
||||
@if (filter()) {
|
||||
<span class="guide-toolbar__count"
|
||||
>{{ shownCount() }} / {{ totalCount() }}</span
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
(click)="filterChange.emit('')"
|
||||
[attr.aria-label]="'EPG.GUIDE.CLEAR_FILTER' | translate"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</label>
|
||||
|
||||
@if (searchEnabled()) {
|
||||
<label class="guide-toolbar__input guide-toolbar__input--search">
|
||||
<mat-icon>manage_search</mat-icon>
|
||||
<input
|
||||
type="text"
|
||||
[value]="searchQuery()"
|
||||
(input)="onSearchInput($event)"
|
||||
(keydown.escape)="onSearchEscape($event)"
|
||||
[placeholder]="'EPG.GUIDE.SEARCH_PROGRAMMES' | translate"
|
||||
autocomplete="off"
|
||||
/>
|
||||
@if (searchQuery()) {
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
(click)="searchQueryChange.emit('')"
|
||||
[attr.aria-label]="'CLEAR' | translate"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,238 @@
|
||||
@use '../epg-theme' as *;
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.guide-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 52px;
|
||||
padding: 0 18px;
|
||||
border-bottom: 1px solid $line;
|
||||
background: var(--app-content-bg);
|
||||
color: $text-primary;
|
||||
|
||||
button,
|
||||
label,
|
||||
input {
|
||||
font: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.guide-toolbar__day {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: 1px solid $line-strong;
|
||||
border-radius: 8px;
|
||||
height: 30px;
|
||||
overflow: hidden;
|
||||
|
||||
button {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.guide-toolbar__date {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 12px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
border-left: 1px solid $line-strong;
|
||||
border-right: 1px solid $line-strong;
|
||||
height: 100%;
|
||||
|
||||
small {
|
||||
font: 10px $font-mono;
|
||||
letter-spacing: 0.1em;
|
||||
color: $text-tertiary;
|
||||
}
|
||||
}
|
||||
|
||||
.guide-toolbar__now,
|
||||
.guide-toolbar__scope {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 30px;
|
||||
padding: 0 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid $line-strong;
|
||||
background: transparent;
|
||||
color: $text-primary;
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
background: $surface-2;
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid $accent-blue;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.guide-toolbar__now {
|
||||
border-color: $accent-blue;
|
||||
color: $accent-text;
|
||||
background: var(--app-selection-surface);
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: $accent-live;
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, $accent-live 20%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.guide-toolbar__scope mat-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
font-size: 18px;
|
||||
margin-right: -6px;
|
||||
}
|
||||
|
||||
.guide-toolbar__toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12.5px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
|
||||
input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
i {
|
||||
width: 30px;
|
||||
height: 16px;
|
||||
border-radius: 99px;
|
||||
background: $surface-3;
|
||||
position: relative;
|
||||
transition: background 120ms;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 2px;
|
||||
top: 2px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: $text-tertiary;
|
||||
transition:
|
||||
left 120ms,
|
||||
background 120ms;
|
||||
}
|
||||
}
|
||||
&.is-on i {
|
||||
background: $accent-blue;
|
||||
|
||||
&::after {
|
||||
left: 16px;
|
||||
background: var(--app-content-bg);
|
||||
}
|
||||
}
|
||||
&:has(input:disabled) {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
&:has(input:focus-visible) i {
|
||||
outline: 2px solid $accent-blue;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.guide-toolbar__zoom {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: $text-secondary;
|
||||
|
||||
input[type='range'] {
|
||||
width: 110px;
|
||||
accent-color: $accent-blue;
|
||||
}
|
||||
}
|
||||
|
||||
.guide-toolbar__spacer {
|
||||
flex: 1;
|
||||
min-width: 8px;
|
||||
}
|
||||
|
||||
.guide-toolbar__input {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 30px;
|
||||
padding: 0 4px 0 10px;
|
||||
width: 190px;
|
||||
border: 1px solid $line-strong;
|
||||
border-radius: 8px;
|
||||
background: $surface-1;
|
||||
color: $text-tertiary;
|
||||
|
||||
mat-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 16px;
|
||||
}
|
||||
input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: $text-primary;
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
|
||||
&::placeholder {
|
||||
color: $text-tertiary;
|
||||
}
|
||||
}
|
||||
button {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
|
||||
mat-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
&:focus-within {
|
||||
border-color: $accent-blue;
|
||||
}
|
||||
}
|
||||
|
||||
.guide-toolbar__input--search {
|
||||
width: 230px;
|
||||
}
|
||||
|
||||
.guide-toolbar__count {
|
||||
font: 10.5px $font-mono;
|
||||
color: $text-tertiary;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// CDK menus render outside the component, so the scope menu needs a piercing
|
||||
// rule rather than component-scoped styling.
|
||||
::ng-deep .guide-toolbar__scope-menu {
|
||||
max-height: 60vh;
|
||||
|
||||
.is-selected {
|
||||
color: var(--app-selection-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { DatePipe } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { EpgDateNavigationDirection } from '../epg-date';
|
||||
import {
|
||||
EPG_GUIDE_ZOOM_MAX,
|
||||
EPG_GUIDE_ZOOM_MIN,
|
||||
EPG_GUIDE_ZOOM_STEP,
|
||||
EpgGuideDensity,
|
||||
} from './epg-guide-layout.util';
|
||||
import { EpgGuideScope } from './epg-guide-source';
|
||||
|
||||
/**
|
||||
* Guide toolbar: day stepper, "Now", scope menu, "Only with EPG", density,
|
||||
* zoom, the channel filter and (when the host supports it) programme search.
|
||||
* Purely presentational — every control reports through an output and the
|
||||
* guide shell owns the state.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-epg-guide-toolbar',
|
||||
imports: [
|
||||
DatePipe,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatMenuModule,
|
||||
MatTooltipModule,
|
||||
TranslatePipe,
|
||||
],
|
||||
templateUrl: './epg-guide-toolbar.component.html',
|
||||
styleUrl: './epg-guide-toolbar.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class EpgGuideToolbarComponent {
|
||||
readonly dayMs = input.required<number>();
|
||||
readonly isToday = input(false);
|
||||
readonly currentLocale = input('en');
|
||||
readonly scopes = input<EpgGuideScope[]>([]);
|
||||
readonly scopeId = input('');
|
||||
readonly onlyWithEpg = input(false);
|
||||
readonly coverageLoaded = input(false);
|
||||
readonly density = input<EpgGuideDensity>('comfortable');
|
||||
readonly zoom = input(EPG_GUIDE_ZOOM_MIN);
|
||||
readonly filter = input('');
|
||||
readonly searchEnabled = input(false);
|
||||
readonly searchQuery = input('');
|
||||
readonly shownCount = input(0);
|
||||
readonly totalCount = input(0);
|
||||
|
||||
readonly stepDay = output<EpgDateNavigationDirection>();
|
||||
readonly jumpNow = output<void>();
|
||||
readonly scopeChange = output<string>();
|
||||
readonly onlyWithEpgChange = output<boolean>();
|
||||
readonly densityChange = output<EpgGuideDensity>();
|
||||
readonly zoomChange = output<number>();
|
||||
readonly filterChange = output<string>();
|
||||
readonly searchQueryChange = output<string>();
|
||||
|
||||
readonly zoomMin = EPG_GUIDE_ZOOM_MIN;
|
||||
readonly zoomMax = EPG_GUIDE_ZOOM_MAX;
|
||||
readonly zoomStep = EPG_GUIDE_ZOOM_STEP;
|
||||
|
||||
readonly scopeLabel = computed(
|
||||
() =>
|
||||
this.scopes().find((scope) => scope.id === this.scopeId())?.label ??
|
||||
''
|
||||
);
|
||||
readonly densityIcon = computed(() =>
|
||||
this.density() === 'comfortable' ? 'density_medium' : 'density_small'
|
||||
);
|
||||
readonly densityLabelKey = computed(() =>
|
||||
this.density() === 'comfortable'
|
||||
? 'EPG.GUIDE.DENSITY_COMPACT'
|
||||
: 'EPG.GUIDE.DENSITY_COMFORTABLE'
|
||||
);
|
||||
|
||||
toggleDensity(): void {
|
||||
this.densityChange.emit(
|
||||
this.density() === 'comfortable' ? 'compact' : 'comfortable'
|
||||
);
|
||||
}
|
||||
|
||||
onZoomInput(event: Event): void {
|
||||
this.zoomChange.emit((event.target as HTMLInputElement).valueAsNumber);
|
||||
}
|
||||
|
||||
onFilterInput(event: Event): void {
|
||||
this.filterChange.emit((event.target as HTMLInputElement).value);
|
||||
}
|
||||
|
||||
onSearchInput(event: Event): void {
|
||||
this.searchQueryChange.emit((event.target as HTMLInputElement).value);
|
||||
}
|
||||
|
||||
/**
|
||||
* The guide's keyboard controller ignores keys typed into inputs, so Esc
|
||||
* is handled here: it first clears a non-empty field, and only blurs an
|
||||
* already-empty one so the next Esc reaches the guide and closes it.
|
||||
*/
|
||||
onFilterEscape(event: Event): void {
|
||||
this.onEscape(event, this.filter(), (value) =>
|
||||
this.filterChange.emit(value)
|
||||
);
|
||||
}
|
||||
|
||||
onSearchEscape(event: Event): void {
|
||||
this.onEscape(event, this.searchQuery(), (value) =>
|
||||
this.searchQueryChange.emit(value)
|
||||
);
|
||||
}
|
||||
|
||||
private onEscape(
|
||||
event: Event,
|
||||
value: string,
|
||||
clear: (value: string) => void
|
||||
): void {
|
||||
if (value.length > 0) {
|
||||
event.stopPropagation();
|
||||
clear('');
|
||||
return;
|
||||
}
|
||||
(event.target as HTMLElement | null)?.blur();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { ListRange } from '@angular/cdk/collections';
|
||||
import { DestroyRef } from '@angular/core';
|
||||
import { CdkVirtualScrollViewport } from '@angular/cdk/scrolling';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { TimelineRenderBlock } from '../epg-timeline/epg-timeline-render.util';
|
||||
import { EpgGuideFocus } from './epg-guide-keyboard.controller';
|
||||
import { EPG_GUIDE_ROW_BUFFER } from './epg-guide-layout.util';
|
||||
import {
|
||||
guideBlockRevealScrollLeft,
|
||||
guideNowScrollLeft,
|
||||
guideRowNeedsReveal,
|
||||
scrollElementLeft,
|
||||
} from './epg-guide-scroll.util';
|
||||
import { EpgGuideChannel } from './epg-guide-source';
|
||||
|
||||
/** Rows kept above the keyboard focus when it is scrolled into view. */
|
||||
const FOCUS_ROW_MARGIN = 2;
|
||||
/** Rows kept above the playing channel when the guide jumps to now. */
|
||||
const ACTIVE_ROW_MARGIN = 3;
|
||||
/** Rows requested before the viewport has reported a rendered range. */
|
||||
const INITIAL_LOAD_ROWS = 30;
|
||||
|
||||
export interface EpgGuideViewportHost {
|
||||
viewport(): CdkVirtualScrollViewport | undefined;
|
||||
rows(): readonly EpgGuideChannel[];
|
||||
rowHeightPx(): number;
|
||||
channelColumnPx(): number;
|
||||
blocksFor(row: number): TimelineRenderBlock[];
|
||||
activeRow(): number;
|
||||
ensureLoaded(channels: readonly EpgGuideChannel[]): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrolling and lazy loading for the guide's virtual viewport: which rows the
|
||||
* programme cache is asked about, where "now" and the keyboard focus are
|
||||
* scrolled to. Split out of the shell component, which owns state and
|
||||
* rendering only.
|
||||
*/
|
||||
export class EpgGuideViewportController {
|
||||
private renderedRange: ListRange | null = null;
|
||||
|
||||
constructor(private readonly host: EpgGuideViewportHost) {}
|
||||
|
||||
/** Reload as the viewport renders new rows, until the host is destroyed. */
|
||||
watch(viewport: CdkVirtualScrollViewport, destroyRef: DestroyRef): void {
|
||||
viewport.renderedRangeStream
|
||||
.pipe(takeUntilDestroyed(destroyRef))
|
||||
.subscribe((range) => {
|
||||
this.renderedRange = range;
|
||||
this.loadRenderedRange();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Request programmes for the rendered rows plus a buffer. Before the
|
||||
* viewport has measured itself (it reports nothing in a zero-sized host,
|
||||
* e.g. jsdom) the first screenful is requested instead, so the guide is
|
||||
* never blank waiting for a range that will not arrive.
|
||||
*/
|
||||
loadRenderedRange(): void {
|
||||
const rows = this.host.rows();
|
||||
const range = this.renderedRange ?? {
|
||||
start: 0,
|
||||
end: Math.min(rows.length, INITIAL_LOAD_ROWS),
|
||||
};
|
||||
const start = Math.max(0, range.start - EPG_GUIDE_ROW_BUFFER);
|
||||
const end = Math.min(rows.length, range.end + EPG_GUIDE_ROW_BUFFER);
|
||||
this.host.ensureLoaded(rows.slice(start, end));
|
||||
}
|
||||
|
||||
/** Put the now-line into view, and the playing channel's row with it. */
|
||||
scrollToNow(nowLeftPx: number | null, animate: boolean): void {
|
||||
const viewport = this.host.viewport();
|
||||
if (!viewport || nowLeftPx === null) {
|
||||
return;
|
||||
}
|
||||
const element = viewport.elementRef.nativeElement;
|
||||
scrollElementLeft(
|
||||
element,
|
||||
guideNowScrollLeft(
|
||||
element.clientWidth,
|
||||
nowLeftPx,
|
||||
this.host.channelColumnPx()
|
||||
),
|
||||
animate
|
||||
);
|
||||
const activeRow = this.host.activeRow();
|
||||
if (activeRow >= 0) {
|
||||
viewport.scrollToIndex(
|
||||
Math.max(0, activeRow - ACTIVE_ROW_MARGIN),
|
||||
animate ? 'smooth' : 'auto'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Keep the keyboard focus target inside the viewport, both axes. */
|
||||
revealFocus(focused: EpgGuideFocus | null): void {
|
||||
const viewport = this.host.viewport();
|
||||
if (!focused || !viewport) {
|
||||
return;
|
||||
}
|
||||
const element = viewport.elementRef.nativeElement;
|
||||
if (
|
||||
guideRowNeedsReveal(
|
||||
focused.row,
|
||||
this.host.rowHeightPx(),
|
||||
viewport.measureScrollOffset('top'),
|
||||
element.clientHeight
|
||||
)
|
||||
) {
|
||||
viewport.scrollToIndex(Math.max(0, focused.row - FOCUS_ROW_MARGIN));
|
||||
}
|
||||
if (focused.block === null) {
|
||||
return;
|
||||
}
|
||||
const block = this.host.blocksFor(focused.row)[focused.block];
|
||||
const left =
|
||||
block &&
|
||||
guideBlockRevealScrollLeft(
|
||||
block,
|
||||
element.scrollLeft,
|
||||
element.clientWidth,
|
||||
this.host.channelColumnPx()
|
||||
);
|
||||
if (typeof left === 'number') {
|
||||
scrollElementLeft(element, left, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
<app-epg-guide-toolbar
|
||||
[dayMs]="axis().startMs"
|
||||
[isToday]="isToday()"
|
||||
[currentLocale]="currentLocale()"
|
||||
[scopes]="scopes()"
|
||||
[scopeId]="scopeId()"
|
||||
[onlyWithEpg]="onlyWithEpg()"
|
||||
[coverageLoaded]="coverageLoaded()"
|
||||
[density]="density()"
|
||||
[zoom]="zoom()"
|
||||
[filter]="filter()"
|
||||
[searchEnabled]="searchEnabled"
|
||||
[searchQuery]="searchQuery()"
|
||||
[shownCount]="rows().length"
|
||||
[totalCount]="totalCount()"
|
||||
(stepDay)="stepDay($event)"
|
||||
(jumpNow)="jumpNow()"
|
||||
(scopeChange)="setScope($event)"
|
||||
(onlyWithEpgChange)="setOnlyWithEpg($event)"
|
||||
(densityChange)="setDensity($event)"
|
||||
(zoomChange)="setZoom($event)"
|
||||
(filterChange)="setFilter($event)"
|
||||
(searchQueryChange)="onSearchQueryChange($event)"
|
||||
/>
|
||||
|
||||
@if (searchEnabled && searchQuery().trim().length >= 2) {
|
||||
<div class="epg-guide__search-results" role="listbox">
|
||||
@for (
|
||||
hit of searchResults();
|
||||
track hit.program.channel + hit.program.start
|
||||
) {
|
||||
<button
|
||||
type="button"
|
||||
class="epg-guide__search-result"
|
||||
role="option"
|
||||
[attr.aria-selected]="false"
|
||||
(click)="openSearchResult(hit)"
|
||||
>
|
||||
<span class="epg-guide__search-title">{{
|
||||
hit.program.title
|
||||
}}</span>
|
||||
<span class="epg-guide__search-meta">
|
||||
{{
|
||||
hit.program.start
|
||||
| date: 'EEE d MMM HH:mm' : '' : currentLocale()
|
||||
}}
|
||||
· {{ hit.program.channel }}
|
||||
</span>
|
||||
</button>
|
||||
} @empty {
|
||||
<div class="epg-guide__search-empty">
|
||||
{{ 'EPG.GUIDE.NO_RESULTS' | translate }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div
|
||||
class="epg-guide__grid"
|
||||
[style.--epg-guide-channel-col.px]="channelColumnPx"
|
||||
>
|
||||
<div class="epg-guide__ruler">
|
||||
<div class="epg-guide__corner">
|
||||
{{ 'EPG.GUIDE.CHANNEL' | translate }}
|
||||
</div>
|
||||
<div class="epg-guide__ruler-clip">
|
||||
<div
|
||||
class="epg-guide__ruler-track"
|
||||
[style.width.px]="trackWidthPx()"
|
||||
[style.transform]="'translateX(' + -scrollLeft() + 'px)'"
|
||||
>
|
||||
@for (tick of ticks(); track tick.ms) {
|
||||
<div
|
||||
class="epg-guide__tick"
|
||||
[class.is-half]="tick.kind === 'half'"
|
||||
[style.left.px]="tick.leftPx"
|
||||
>
|
||||
{{ tick.ms | date: 'HH:mm' }}
|
||||
</div>
|
||||
}
|
||||
@if (nowLeftPx(); as nowX) {
|
||||
<div class="epg-guide__now-badge" [style.left.px]="nowX">
|
||||
{{ nowMs() | date: 'HH:mm' }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<cdk-virtual-scroll-viewport
|
||||
#viewport
|
||||
class="epg-guide__viewport"
|
||||
[itemSize]="rowHeightPx()"
|
||||
[minBufferPx]="rowHeightPx() * 6"
|
||||
[maxBufferPx]="rowHeightPx() * 12"
|
||||
(scroll)="onViewportScroll($event)"
|
||||
>
|
||||
<app-epg-guide-row
|
||||
*cdkVirtualFor="
|
||||
let channel of rows();
|
||||
trackBy: trackRow;
|
||||
let index = index
|
||||
"
|
||||
[channel]="channel"
|
||||
[programs]="programsFor(channel.id)"
|
||||
[status]="statusFor(channel.id)"
|
||||
[axis]="axis()"
|
||||
[hourWidthPx]="zoom()"
|
||||
[nowMs]="nowMs()"
|
||||
[offsetMinutes]="offsetMinutes()"
|
||||
[catchUpAvailable]="catchUpAvailable"
|
||||
[active]="channel.id === activeChannelId()"
|
||||
[density]="density()"
|
||||
[rowFocused]="focus()?.row === index"
|
||||
[focusedBlock]="focusedBlockFor(index)"
|
||||
[style.height.px]="rowHeightPx()"
|
||||
(channelActivated)="activateRow(channel)"
|
||||
(channelCommitted)="commitRow(channel)"
|
||||
(detailsRequested)="openDetails(channel, $event)"
|
||||
(watchRequested)="watch(channel, $event)"
|
||||
/>
|
||||
@if (rows().length === 0) {
|
||||
<div class="epg-guide__no-rows">
|
||||
<mat-icon>search_off</mat-icon>
|
||||
<p>
|
||||
{{
|
||||
'EPG.GUIDE.NO_CHANNELS' | translate: { query: filter() }
|
||||
}}
|
||||
</p>
|
||||
@if (filter()) {
|
||||
<button type="button" mat-button (click)="setFilter('')">
|
||||
{{ 'EPG.GUIDE.CLEAR_FILTER' | translate }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</cdk-virtual-scroll-viewport>
|
||||
|
||||
@if (nowLeftPx(); as nowX) {
|
||||
<div
|
||||
class="epg-guide__now-line"
|
||||
[style.left.px]="channelColumnPx + nowX - scrollLeft()"
|
||||
></div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="epg-guide__keys" aria-hidden="true">
|
||||
<span
|
||||
><kbd>↑</kbd><kbd>↓</kbd
|
||||
>{{ 'EPG.GUIDE.KEYS.CHANNEL' | translate }}</span
|
||||
>
|
||||
<span
|
||||
><kbd>←</kbd><kbd>→</kbd
|
||||
>{{ 'EPG.GUIDE.KEYS.PROGRAMME' | translate }}</span
|
||||
>
|
||||
<span><kbd>Enter</kbd>{{ 'EPG.GUIDE.KEYS.PLAY' | translate }}</span>
|
||||
<span><kbd>I</kbd>{{ 'EPG.GUIDE.KEYS.DETAILS' | translate }}</span>
|
||||
<span><kbd>N</kbd>{{ 'EPG.GUIDE.KEYS.NOW' | translate }}</span>
|
||||
<span
|
||||
><kbd>PgUp</kbd><kbd>PgDn</kbd
|
||||
>{{ 'EPG.GUIDE.KEYS.DAY' | translate }}</span
|
||||
>
|
||||
<span><kbd>Esc</kbd>{{ 'EPG.GUIDE.KEYS.CLOSE' | translate }}</span>
|
||||
</div>
|
||||
@@ -0,0 +1,199 @@
|
||||
@use '../epg-theme' as *;
|
||||
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
background: var(--app-content-bg);
|
||||
color: $text-primary;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.epg-guide__grid {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.epg-guide__ruler {
|
||||
display: flex;
|
||||
height: 34px;
|
||||
flex: 0 0 auto;
|
||||
border-bottom: 1px solid $line-strong;
|
||||
background: var(--app-content-bg);
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.epg-guide__corner {
|
||||
flex: 0 0 var(--epg-guide-channel-col);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 14px;
|
||||
font: 500 10px $font-mono;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: $text-tertiary;
|
||||
border-right: 1px solid $line-strong;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.epg-guide__ruler-clip {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.epg-guide__ruler-track {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.epg-guide__tick {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
padding-left: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-left: 1px solid $line-strong;
|
||||
font: 11px $font-mono;
|
||||
color: $text-secondary;
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
&.is-half {
|
||||
border-left-color: $line;
|
||||
color: $text-tertiary;
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.epg-guide__now-badge {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
transform: translateX(-50%);
|
||||
background: $accent-live;
|
||||
color: #1a0d0d;
|
||||
font: 600 10.5px $font-mono;
|
||||
padding: 3px 7px;
|
||||
border-radius: 5px;
|
||||
z-index: 5;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.epg-guide__viewport {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
|
||||
::ng-deep .cdk-virtual-scroll-content-wrapper {
|
||||
min-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.epg-guide__now-line {
|
||||
position: absolute;
|
||||
top: 34px;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: $accent-live;
|
||||
box-shadow: 0 0 10px color-mix(in srgb, $accent-live 60%, transparent);
|
||||
pointer-events: none;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.epg-guide__no-rows {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 48px 16px;
|
||||
color: $text-tertiary;
|
||||
|
||||
mat-icon {
|
||||
font-size: 32px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.epg-guide__keys {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
height: 34px;
|
||||
padding: 0 18px;
|
||||
border-top: 1px solid $line;
|
||||
font-size: 11px;
|
||||
color: $text-tertiary;
|
||||
flex: 0 0 auto;
|
||||
|
||||
kbd {
|
||||
font: 10px $font-mono;
|
||||
color: $text-secondary;
|
||||
border: 1px solid $line-strong;
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.epg-guide__search-results {
|
||||
position: absolute;
|
||||
top: 52px;
|
||||
right: 18px;
|
||||
width: 420px;
|
||||
max-height: 50vh;
|
||||
overflow: auto;
|
||||
z-index: 20;
|
||||
background: $surface-1;
|
||||
border: 1px solid $line-strong;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.35);
|
||||
padding: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.epg-guide__search-result {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: $text-primary;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background: $surface-3;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.epg-guide__search-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.epg-guide__search-meta {
|
||||
font: 10.5px $font-mono;
|
||||
color: $text-tertiary;
|
||||
}
|
||||
|
||||
.epg-guide__search-empty {
|
||||
padding: 14px;
|
||||
text-align: center;
|
||||
color: $text-tertiary;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { SettingsStore } from '@iptvnator/services';
|
||||
import { EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { BehaviorSubject, of } from 'rxjs';
|
||||
import { EpgProgrammeDialogService } from '../epg-programme-dialog.service';
|
||||
import { EpgGuideComponent } from './epg-guide.component';
|
||||
import {
|
||||
EPG_GUIDE_DENSITY_KEY,
|
||||
EPG_GUIDE_ONLY_WITH_EPG_KEY,
|
||||
} from './epg-guide-preferences';
|
||||
import { EpgGuideToolbarComponent } from './epg-guide-toolbar.component';
|
||||
import {
|
||||
EPG_GUIDE_SOURCE,
|
||||
EpgGuideChannel,
|
||||
EpgGuideSource,
|
||||
} from './epg-guide-source';
|
||||
|
||||
function channel(
|
||||
id: string,
|
||||
epgKey: string | null = id,
|
||||
number = 1
|
||||
): EpgGuideChannel {
|
||||
return { id, number, name: `Channel ${id}`, logoUrl: null, epgKey };
|
||||
}
|
||||
|
||||
function nowProgram(channelId: string): EpgProgram {
|
||||
const start = new Date(Date.now() - 10 * 60_000);
|
||||
const stop = new Date(Date.now() + 20 * 60_000);
|
||||
return {
|
||||
start: start.toISOString(),
|
||||
stop: stop.toISOString(),
|
||||
channel: channelId,
|
||||
title: `${channelId} now`,
|
||||
desc: null,
|
||||
category: null,
|
||||
};
|
||||
}
|
||||
|
||||
async function settle(fixture: ComponentFixture<unknown>): Promise<void> {
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
}
|
||||
|
||||
describe('EpgGuideComponent', () => {
|
||||
let fixture: ComponentFixture<EpgGuideComponent>;
|
||||
let component: EpgGuideComponent;
|
||||
const channels = signal<EpgGuideChannel[]>([]);
|
||||
const activeChannelId = signal<string | null>(null);
|
||||
const activate = jest.fn();
|
||||
const setScope = jest.fn();
|
||||
const dialogOpen = jest.fn(() => of(undefined));
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
activate.mockReset();
|
||||
setScope.mockReset();
|
||||
dialogOpen.mockClear();
|
||||
channels.set([
|
||||
channel('a', 'a', 1),
|
||||
channel('b', null, 2),
|
||||
channel('c', 'c', 3),
|
||||
]);
|
||||
activeChannelId.set('a');
|
||||
const source: EpgGuideSource = {
|
||||
channels,
|
||||
scopes: signal([{ id: 'all', label: 'All channels', kind: 'all' }]),
|
||||
scopeId: signal('all'),
|
||||
setScope,
|
||||
loadPrograms: async (window) =>
|
||||
new Map(
|
||||
window.channels.map((item) => [
|
||||
item.id,
|
||||
item.id === 'a' ? [nowProgram('a')] : [],
|
||||
])
|
||||
),
|
||||
loadCoverage: async () => new Set(['a']),
|
||||
activeChannelId,
|
||||
activate,
|
||||
};
|
||||
TestBed.configureTestingModule({
|
||||
imports: [EpgGuideComponent],
|
||||
providers: [
|
||||
{ provide: EPG_GUIDE_SOURCE, useValue: source },
|
||||
{
|
||||
provide: EpgProgrammeDialogService,
|
||||
useValue: { open: dialogOpen },
|
||||
},
|
||||
{ provide: MatDialog, useValue: { openDialogs: [] } },
|
||||
{
|
||||
provide: SettingsStore,
|
||||
useValue: { resolvedEpgOffsetMinutes: signal(0) },
|
||||
},
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
currentLang: 'en',
|
||||
defaultLang: 'en',
|
||||
onLangChange: new BehaviorSubject(null),
|
||||
onTranslationChange: new BehaviorSubject(null),
|
||||
onDefaultLangChange: new BehaviorSubject(null),
|
||||
instant: (key: string) => key,
|
||||
get: (key: string) => of(key),
|
||||
stream: (key: string) => of(key),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
fixture = TestBed.createComponent(EpgGuideComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it('lists the scope channels in order and marks the active one', async () => {
|
||||
await settle(fixture);
|
||||
expect(component.rows().map((row) => row.id)).toEqual(['a', 'b', 'c']);
|
||||
expect(component.activeRowIndex()).toBe(0);
|
||||
});
|
||||
|
||||
it('emits activate on click and activate + close on double click / Enter', async () => {
|
||||
await settle(fixture);
|
||||
const close = jest.fn();
|
||||
component.close.subscribe(close);
|
||||
component.activateRow(component.rows()[2]);
|
||||
expect(activate).toHaveBeenCalledWith('c');
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
component.commitRow(component.rows()[2]);
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
component.onKeydown(new KeyboardEvent('keydown', { key: 'Enter' }));
|
||||
expect(activate).toHaveBeenLastCalledWith('a');
|
||||
expect(close).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('hides only uncovered rows when "Only with EPG" is on, after coverage arrived', async () => {
|
||||
await settle(fixture);
|
||||
component.setOnlyWithEpg(true);
|
||||
await settle(fixture);
|
||||
expect(component.rows().map((row) => row.id)).toEqual(['a']);
|
||||
expect(localStorage.getItem(EPG_GUIDE_ONLY_WITH_EPG_KEY)).toBe('1');
|
||||
});
|
||||
|
||||
it('filters by channel name and clears', async () => {
|
||||
await settle(fixture);
|
||||
component.setFilter('channel c');
|
||||
expect(component.rows().map((row) => row.id)).toEqual(['c']);
|
||||
component.setFilter('');
|
||||
expect(component.rows()).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('persists density and zoom and restores them', async () => {
|
||||
await settle(fixture);
|
||||
component.setDensity('compact');
|
||||
component.setZoom(9_999);
|
||||
await settle(fixture);
|
||||
expect(localStorage.getItem(EPG_GUIDE_DENSITY_KEY)).toBe('compact');
|
||||
expect(component.zoom()).toBe(480);
|
||||
expect(component.rowHeightPx()).toBe(44);
|
||||
});
|
||||
|
||||
it('opens the programme dialog for a non-live card and activates on "live"', async () => {
|
||||
await settle(fixture);
|
||||
dialogOpen.mockReturnValueOnce(of('live'));
|
||||
const row = component.rows()[0];
|
||||
const item = {
|
||||
kind: 'block' as const,
|
||||
key: 'k',
|
||||
block: {
|
||||
program: nowProgram('a'),
|
||||
key: 'k',
|
||||
startMs: 0,
|
||||
stopMs: 1,
|
||||
when: 'past' as const,
|
||||
offsetMin: 0,
|
||||
durationMin: 1,
|
||||
},
|
||||
leftPx: 0,
|
||||
widthPx: 10,
|
||||
tier: 'wide' as const,
|
||||
nowFillPercent: 0,
|
||||
canCatchUp: false,
|
||||
};
|
||||
component.openDetails(row, item);
|
||||
expect(dialogOpen).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channelName: 'Channel a',
|
||||
primaryAction: null,
|
||||
archiveUnavailableNote: true,
|
||||
})
|
||||
);
|
||||
expect(activate).toHaveBeenCalledWith('a');
|
||||
});
|
||||
|
||||
it('closes on Escape and steps days with PageUp/PageDown', async () => {
|
||||
await settle(fixture);
|
||||
const close = jest.fn();
|
||||
component.close.subscribe(close);
|
||||
const before = component.dayKey();
|
||||
component.onKeydown(new KeyboardEvent('keydown', { key: 'PageDown' }));
|
||||
expect(component.dayKey()).not.toBe(before);
|
||||
component.onKeydown(new KeyboardEvent('keydown', { key: 'Escape' }));
|
||||
expect(close).toHaveBeenCalled();
|
||||
expect(
|
||||
fixture.debugElement.query(By.css('app-epg-guide-toolbar'))
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('clears the channel filter on Escape inside the toolbar field, then lets Escape through', async () => {
|
||||
await settle(fixture);
|
||||
component.setFilter('channel c');
|
||||
await settle(fixture);
|
||||
const toolbar = fixture.debugElement.query(
|
||||
By.directive(EpgGuideToolbarComponent)
|
||||
);
|
||||
const input: HTMLInputElement = toolbar.query(
|
||||
By.css('.guide-toolbar__input input')
|
||||
).nativeElement;
|
||||
const cleared = jest.fn();
|
||||
toolbar.componentInstance.filterChange.subscribe(cleared);
|
||||
const blur = jest.spyOn(input, 'blur');
|
||||
|
||||
input.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
);
|
||||
expect(cleared).toHaveBeenCalledWith('');
|
||||
expect(blur).not.toHaveBeenCalled();
|
||||
|
||||
component.setFilter('');
|
||||
await settle(fixture);
|
||||
input.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
);
|
||||
expect(blur).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,378 @@
|
||||
import {
|
||||
CdkVirtualScrollViewport,
|
||||
ScrollingModule,
|
||||
} from '@angular/cdk/scrolling';
|
||||
import { DatePipe } from '@angular/common';
|
||||
import {
|
||||
afterNextRender,
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
DestroyRef,
|
||||
effect,
|
||||
HostListener,
|
||||
inject,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
output,
|
||||
signal,
|
||||
untracked,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { normalizeDateLocale } from '@iptvnator/pipes';
|
||||
import { SettingsStore } from '@iptvnator/services';
|
||||
import { EpgProgram, epgProviderClockMs } from '@iptvnator/shared/interfaces';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { startWith } from 'rxjs';
|
||||
import {
|
||||
EpgDateNavigationDirection,
|
||||
getTodayEpgDateKey,
|
||||
shiftEpgDateKey,
|
||||
} from '../epg-date';
|
||||
import { EpgItemDialogAction } from '../epg-item-description/epg-item-description.component';
|
||||
import { EpgProgrammeDialogService } from '../epg-programme-dialog.service';
|
||||
import { TimelineRenderBlock } from '../epg-timeline/epg-timeline-render.util';
|
||||
import { EpgGuideKeyboardController } from './epg-guide-keyboard.controller';
|
||||
import {
|
||||
buildGuideDayAxis,
|
||||
buildGuideRowBlocks,
|
||||
buildGuideTicks,
|
||||
EPG_GUIDE_CHANNEL_COLUMN_PX,
|
||||
EPG_GUIDE_ROW_HEIGHT_PX,
|
||||
EpgGuideDensity,
|
||||
guideNowLeftPx,
|
||||
guideTrackWidthPx,
|
||||
} from './epg-guide-layout.util';
|
||||
import {
|
||||
clampGuideZoom,
|
||||
persistEpgGuidePreferences,
|
||||
restoreEpgGuidePreferences,
|
||||
} from './epg-guide-preferences';
|
||||
import {
|
||||
EpgGuideProgramsService,
|
||||
EpgGuideRowStatus,
|
||||
} from './epg-guide-programs.service';
|
||||
import { EpgGuideRowComponent } from './epg-guide-row.component';
|
||||
import { EpgGuideSearchController } from './epg-guide-search.controller';
|
||||
import {
|
||||
EPG_GUIDE_SOURCE,
|
||||
EpgGuideChannel,
|
||||
EpgGuideSearchHit,
|
||||
} from './epg-guide-source';
|
||||
import { EpgGuideToolbarComponent } from './epg-guide-toolbar.component';
|
||||
import { EpgGuideViewportController } from './epg-guide-viewport.controller';
|
||||
|
||||
/**
|
||||
* Multi-channel programme guide. Reads everything from `EPG_GUIDE_SOURCE`;
|
||||
* owns the selected day, zoom, density, filters and keyboard navigation.
|
||||
* Rows are virtualised; the channel column and the ruler are sticky.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-epg-guide',
|
||||
imports: [
|
||||
DatePipe,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
ScrollingModule,
|
||||
TranslatePipe,
|
||||
EpgGuideRowComponent,
|
||||
EpgGuideToolbarComponent,
|
||||
],
|
||||
providers: [EpgGuideProgramsService],
|
||||
templateUrl: './epg-guide.component.html',
|
||||
styleUrl: './epg-guide.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class EpgGuideComponent implements OnInit, OnDestroy {
|
||||
private readonly source = inject(EPG_GUIDE_SOURCE);
|
||||
private readonly programsService = inject(EpgGuideProgramsService);
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
private readonly programmeDialog = inject(EpgProgrammeDialogService);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
readonly close = output<void>();
|
||||
readonly channelActivated = output<string>();
|
||||
|
||||
readonly viewport = viewChild<CdkVirtualScrollViewport>('viewport');
|
||||
|
||||
private readonly preferences = restoreEpgGuidePreferences();
|
||||
readonly dayKey = signal(getTodayEpgDateKey());
|
||||
readonly zoom = signal(this.preferences.zoom);
|
||||
readonly density = signal<EpgGuideDensity>(this.preferences.density);
|
||||
readonly onlyWithEpg = signal(this.preferences.onlyWithEpg);
|
||||
readonly filter = signal('');
|
||||
readonly nowMs = signal(Date.now());
|
||||
readonly scrollLeft = signal(0);
|
||||
|
||||
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 offsetMinutes = this.settingsStore.resolvedEpgOffsetMinutes;
|
||||
readonly scopes = this.source.scopes;
|
||||
readonly scopeId = this.source.scopeId;
|
||||
readonly activeChannelId = this.source.activeChannelId;
|
||||
readonly coverageLoaded = this.programsService.coverageLoaded;
|
||||
private readonly search = new EpgGuideSearchController(
|
||||
this.source.searchPrograms?.bind(this.source)
|
||||
);
|
||||
readonly searchEnabled = this.search.enabled;
|
||||
readonly searchQuery = this.search.query;
|
||||
readonly searchResults = this.search.results;
|
||||
readonly catchUpAvailable = this.source.catchUp !== undefined;
|
||||
readonly channelColumnPx = EPG_GUIDE_CHANNEL_COLUMN_PX;
|
||||
|
||||
readonly axis = computed(() => buildGuideDayAxis(this.dayKey()));
|
||||
readonly isToday = computed(() => {
|
||||
this.nowMs();
|
||||
return this.dayKey() === getTodayEpgDateKey();
|
||||
});
|
||||
readonly ticks = computed(() => buildGuideTicks(this.axis(), this.zoom()));
|
||||
readonly trackWidthPx = computed(() =>
|
||||
guideTrackWidthPx(this.axis(), this.zoom())
|
||||
);
|
||||
readonly nowLeftPx = computed(() =>
|
||||
guideNowLeftPx(this.axis(), this.nowMs(), this.zoom())
|
||||
);
|
||||
readonly rowHeightPx = computed(
|
||||
() => EPG_GUIDE_ROW_HEIGHT_PX[this.density()]
|
||||
);
|
||||
readonly totalCount = computed(() => this.source.channels().length);
|
||||
readonly rows = computed(() => {
|
||||
const needle = this.filter().trim().toLowerCase();
|
||||
const onlyWithEpg = this.onlyWithEpg();
|
||||
return this.source
|
||||
.channels()
|
||||
.filter(
|
||||
(channel) =>
|
||||
(!needle || channel.name.toLowerCase().includes(needle)) &&
|
||||
(!onlyWithEpg || this.programsService.isCovered(channel.id))
|
||||
);
|
||||
});
|
||||
readonly activeRowIndex = computed(() => {
|
||||
const activeId = this.activeChannelId();
|
||||
return activeId === null
|
||||
? -1
|
||||
: this.rows().findIndex((channel) => channel.id === activeId);
|
||||
});
|
||||
|
||||
private readonly keyboard = new EpgGuideKeyboardController({
|
||||
rowCount: () => this.rows().length,
|
||||
blockCount: (row) => this.blocksFor(row).length,
|
||||
activeRow: () => this.activeRowIndex(),
|
||||
isBlocked: () => this.dialog.openDialogs.length > 0,
|
||||
play: (row) => this.commitRow(this.rows()[row]),
|
||||
details: (row, block) =>
|
||||
this.openDetails(this.rows()[row], this.blocksFor(row)[block]),
|
||||
jumpNow: () => this.jumpNow(),
|
||||
stepDay: (direction) => this.stepDay(direction),
|
||||
close: () => this.close.emit(),
|
||||
});
|
||||
readonly focus = this.keyboard.focus;
|
||||
|
||||
private readonly viewportController = new EpgGuideViewportController({
|
||||
viewport: () => this.viewport(),
|
||||
rows: () => this.rows(),
|
||||
rowHeightPx: () => this.rowHeightPx(),
|
||||
channelColumnPx: () => this.channelColumnPx,
|
||||
blocksFor: (row) => this.blocksFor(row),
|
||||
activeRow: () => this.activeRowIndex(),
|
||||
ensureLoaded: (channels) => this.programsService.ensureLoaded(channels),
|
||||
});
|
||||
|
||||
private minuteTimer?: number;
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const axis = this.axis();
|
||||
const offset = this.offsetMinutes();
|
||||
this.programsService.setWindow(
|
||||
epgProviderClockMs(axis.startMs, offset),
|
||||
epgProviderClockMs(axis.endMs, offset)
|
||||
);
|
||||
untracked(() => this.viewportController.loadRenderedRange());
|
||||
});
|
||||
effect(() => {
|
||||
this.rows();
|
||||
untracked(() => this.viewportController.loadRenderedRange());
|
||||
});
|
||||
effect(() =>
|
||||
persistEpgGuidePreferences({
|
||||
density: this.density(),
|
||||
zoom: this.zoom(),
|
||||
onlyWithEpg: this.onlyWithEpg(),
|
||||
})
|
||||
);
|
||||
effect(() => {
|
||||
const viewport = this.viewport();
|
||||
if (!viewport) {
|
||||
return;
|
||||
}
|
||||
untracked(() =>
|
||||
this.viewportController.watch(viewport, this.destroyRef)
|
||||
);
|
||||
});
|
||||
afterNextRender(() => this.jumpNow(false));
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.minuteTimer = window.setInterval(
|
||||
() => this.nowMs.set(Date.now()),
|
||||
60_000
|
||||
);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
window.clearInterval(this.minuteTimer);
|
||||
this.search.destroy();
|
||||
}
|
||||
|
||||
@HostListener('document:keydown', ['$event'])
|
||||
onKeydown(event: KeyboardEvent): void {
|
||||
if (this.keyboard.handle(event)) {
|
||||
event.preventDefault();
|
||||
this.viewportController.revealFocus(this.focus());
|
||||
}
|
||||
}
|
||||
|
||||
trackRow(_index: number, channel: EpgGuideChannel): string {
|
||||
return `${channel.number}:${channel.id}`;
|
||||
}
|
||||
|
||||
programsFor(channelId: string): readonly EpgProgram[] {
|
||||
return this.programsService.programsFor(channelId);
|
||||
}
|
||||
|
||||
statusFor(channelId: string): EpgGuideRowStatus {
|
||||
return this.programsService.statusFor(channelId);
|
||||
}
|
||||
|
||||
focusedBlockFor(row: number): number | null {
|
||||
const focused = this.focus();
|
||||
return focused?.row === row ? focused.block : null;
|
||||
}
|
||||
|
||||
onViewportScroll(event: Event): void {
|
||||
this.scrollLeft.set((event.target as HTMLElement).scrollLeft);
|
||||
}
|
||||
|
||||
stepDay(direction: EpgDateNavigationDirection): void {
|
||||
this.dayKey.set(shiftEpgDateKey(this.dayKey(), direction));
|
||||
this.focus.set(null);
|
||||
}
|
||||
|
||||
jumpNow(animate = true): void {
|
||||
this.dayKey.set(getTodayEpgDateKey());
|
||||
this.nowMs.set(Date.now());
|
||||
this.viewportController.scrollToNow(this.nowLeftPx(), animate);
|
||||
}
|
||||
|
||||
setScope(scopeId: string): void {
|
||||
this.source.setScope(scopeId);
|
||||
this.focus.set(null);
|
||||
}
|
||||
|
||||
setOnlyWithEpg(value: boolean): void {
|
||||
this.onlyWithEpg.set(value);
|
||||
}
|
||||
|
||||
setDensity(value: EpgGuideDensity): void {
|
||||
this.density.set(value);
|
||||
}
|
||||
|
||||
setZoom(value: number): void {
|
||||
this.zoom.set(clampGuideZoom(value));
|
||||
}
|
||||
|
||||
setFilter(value: string): void {
|
||||
this.filter.set(value);
|
||||
this.focus.set(null);
|
||||
}
|
||||
|
||||
onSearchQueryChange(query: string): void {
|
||||
this.search.setQuery(query);
|
||||
}
|
||||
|
||||
openSearchResult(hit: EpgGuideSearchHit): void {
|
||||
this.programmeDialog.open({ ...hit.program }).subscribe();
|
||||
}
|
||||
|
||||
activateRow(channel: EpgGuideChannel | undefined): void {
|
||||
if (!channel) {
|
||||
return;
|
||||
}
|
||||
this.source.activate(channel.id);
|
||||
this.channelActivated.emit(channel.id);
|
||||
}
|
||||
|
||||
commitRow(channel: EpgGuideChannel | undefined): void {
|
||||
if (!channel) {
|
||||
return;
|
||||
}
|
||||
this.activateRow(channel);
|
||||
this.close.emit();
|
||||
}
|
||||
|
||||
openDetails(
|
||||
channel: EpgGuideChannel | undefined,
|
||||
item: TimelineRenderBlock | undefined
|
||||
): void {
|
||||
if (!channel || !item) {
|
||||
return;
|
||||
}
|
||||
const when = item.block.when;
|
||||
this.programmeDialog
|
||||
.open({
|
||||
...item.block.program,
|
||||
channelName: channel.name,
|
||||
channelLogo: channel.logoUrl,
|
||||
primaryAction:
|
||||
when === 'now'
|
||||
? 'live'
|
||||
: item.canCatchUp
|
||||
? 'timeshift'
|
||||
: null,
|
||||
archiveUnavailableNote: when === 'past' && !item.canCatchUp,
|
||||
})
|
||||
.subscribe((result: EpgItemDialogAction | undefined) => {
|
||||
if (result === 'live') {
|
||||
this.activateRow(channel);
|
||||
} else if (result === 'timeshift') {
|
||||
this.source.catchUp?.watch(channel, item.block.program);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
watch(channel: EpgGuideChannel, item: TimelineRenderBlock): void {
|
||||
this.source.catchUp?.watch(channel, item.block.program);
|
||||
}
|
||||
|
||||
private blocksFor(row: number): TimelineRenderBlock[] {
|
||||
const channel = this.rows()[row];
|
||||
if (!channel) {
|
||||
return [];
|
||||
}
|
||||
return buildGuideRowBlocks(
|
||||
this.programsService.programsFor(channel.id),
|
||||
{
|
||||
axis: this.axis(),
|
||||
hourWidthPx: this.zoom(),
|
||||
nowMs: this.nowMs(),
|
||||
offsetMinutes: this.offsetMinutes(),
|
||||
catchUpAvailable: this.catchUpAvailable,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user