fix(epg): offer the Guide action in the list view too

The EPG list view mirrors the timeline's input/output contract, but the Guide
action was bound only in the timeline branch, so Settings → EPG → Guide view =
List lost the in-panel entry point. The list toolbar now carries the same
icon-only Guide button behind `guideAvailable`/`openGuide`, and the M3U
host binds it in both branches.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-06 18:08:57 +02:00
1 parent 9071bbf19b
commit a7a15d252e
7 files changed
+88 -14

No files matched your search

+1 -1
View File
@@ -1486,7 +1486,7 @@ stream_id`); it drops `series_id`/`movie_id`, so the builder pins the
- Background parsing in worker thread
- Stored in database for quick lookup
- Global display-time offset (`Settings.epgOffsetMinutes`, Settings → EPG, ±720 min, Electron only): display-only, provider data is never rewritten. Two equivalent forms in `libs/shared/interfaces/src/lib/epg-display-offset.util.ts` — `epgDisplayTimeMs` (shift the programme; `ui/epg` rendering via the `offsetMinutes` input, channel rows, dashboard/recording labels; the programme dialog and the programme guide read the store themselves) and `epgProviderClockMs` (shift "now"; every "currently airing" decision: the `GET_CURRENT_PROGRAMS_BATCH` lookup takes an explicit `nowMs` and `EpgService` tags its cache with the offset, Xtream/Stalker/M3U current-programme selection and previews, the unified collection resolver, dashboard progress, recording overlap). A consumer applies exactly one form per comparison. Contract: `docs/architecture/m3u-playlist-module.md` ("EPG display offset")
- Programme guide (Electron, M3U): `app-epg-guide` in `libs/ui/epg` fed by the host-provided `EPG_GUIDE_SOURCE`; the M3U host switches into guide mode (docked player strip, no sidebar/timeline, no remount) from the header action, the palette, the timeline's Guide button or `G`. Data: `EPG_GET_PROGRAMS_FOR_CHANNELS` / `EPG_GET_PROGRAM_COVERAGE` (keys resolved in main; manual mappings honoured). Contract: `docs/architecture/m3u-playlist-module.md` ("Programme guide").
- Programme guide (Electron, M3U): `app-epg-guide` in `libs/ui/epg` fed by the host-provided `EPG_GUIDE_SOURCE`; the M3U host switches into guide mode (docked player strip, no sidebar/timeline, no remount) from the header action, the palette, the EPG panel's Guide button (timeline or list view) or `G`. Data: `EPG_GET_PROGRAMS_FOR_CHANNELS` / `EPG_GET_PROGRAM_COVERAGE` (keys resolved in main; manual mappings honoured). Contract: `docs/architecture/m3u-playlist-module.md` ("Programme guide").
- Manual EPG mapping (Electron only): right-click a channel in any list (M3U views, Xtream portal list, Stalker ITV sidebar, global favorites) → "Map EPG channel" attaches it to an uploaded-XMLTV channel; stored in `epg_channel_mappings` keyed by the M3U lookup key or a playlist-scoped portal key (`xtream:{playlistId}:{id}` / `stalker:{playlistId}:{id}`, helpers in `libs/shared/interfaces/src/lib/epg-mapping-key.util.ts`); resolved on every EPG path (single + batch IPC lookups, portal detail views, preview queues); dialog: `libs/ui/components/src/lib/channel-list-container/epg-mapping-dialog/`
**TMDB Metadata Enrichment** (opt-in):
+14 -9
View File
@@ -764,7 +764,10 @@ matching `hasProgramsForDateKey`), sorts, and deduplicates via a pure
`app-epg-list-view-row`; and delegates its own vertical auto-focus + sticky
"now" strip to `EpgListScrollController` (`epg-list-scroll.controller.ts`). Render
states, the collapsed inline summary, the date stepper, catch-up/timeshift
activation, and the details dialog behave identically to the timeline.
activation, and the details dialog behave identically to the timeline. Both
views also carry the optional `guideAvailable` input and `openGuide` output:
the M3U host binds them in both branches, so the programme guide's Guide
action is reachable whichever view the setting selects.
- **One channel, preloaded window.** The panel always shows a single channel.
Each provider returns a multi-day window in roughly one call (M3U
@@ -1053,11 +1056,11 @@ class EpgService {
### EPG Components
| Component | Purpose |
| ----------------------------- | ------------------------------------ |
| `EpgTimelineComponent` | Horizontal timeline for one channel |
| `EpgListViewComponent` | Vertical single-day list alternative |
| `EpgItemDescriptionComponent` | Program details dialog |
| Component | Purpose |
| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `EpgTimelineComponent` | Horizontal timeline for one channel |
| `EpgListViewComponent` | Vertical single-day list alternative |
| `EpgItemDescriptionComponent` | Program details dialog |
| `EpgGuideComponent` | Multi-channel programme guide grid fed by `EPG_GUIDE_SOURCE` (rows: `EpgGuideRowComponent`, toolbar: `EpgGuideToolbarComponent`, docked strip: `EpgGuideNowPlayingComponent`) |
## Video Player
@@ -1175,8 +1178,10 @@ MPV bounds survive. While the guide is open the docked `.video-player` carries
`data-player-shortcuts-suspended`, which makes `ControlsShortcuts` (shared and
legacy player shortcuts alike) yield ↑/↓, Space, F and M to the guide's own
keyboard controller. Entry points: the workspace header action
(`m3u-epg-guide`), the command palette, the Guide button in the timeline
toolbar (`EpgTimelineComponent.openGuide`) and the `G` key on the player page.
(`m3u-epg-guide`), the command palette, the Guide button in the EPG panel's
toolbar (`openGuide` on both `EpgTimelineComponent` and `EpgListViewComponent`,
so the action survives the list-view setting) and the `G` key on the player
page.
The header action reports `disabled` whenever the guide cannot open, which
greys out the header button and disables its palette command instead of
offering a no-op. Player fullscreen, radio, recognised movies, switching to
@@ -1206,7 +1211,7 @@ request window is converted with `epgProviderClockMs`.
both IPCs from one `guideWindowCondition()` predicate — when the request
carries `sourceUrls` (portal hosts only; the M3U host never does), a row
qualifies if it belongs to one of those sources OR carries no source at all
(legacy pre-per-source-tracking data), never if it belongs to a *different*
(legacy pre-per-source-tracking data), never if it belongs to a _different_
source. Both reads cap the requested channel-key batch
(`EPG_GUIDE_MAX_CHANNELS_PER_REQUEST` = 100 for programmes,
`EPG_GUIDE_MAX_COVERAGE_KEYS_PER_REQUEST` = 2000 for the cheaper coverage
@@ -191,6 +191,7 @@
liveEpgPanelSummaryLabelKey()
"
[emptyReason]="liveEpgEmptyReason()"
[guideAvailable]="canOpenGuide()"
(programActivated)="
onTimelineProgramActivated($event)
"
@@ -202,6 +203,7 @@
onLiveEpgPanelCollapsedChange($event)
"
(openEpgSettings)="openEpgSettings()"
(openGuide)="openGuide()"
/>
} @else {
<app-epg-timeline
@@ -79,6 +79,17 @@
{{ 'EPG.TIMELINE.RETURN_TO_LIVE' | translate }}
</button>
}
@if (guideAvailable()) {
<button
type="button"
class="g-iconbtn g-guide"
(click)="openGuide.emit()"
[matTooltip]="'EPG.TIMELINE.OPEN_GUIDE' | translate"
[attr.aria-label]="'EPG.TIMELINE.OPEN_GUIDE' | translate"
>
<mat-icon>grid_view</mat-icon>
</button>
}
@if (showJump()) {
<button
type="button"
@@ -112,7 +123,8 @@
}
</span>
<small>{{
viewDate() | date: 'EEE, d MMM' : '' : currentLocale()
viewDate()
| date: 'EEE, d MMM' : '' : currentLocale()
}}</small>
</div>
<button
@@ -133,7 +145,9 @@
@if (state === 'list' && !archivePlaybackAvailable()) {
<div class="g-notice" role="note">
<mat-icon>info</mat-icon>
<span>{{ 'EPG.TIMELINE.SCHEDULE_ONLY_NOTICE' | translate }}</span>
<span>{{
'EPG.TIMELINE.SCHEDULE_ONLY_NOTICE' | translate
}}</span>
</div>
}
@@ -163,7 +177,9 @@
<span class="ns-lbl">{{
'EPG.TIMELINE.ON_NOW' | translate
}}</span>
<span class="ns-title">{{ now.program.title }}</span>
<span class="ns-title">{{
now.program.title
}}</span>
@if (now.progress !== null) {
<span class="ns-bar" aria-hidden="true"
><i [style.width.%]="now.progress"></i
@@ -138,6 +138,30 @@
font-size: 16px;
}
/* icon-only toolbar action (Guide), same footprint as the timeline's */
.g-iconbtn {
display: inline-grid;
place-items: center;
width: 34px;
height: 34px;
padding: 0;
border-radius: 999px;
background: $surface-2;
border: 1px solid $line-strong;
color: $text-secondary;
cursor: pointer;
flex: 0 0 auto;
}
.g-iconbtn:hover {
color: $text-primary;
border-color: $accent-blue;
}
.g-iconbtn mat-icon {
width: 16px;
height: 16px;
font-size: 16px;
}
.g-date {
display: inline-flex;
align-items: center;
@@ -2,7 +2,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { EpgProgram } from '@iptvnator/shared/interfaces';
import { TranslateService } from '@ngx-translate/core';
import { BehaviorSubject } from 'rxjs';
import { BehaviorSubject, of } from 'rxjs';
import { EpgListViewComponent } from './epg-list-view.component';
import { EpgListRow } from './epg-list-view.utils';
@@ -53,6 +53,9 @@ describe('EpgListViewComponent', () => {
currentLang: 'en',
defaultLang: 'en',
onLangChange: new BehaviorSubject(null),
onTranslationChange: new BehaviorSubject(null),
onDefaultLangChange: new BehaviorSubject(null),
get: (key: string) => of(key),
},
},
],
@@ -244,4 +247,25 @@ describe('EpgListViewComponent', () => {
expect(component.progress()).toBeLessThan(60);
expect(component.minutesLeft()).toBeGreaterThan(0);
});
it('offers the Guide action only when the host can open the guide, like the timeline', () => {
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.g-guide')).toBeNull();
fixture.componentRef.setInput('guideAvailable', true);
fixture.detectChanges();
const openGuide = jest.fn();
component.openGuide.subscribe(openGuide);
const button = fixture.nativeElement.querySelector(
'.g-guide'
) as HTMLButtonElement | null;
expect(button).not.toBeNull();
button?.click();
expect(openGuide).toHaveBeenCalledTimes(1);
// The collapsed toolbar row only carries the inline summary.
fixture.componentRef.setInput('collapsed', true);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.g-guide')).toBeNull();
});
});
@@ -91,6 +91,8 @@ export class EpgListViewComponent {
readonly summary = input<EpgTimelineSummary | null>(null);
readonly summaryLabelKey = input('EPG.CURRENT_PROGRAM');
readonly offsetMinutes = input(0);
/** Same as the timeline's: renders the Guide action only when the host can open the programme guide. */
readonly guideAvailable = input(false);
readonly programActivated = output<EpgProgramActivationEvent>();
readonly returnToLive = output<void>();
@@ -98,6 +100,7 @@ export class EpgListViewComponent {
readonly openEpgSettings = output<void>();
readonly retry = output<void>();
readonly collapsedChange = output<boolean>();
readonly openGuide = output<void>();
private readonly programmeDialog = inject(EpgProgrammeDialogService);
private readonly translate = inject(TranslateService);