Merge remote-tracking branch 'origin/master' into agent/download-season-queue

# Conflicts:
#	apps/electron-backend/src/app/events/database/download-requests.ts
#	libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts
#	libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts
#	libs/ui/components/src/lib/season-container/episode-download.util.ts
#	libs/ui/components/src/lib/season-container/episode-utils.spec.ts
#	libs/ui/components/src/lib/season-container/season-container.component.spec.ts
#	libs/ui/components/src/lib/season-container/season-container.component.ts
This commit is contained in:
4gray committed 2026-08-02 18:06:37 +02:00
commit 31ac306bb0
243 files changed
+12935 -1106

No files matched your search

@@ -542,6 +542,26 @@
}
}
// A phone cannot carry the poster and the details side by side: the details
// column drops to ~200px, and the action buttons are then squeezed below
// their own labels ("Play" clipped down to its icon). Stack them instead.
@media (max-width: 640px) {
.hero__content {
flex-direction: column;
align-items: stretch;
padding-inline: 16px;
}
.poster {
width: 140px;
align-self: center;
}
.details__title {
text-align: center;
}
}
// ============================================================================
// Animations
// ============================================================================
@@ -587,3 +607,14 @@ ngx-skeleton-loader {
min-height: 330px; // Fallback
aspect-ratio: 2 / 3;
}
// After the rule above so it wins the cascade at equal specificity: that
// 330px skeleton fallback is sized for the desktop poster width, while the
// stacked phone hero renders the poster 140px wide — its aspect-ratio height
// is ~210px, and the leftover minimum was pure empty space pushing the title
// and actions ~120px down on every loaded detail page.
@media (max-width: 640px) {
.poster {
min-height: 0;
}
}
+1
View File
@@ -14,4 +14,5 @@ export * from './lib/portal-inline-player/up-next-rail.util';
export * from './lib/vjs-player/vjs-player.component';
export * from './lib/video-player/sidebar/sidebar.component';
export * from './lib/vod-details/vod-details.component';
export * from './lib/web-player-view/electron-stream-headers.service';
export * from './lib/web-player-view/web-player-view.component';
@@ -122,6 +122,21 @@ describe('AudioPlayerComponent', () => {
expect(component.volume()).toBe(0);
});
it('ignores keyboard shortcuts while inside an inert region', () => {
createComponent();
component.setVolume(0.5);
// e.g. the workspace's phone context drawer marks the route content
// inert while open; document-level key listeners still fire, so the
// player must opt out itself.
fixture.nativeElement.setAttribute('inert', '');
const upEvent = createKeyboardEvent('ArrowUp');
component.handleKeyboard(upEvent);
expect(upEvent.preventDefault).not.toHaveBeenCalled();
expect(component.volume()).toBe(0.5);
});
it('restores the previous volume when unmuting', () => {
const audio = createComponent();
component.setVolume(0.65);
@@ -248,6 +248,12 @@ export class AudioPlayerComponent {
)
return;
// Inside an inert region (e.g. behind the workspace's phone context
// drawer) the player is out of the interaction model: inert strips
// pointer and Tab access, but this document-level listener still
// fires, so it must opt out itself.
if (this.hostEl.nativeElement.closest('[inert]')) return;
if (event.key === 'ArrowUp') {
event.preventDefault();
this.setVolume(this.volume() + 0.05);
@@ -0,0 +1,69 @@
import { TestBed } from '@angular/core/testing';
import { OverlayContainer } from '@angular/cdk/overlay';
import { MatDialog } from '@angular/material/dialog';
import { Subject } from 'rxjs';
import { EmbeddedMpvOverlayVisibilityService } from './embedded-mpv-overlay-visibility.service';
describe('EmbeddedMpvOverlayVisibilityService', () => {
let service: EmbeddedMpvOverlayVisibilityService;
let container: HTMLElement;
beforeEach(() => {
container = document.createElement('div');
TestBed.configureTestingModule({
providers: [
{
provide: OverlayContainer,
useValue: { getContainerElement: () => container },
},
{
provide: MatDialog,
useValue: {
openDialogs: [],
afterOpened: new Subject(),
afterAllClosed: new Subject(),
},
},
],
});
service = TestBed.inject(EmbeddedMpvOverlayVisibilityService);
});
it('starts with no active overlay', () => {
expect(service.overlayActive()).toBe(false);
});
it('treats a registered external modal surface like an open dialog', () => {
// e.g. the workspace's phone context drawer — a plain DOM panel the
// CDK-container observer can never see, but which must hide the
// native-view video that paints above DOM stacking.
const release = service.acquireExternalModalSurface();
expect(service.overlayActive()).toBe(true);
release();
expect(service.overlayActive()).toBe(false);
});
it('stays active until every external surface is released', () => {
const releaseFirst = service.acquireExternalModalSurface();
const releaseSecond = service.acquireExternalModalSurface();
releaseFirst();
expect(service.overlayActive()).toBe(true);
releaseSecond();
expect(service.overlayActive()).toBe(false);
});
it('ignores a double release', () => {
const releaseFirst = service.acquireExternalModalSurface();
const releaseSecond = service.acquireExternalModalSurface();
releaseFirst();
releaseFirst();
expect(service.overlayActive()).toBe(true);
releaseSecond();
expect(service.overlayActive()).toBe(false);
});
});
@@ -9,6 +9,7 @@ export class EmbeddedMpvOverlayVisibilityService {
private readonly overlayContainer = inject(OverlayContainer);
private readonly dialog = inject(MatDialog);
private observer: MutationObserver | null = null;
private externalModalSurfaces = 0;
constructor() {
this.dialog.afterOpened.subscribe(() => this.recompute());
@@ -26,15 +27,42 @@ export class EmbeddedMpvOverlayVisibilityService {
this.recompute();
}
/**
* Registers a modal surface that does not live in the CDK overlay
* container — e.g. the workspace's phone context drawer, a plain
* translate-in DOM panel. The native-view video surface paints above
* DOM stacking, so any such surface must hide it exactly like a
* Material dialog does. Returns a release function; releasing twice is
* a no-op so effect cleanups can call it defensively.
*/
acquireExternalModalSurface(): () => void {
this.externalModalSurfaces += 1;
this.recompute();
let released = false;
return () => {
if (released) {
return;
}
released = true;
this.externalModalSurfaces -= 1;
this.recompute();
};
}
private recompute(): void {
const dialogOpen = this.dialog.openDialogs.length > 0;
const backdropPresent =
this.overlayContainer
.getContainerElement()
.querySelector('.cdk-overlay-backdrop') !== null;
const next = dialogOpen || backdropPresent;
if (this.overlayActive() !== next) {
this.overlayActive.set(next);
}
const next =
dialogOpen || backdropPresent || this.externalModalSurfaces > 0;
// Plain set, no read-back guard: signals already skip notification
// for equal values, and reading overlayActive here would silently
// register it as a dependency of any reactive context that calls
// into this service (acquireExternalModalSurface from an effect
// looped forever on exactly that).
this.overlayActive.set(next);
}
}
@@ -402,6 +402,7 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
isAvailable: () =>
this.legacyInteractions.isAvailable() &&
!this.overlayVisibility.overlayActive(),
hostElement: () => this.playerRoot()?.nativeElement ?? null,
arrowKeysBlocked: () => this.menus.dockPanelOpen(),
onEscape: () => this.legacyInteractions.closePopovers(),
togglePaused: () => void this.togglePaused(),
@@ -30,6 +30,31 @@ describe('EmbeddedMpvShortcuts', () => {
shortcuts.detach();
});
it('ignores every shortcut while the host sits inside an inert region', () => {
const inertRegion = document.createElement('div');
inertRegion.setAttribute('inert', '');
const host = document.createElement('div');
inertRegion.appendChild(host);
document.body.appendChild(inertRegion);
(
handlers as unknown as { hostElement: () => HTMLElement }
).hostElement = () => host;
try {
expect(dispatchKey(' ')).toBe(false);
expect(dispatchKey('ArrowUp')).toBe(false);
expect(dispatchKey('m')).toBe(false);
dispatchKey('Escape');
expect(handlers.togglePaused).not.toHaveBeenCalled();
expect(handlers.adjustVolume).not.toHaveBeenCalled();
expect(handlers.toggleMute).not.toHaveBeenCalled();
expect(handlers.onEscape).not.toHaveBeenCalled();
} finally {
inertRegion.remove();
}
});
it('forwards playback, seek, volume, and fullscreen shortcuts', () => {
expect(dispatchKey(' ')).toBe(true);
expect(dispatchKey('ArrowLeft')).toBe(true);
@@ -1,5 +1,12 @@
export interface EmbeddedMpvShortcutHandlers {
isAvailable: () => boolean;
/**
* The player's host element, used to opt out of every shortcut while an
* ancestor is `inert` (e.g. behind the workspace's phone context
* drawer): inert strips pointer and Tab access, but this document-level
* listener still fires, so shortcuts must check it themselves.
*/
hostElement?: () => HTMLElement | null;
/**
* While true, arrow keys stop seeking/adjusting volume — an open dock
* chip panel owns them for chip navigation instead.
@@ -37,6 +44,13 @@ export class EmbeddedMpvShortcuts {
return;
}
// Inside an inert region the player is outside the interaction
// model entirely — the modal surface above it owns the keyboard,
// including Escape.
if (handlers.hostElement?.()?.closest('[inert]')) {
return;
}
if (event.key === 'Escape') {
handlers.onEscape();
return;
@@ -124,7 +124,7 @@ describe('HtmlVideoPlayerComponent', () => {
expect(component.playChannel).toHaveBeenCalledWith(TEST_CHANNEL);
});
it('passes channel headers and stream URL to Electron header overrides', () => {
it('does not configure Electron header overrides itself — WebPlayerViewComponent owns them', () => {
jest.spyOn(
component.videoPlayer.nativeElement,
'load'
@@ -145,39 +145,9 @@ describe('HtmlVideoPlayerComponent', () => {
url: 'https://stream.example/video.mp4',
});
expect(electronApi.setUserAgent).toHaveBeenCalledWith(
'ChannelAgent/1.0',
'https://portal.example/referrer',
'https://stream.example/video.mp4'
);
});
it('clears Electron header overrides for channels without custom headers', () => {
jest.spyOn(
component.videoPlayer.nativeElement,
'load'
).mockImplementation(() => undefined);
jest.spyOn(
component.videoPlayer.nativeElement,
'play'
).mockResolvedValue(undefined);
component.playChannel({
...TEST_CHANNEL,
http: {
'user-agent': '',
origin: '',
referrer: '',
},
radio: 'false',
url: 'https://stream.example/video.mp4',
});
expect(electronApi.setUserAgent).toHaveBeenCalledWith(
'',
'',
'https://stream.example/video.mp4'
);
// A second three-header call from here would overwrite the richer
// scoped override (incl. Cookie/Authorization) the host configured.
expect(electronApi.setUserAgent).not.toHaveBeenCalled();
});
it('replaces and reloads native video sources when switching episodes', () => {
@@ -187,18 +187,11 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
const url = channel.url + (channel.epgParams ?? '');
const extension = getPlaybackMediaExtensionFromUrl(channel.url);
void window.electron
?.setUserAgent(
channel.http?.['user-agent'],
channel.http?.referrer,
channel.url
)
.catch((error: unknown) => {
console.warn(
'[HtmlVideoPlayer] Failed to configure Electron request headers:',
error
);
});
// The scoped Electron header override is owned by
// WebPlayerViewComponent, which configures the full header set
// (incl. portal Cookie/Authorization) before this component
// receives the channel. Re-issuing the three-header call here
// would overwrite that richer override.
if (extension === 'mpd') {
debugHtmlPlayer(
@@ -56,6 +56,46 @@ describe('ControlsShortcuts', () => {
expect(handlers.toggleMute).toHaveBeenCalledTimes(1);
});
it('ignores every shortcut while the host sits inside an inert region', () => {
const inertRegion = document.createElement('div');
inertRegion.setAttribute('inert', '');
const host = document.createElement('div');
inertRegion.appendChild(host);
document.body.appendChild(inertRegion);
(
handlers as unknown as { hostElement: () => HTMLElement }
).hostElement = () => host;
try {
expect(dispatchKey(' ')).toBe(false);
expect(dispatchKey('ArrowUp')).toBe(false);
expect(dispatchKey('m')).toBe(false);
dispatchKey('Escape');
expect(handlers.togglePaused).not.toHaveBeenCalled();
expect(handlers.adjustVolume).not.toHaveBeenCalled();
expect(handlers.toggleMute).not.toHaveBeenCalled();
expect(handlers.onEscape).not.toHaveBeenCalled();
} finally {
inertRegion.remove();
}
});
it('handles shortcuts when the host is not inside an inert region', () => {
const host = document.createElement('div');
document.body.appendChild(host);
(
handlers as unknown as { hostElement: () => HTMLElement }
).hostElement = () => host;
try {
expect(dispatchKey(' ')).toBe(true);
expect(handlers.togglePaused).toHaveBeenCalledTimes(1);
} finally {
host.remove();
}
});
it('does not consume keys for unsupported actions', () => {
handlers.canTogglePaused.mockReturnValue(false);
handlers.canSeek.mockReturnValue(false);
@@ -1,5 +1,12 @@
export interface ControlsShortcutHandlers {
isAvailable: () => boolean;
/**
* The player's host element, used to opt out of global shortcuts while
* an ancestor is `inert` (e.g. behind the workspace's phone context
* drawer): inert strips pointer and Tab access, but document-level
* keydown listeners still fire, so shortcuts must check it themselves.
*/
hostElement?: () => HTMLElement | null;
canTogglePaused: () => boolean;
canSeek: () => boolean;
canAdjustVolume: () => boolean;
@@ -78,6 +85,14 @@ export class ControlsShortcuts {
return;
}
// A player inside an inert region is outside the interaction model
// entirely — a modal surface above it owns the keyboard. This also
// covers Escape: the modal decides what Escape means, not the
// obscured player.
if (handlers.hostElement?.()?.closest('[inert]')) {
return;
}
if (event.key === 'Escape') {
handlers.onEscape();
return;
@@ -140,6 +140,7 @@ export class PlayerControlsComponent implements OnDestroy {
constructor() {
this.shortcuts.attach({
isAvailable: () => this.shortcutsEnabled() && this.showControls(),
hostElement: () => this.host,
canTogglePaused: () => this.canTogglePlay(),
canSeek: () => this.capabilities().seek && this.state().canSeek,
canAdjustVolume: () => this.capabilities().volume,
@@ -0,0 +1,141 @@
import { TestBed } from '@angular/core/testing';
import { ElectronStreamHeadersService } from './electron-stream-headers.service';
const STREAM_URL = 'http://portal.example:8080/live/ch1.ts';
const GATED_PLAYBACK = {
streamUrl: STREAM_URL,
title: 'Gated Channel',
headers: {
'User-Agent': 'MAG250',
Referer: 'http://portal.example',
Cookie: 'mac=00%3A1A%3A79%3A00%3A00%3A01; stb_lang=en_US',
Authorization: 'Bearer TOKEN123',
},
};
describe('ElectronStreamHeadersService', () => {
let service: ElectronStreamHeadersService;
let setUserAgent: jest.Mock;
beforeEach(() => {
setUserAgent = jest.fn().mockResolvedValue(true);
(window as unknown as { electron?: unknown }).electron = {
setUserAgent,
};
service = TestBed.inject(ElectronStreamHeadersService);
});
afterEach(() => {
delete (window as unknown as { electron?: unknown }).electron;
});
it('returns null without an Electron bridge (PWA)', () => {
delete (window as unknown as { electron?: unknown }).electron;
expect(service.apply(GATED_PLAYBACK)).toBeNull();
expect(setUserAgent).not.toHaveBeenCalled();
});
it('extracts the full header set and scopes it to the stream URL', async () => {
await expect(service.apply(GATED_PLAYBACK)).resolves.toBe(true);
expect(setUserAgent).toHaveBeenCalledWith(
'MAG250',
'http://portal.example',
STREAM_URL,
{
authorization: 'Bearer TOKEN123',
cookie: 'mac=00%3A1A%3A79%3A00%3A00%3A01; stb_lang=en_US',
}
);
});
it('prefers explicit userAgent/referer fields over the headers map', async () => {
await service.apply({
...GATED_PLAYBACK,
userAgent: 'ExplicitAgent/1.0',
referer: 'http://explicit.example',
});
expect(setUserAgent).toHaveBeenCalledWith(
'ExplicitAgent/1.0',
'http://explicit.example',
STREAM_URL,
expect.anything()
);
});
it('omits the credentials object when the playback carries none', async () => {
await service.apply({
streamUrl: 'https://example.com/live/plain.m3u8',
title: 'Plain Channel',
userAgent: 'PlainAgent/1.0',
});
expect(setUserAgent).toHaveBeenCalledWith(
'PlainAgent/1.0',
undefined,
'https://example.com/live/plain.m3u8',
undefined
);
});
it('reports a superseded apply as not current', async () => {
const resolvers: Array<() => void> = [];
setUserAgent.mockImplementation(
() =>
new Promise<boolean>((resolve) =>
resolvers.push(() => resolve(true))
)
);
const first = service.apply(GATED_PLAYBACK);
const second = service.apply({
...GATED_PLAYBACK,
streamUrl: 'http://portal.example:8080/live/ch2.ts',
});
resolvers[0]();
resolvers[1]();
await expect(first).resolves.toBe(false);
await expect(second).resolves.toBe(true);
});
it('clears the override only while the caller still owns the slot', async () => {
await service.apply(GATED_PLAYBACK);
setUserAgent.mockClear();
// A stale consumer (e.g. a destroyed component) must not wipe the
// override a newer playback configured.
service.clear('http://portal.example:8080/other-stream.ts');
expect(setUserAgent).not.toHaveBeenCalled();
service.clear(STREAM_URL);
expect(setUserAgent).toHaveBeenCalledWith(
undefined,
undefined,
STREAM_URL
);
});
it('does not clear twice for the same stream', async () => {
await service.apply(GATED_PLAYBACK);
service.clear(STREAM_URL);
setUserAgent.mockClear();
service.clear(STREAM_URL);
expect(setUserAgent).not.toHaveBeenCalled();
});
it('resolves instead of throwing when the bridge rejects', async () => {
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {
/* silence */
});
setUserAgent.mockRejectedValue(new Error('ipc down'));
await expect(service.apply(GATED_PLAYBACK)).resolves.toBe(true);
expect(warn).toHaveBeenCalled();
warn.mockRestore();
});
});
@@ -0,0 +1,104 @@
import { Injectable } from '@angular/core';
import type { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces';
function getHeaderValue(
headers: ResolvedPortalPlayback['headers'] | undefined,
name: string
): string | undefined {
if (!headers) {
return undefined;
}
const matchingKey = Object.keys(headers).find(
(key) => key.toLowerCase() === name.toLowerCase()
);
return matchingKey ? headers[matchingKey] : undefined;
}
/**
* Single owner of the scoped Electron request-header override for built-in
* playback. There is exactly one scoped override slot in the main process, so
* every surface that plays a stream inline goes through this service:
* `WebPlayerViewComponent` for the web video players, and the Stalker live
* layout for the dedicated radio audio player, which never mounts a
* `WebPlayerViewComponent` at all.
*
* `apply()` extracts the full header set from the resolved playback —
* including the portal Cookie/Authorization that auth-gated streams require —
* and hands it to the main process scoped to the stream's URL. A playback
* without custom headers deliberately clears the previous scoped override so
* stale headers never leak onto the next stream. `clear()` releases the slot
* only while the caller's stream still owns it, so a consumer being destroyed
* cannot wipe the override a newer consumer just configured.
*/
@Injectable({ providedIn: 'root' })
export class ElectronStreamHeadersService {
/** Guards against a superseded apply resolving its header IPC late. */
private syncSequence = 0;
/** Stream URL the currently configured override belongs to. */
private currentScopeUrl: string | null = null;
/**
* Configures the scoped override for this playback. Resolves `true` when
* this apply is still the newest one at the time the main process
* acknowledged it — callers must only hand the source to a player on
* `true`, so the first media request already carries the headers. Returns
* null when there is no Electron bridge (PWA), where sources apply
* synchronously and no override exists.
*/
apply(playback: ResolvedPortalPlayback): Promise<boolean> | null {
// Feature-detect the bridge method rather than the bridge object:
// partial bridges (tests, older preloads) must behave like the PWA
// instead of throwing inside a playback flow.
if (typeof window.electron?.setUserAgent !== 'function') {
return null;
}
const sequence = ++this.syncSequence;
const userAgent =
playback.userAgent ??
getHeaderValue(playback.headers, 'User-Agent');
const referer =
playback.referer ?? getHeaderValue(playback.headers, 'Referer');
const cookie = getHeaderValue(playback.headers, 'Cookie');
const authorization = getHeaderValue(playback.headers, 'Authorization');
this.currentScopeUrl = playback.streamUrl;
return window.electron
.setUserAgent(
userAgent,
referer,
playback.streamUrl,
cookie || authorization ? { authorization, cookie } : undefined
)
.catch((error: unknown) => {
console.warn(
'[ElectronStreamHeaders] Failed to configure request headers:',
error
);
return true;
})
.then(() => sequence === this.syncSequence);
}
/**
* Clears the scoped override — portal credentials must not outlive the
* playback session that needed them. No-ops when another stream has taken
* ownership of the slot since, and always in the PWA.
*/
clear(streamUrl: string | null): void {
if (
typeof window.electron?.setUserAgent !== 'function' ||
streamUrl === null ||
this.currentScopeUrl !== streamUrl
) {
return;
}
this.syncSequence += 1;
this.currentScopeUrl = null;
void window.electron
.setUserAgent(undefined, undefined, streamUrl)
.catch(() => undefined);
}
}
@@ -1,59 +1,68 @@
@if (selectedPlayer() === 'videojs') {
@defer (on immediate) {
<app-vjs-player
[options]="vjsOptions"
[mediaTitle]="resolvedMediaTitle()"
[volume]="volume()"
[showCaptions]="showCaptions()"
[interactionEnabled]="playbackInteractionEnabled()"
[startTime]="startTime()"
[seriesNavigation]="seriesNavigation()"
(timeUpdate)="timeUpdate.emit($event)"
(playbackIssue)="handlePlaybackIssue($event)"
(playbackEnded)="playbackEnded.emit()"
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
/>
<!-- The source hand-off is deferred until the scoped Electron header
override is configured; mounting the player before `vjsOptions`
exists would fire a source-less (or credential-less) first load. -->
@if (vjsOptions) {
<app-vjs-player
[options]="vjsOptions"
[mediaTitle]="resolvedMediaTitle()"
[volume]="volume()"
[showCaptions]="showCaptions()"
[interactionEnabled]="playbackInteractionEnabled()"
[startTime]="startTime()"
[seriesNavigation]="seriesNavigation()"
(timeUpdate)="timeUpdate.emit($event)"
(playbackIssue)="handlePlaybackIssue($event)"
(playbackEnded)="playbackEnded.emit()"
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
/>
}
} @placeholder {
<div class="web-player-defer-placeholder" aria-hidden="true"></div>
}
} @else if (selectedPlayer() === 'html5') {
@defer (on immediate) {
<app-html-video-player
[channel]="$any(channel)"
[mediaTitle]="resolvedMediaTitle()"
[volume]="volume()"
[showCaptions]="showCaptions()"
[isLive]="resolvedIsLive()"
[interactionEnabled]="playbackInteractionEnabled()"
[startTime]="startTime()"
[seriesNavigation]="seriesNavigation()"
(timeUpdate)="timeUpdate.emit($event)"
(playbackIssue)="handlePlaybackIssue($event)"
(playbackEnded)="playbackEnded.emit()"
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
/>
@if (channel) {
<app-html-video-player
[channel]="$any(channel)"
[mediaTitle]="resolvedMediaTitle()"
[volume]="volume()"
[showCaptions]="showCaptions()"
[isLive]="resolvedIsLive()"
[interactionEnabled]="playbackInteractionEnabled()"
[startTime]="startTime()"
[seriesNavigation]="seriesNavigation()"
(timeUpdate)="timeUpdate.emit($event)"
(playbackIssue)="handlePlaybackIssue($event)"
(playbackEnded)="playbackEnded.emit()"
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
/>
}
} @placeholder {
<div class="web-player-defer-placeholder" aria-hidden="true"></div>
}
} @else if (selectedPlayer() === 'artplayer') {
@defer (on immediate) {
<app-art-player
[channel]="$any(channel)"
[mediaTitle]="resolvedMediaTitle()"
[volume]="volume()"
[showCaptions]="showCaptions()"
[isLive]="resolvedIsLive()"
[interactionEnabled]="playbackInteractionEnabled()"
[startTime]="startTime()"
[seriesNavigation]="seriesNavigation()"
(timeUpdate)="timeUpdate.emit($event)"
(playbackIssue)="handlePlaybackIssue($event)"
(playbackEnded)="playbackEnded.emit()"
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
/>
@if (channel) {
<app-art-player
[channel]="$any(channel)"
[mediaTitle]="resolvedMediaTitle()"
[volume]="volume()"
[showCaptions]="showCaptions()"
[isLive]="resolvedIsLive()"
[interactionEnabled]="playbackInteractionEnabled()"
[startTime]="startTime()"
[seriesNavigation]="seriesNavigation()"
(timeUpdate)="timeUpdate.emit($event)"
(playbackIssue)="handlePlaybackIssue($event)"
(playbackEnded)="playbackEnded.emit()"
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
/>
}
} @placeholder {
<div class="web-player-defer-placeholder" aria-hidden="true"></div>
}
@@ -913,6 +913,122 @@ describe('WebPlayerViewComponent', () => {
)
).toBeNull();
});
describe('Electron scoped header override ownership', () => {
const GATED_STREAM_URL = 'http://portal.example:8080/live/ch1.ts';
const GATED_PLAYBACK = {
streamUrl: GATED_STREAM_URL,
title: 'Gated Channel',
isLive: true,
headers: {
'User-Agent': 'MAG250',
Referer: 'http://portal.example',
Cookie: 'mac=00%3A1A%3A79%3A00%3A00%3A01; stb_lang=en_US',
Authorization: 'Bearer TOKEN123',
},
};
let setUserAgent: jest.Mock;
beforeEach(() => {
setUserAgent = jest.fn().mockResolvedValue(true);
(window as unknown as { electron?: unknown }).electron = {
setUserAgent,
};
});
afterEach(() => {
fixture.destroy();
delete (window as unknown as { electron?: unknown }).electron;
});
it('configures the full header set — incl. credentials — before handing the source to the player', async () => {
fixture.componentRef.setInput('playback', GATED_PLAYBACK);
fixture.detectChanges();
// The source is handed over only after the override IPC resolves,
// so the first media request already carries the credentials.
expect(setUserAgent).toHaveBeenCalledWith(
'MAG250',
'http://portal.example',
GATED_STREAM_URL,
{
authorization: 'Bearer TOKEN123',
cookie: 'mac=00%3A1A%3A79%3A00%3A00%3A01; stb_lang=en_US',
}
);
expect(component.channel).toBeUndefined();
await fixture.whenStable();
fixture.detectChanges();
expect(component.channel.url).toBe(GATED_STREAM_URL);
});
it('omits the credentials object when the playback carries none', async () => {
fixture.componentRef.setInput('playback', {
streamUrl: 'https://example.com/live/plain.m3u8',
title: 'Plain Channel',
userAgent: 'PlainAgent/1.0',
});
fixture.detectChanges();
await fixture.whenStable();
expect(setUserAgent).toHaveBeenCalledWith(
'PlainAgent/1.0',
undefined,
'https://example.com/live/plain.m3u8',
undefined
);
});
it('applies only the newest playback when a switch supersedes a pending header IPC', async () => {
const resolvers: Array<() => void> = [];
setUserAgent.mockImplementation(
() =>
new Promise<boolean>((resolve) =>
resolvers.push(() => resolve(true))
)
);
fixture.componentRef.setInput('playback', GATED_PLAYBACK);
fixture.detectChanges();
fixture.componentRef.setInput('playback', {
streamUrl: 'http://portal.example:8080/live/ch2.ts',
title: 'Next Channel',
isLive: true,
headers: GATED_PLAYBACK.headers,
});
fixture.detectChanges();
// The stale IPC completion must not hand the old source over.
resolvers[0]();
await fixture.whenStable();
expect(component.channel).toBeUndefined();
resolvers[1]();
await fixture.whenStable();
expect(component.channel.url).toBe(
'http://portal.example:8080/live/ch2.ts'
);
});
it('clears the scoped override on destroy so credentials do not outlive playback', async () => {
fixture.componentRef.setInput('playback', GATED_PLAYBACK);
fixture.detectChanges();
await fixture.whenStable();
setUserAgent.mockClear();
fixture.destroy();
expect(setUserAgent).toHaveBeenCalledWith(
undefined,
undefined,
GATED_STREAM_URL
);
});
});
});
function createUnsupportedContainerDiagnostic(): PlaybackDiagnostic {
@@ -1,5 +1,6 @@
import {
Component,
OnDestroy,
Signal,
ViewEncapsulation,
computed,
@@ -47,6 +48,7 @@ import {
} from '../playback-diagnostics/playback-diagnostics.util';
import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation';
import { VjsPlayerComponent } from '../vjs-player/vjs-player.component';
import { ElectronStreamHeadersService } from './electron-stream-headers.service';
import {
getDiagnosticCodecHint,
getDiagnosticDescriptionKey,
@@ -89,7 +91,7 @@ function resolveWebPlayerSharedControls(): boolean {
],
encapsulation: ViewEncapsulation.None,
})
export class WebPlayerViewComponent {
export class WebPlayerViewComponent implements OnDestroy {
storage = inject(StorageMap);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly settingsStore = inject(SettingsStore);
@@ -221,15 +223,58 @@ export class WebPlayerViewComponent {
});
readonly recordingFolder = computed(() => this.settings()?.recordingFolder ?? '');
/** Stream URL the currently configured Electron header override belongs to. */
private headerScopeStreamUrl: string | null = null;
private readonly streamHeaders = inject(ElectronStreamHeadersService);
constructor() {
effect(() => {
// Track player changes so stale browser diagnostics are cleared on switch.
this.selectedPlayer();
const playback = this.resolvedPlayback();
const isLive = this.resolvedIsLive();
this.playbackDiagnostic.set(null);
this.applyPlayback(playback, isLive);
});
}
ngOnDestroy(): void {
// Portal credentials must not outlive the playback session that
// needed them: dropping the scoped override here keeps only the
// playlist-level (unscoped) User-Agent/Referer defaults active. The
// service no-ops if a newer consumer already owns the override slot.
this.streamHeaders.clear(this.headerScopeStreamUrl);
}
/**
* Configures the scoped Electron request headers BEFORE the stream source
* is handed to a player, so the very first media request already carries
* them — an auth-gated portal stream answers 403 without its
* Cookie/Authorization, and several engines treat that first failure as
* fatal. In the PWA there is no header bridge and the source applies
* synchronously, exactly as before.
*/
private applyPlayback(
playback: ResolvedPortalPlayback,
isLive: boolean
): void {
const headerSync = this.streamHeaders.apply(playback);
this.headerScopeStreamUrl = playback.streamUrl;
const handOff = (): void => {
this.setChannel(playback);
this.setVjsOptions(playback.streamUrl, this.resolvedIsLive());
this.setVjsOptions(playback.streamUrl, isLive);
};
if (!headerSync) {
handOff();
return;
}
void headerSync.then((stillCurrent) => {
if (stillCurrent) {
handOff();
}
});
}
+533
View File
@@ -0,0 +1,533 @@
// Shared account-info dialog stylesheet, used by both portal dialogs:
// - libs/portal/xtream/feature/src/lib/account-info/
// - libs/portal/stalker/feature/src/lib/stalker-account-info/
//
// The provider accent is injected via the `--account-dialog-accent` custom
// property, set on :host by each consumer. Provider-specific structural
// tweaks (e.g. the stats column count) stay in the consumer stylesheet.
// ─── Hero header ────────────────────────────────────────────────────────────
.account-dialog__hero {
margin: 0;
padding: 20px 20px 18px;
display: grid;
gap: 18px;
background:
var(--app-accent-gradient),
var(--app-widget-header-bg, var(--mat-sys-surface-container-high));
border-bottom: 1px solid
var(--app-widget-header-border, rgba(255, 255, 255, 0.05));
}
.account-dialog__hero-copy {
display: grid;
gap: 10px;
}
.account-dialog__eyebrow {
display: inline-flex;
align-items: center;
gap: 8px;
margin: 0;
color: var(--app-eyebrow-color, var(--mat-sys-on-surface-variant));
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.account-dialog__eyebrow-mark {
width: 8px;
height: 8px;
border-radius: 999px;
background: var(--account-dialog-accent, var(--mat-sys-primary));
box-shadow: 0 0 0 4px
color-mix(
in srgb,
var(--account-dialog-accent, var(--mat-sys-primary)) 14%,
transparent
);
}
.account-dialog__eyebrow-divider {
width: 3px;
height: 3px;
border-radius: 999px;
background: currentColor;
opacity: 0.5;
}
.account-dialog__headline-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
}
.account-dialog__title {
margin: 0;
font-size: 1.3rem;
line-height: 1.2;
font-weight: 650;
letter-spacing: -0.02em;
color: var(--app-heading-color, var(--mat-sys-on-surface));
}
.account-dialog__subtitle {
margin: 4px 0 0;
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
font-size: 0.88rem;
}
// ─── Status pills ───────────────────────────────────────────────────────────
.account-dialog__status-cluster {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 6px;
}
.account-dialog__status-pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
border: 1px solid var(--app-separator);
background: var(--mat-sys-surface-container);
color: var(--app-body-color, var(--mat-sys-on-surface));
font-size: 0.76rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.account-dialog__status-pill--active {
border-color: color-mix(in srgb, #34d399 30%, transparent);
background: color-mix(in srgb, #34d399 12%, transparent);
color: #2db36f;
}
.account-dialog__status-pill--trial,
.account-dialog__status-pill--cached {
border-color: color-mix(in srgb, #fbbf24 28%, transparent);
background: color-mix(in srgb, #f59e0b 12%, transparent);
color: #d18b09;
}
// ─── Messages ───────────────────────────────────────────────────────────────
.account-dialog__message,
.account-dialog__loading-copy {
margin: 0;
max-width: 60ch;
color: var(--app-body-color, var(--mat-sys-on-surface));
}
.account-dialog__message {
padding: 10px 14px;
border-radius: 10px;
border: 1px solid var(--app-separator);
background: var(--mat-sys-surface-container);
line-height: 1.5;
font-size: 0.88rem;
}
// ─── Hero stat cards ────────────────────────────────────────────────────────
.account-dialog__hero-stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.account-stat {
display: grid;
gap: 6px;
padding: 12px 14px;
border-radius: 12px;
border: 1px solid var(--app-widget-border, transparent);
background: var(--app-widget-bg, var(--mat-sys-surface-container));
box-shadow: var(--app-widget-shadow, none);
// Subtle inset highlight matching widget-shell pattern
outline: 1px solid rgba(255, 255, 255, 0.04);
outline-offset: -1px;
}
.account-stat__icon {
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 10px;
background: color-mix(
in srgb,
var(--account-dialog-accent, var(--mat-sys-primary)) 12%,
transparent
);
color: var(--account-dialog-accent, var(--mat-sys-primary));
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}
.account-stat__label {
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.account-stat__value {
font-size: 1.3rem;
font-weight: 650;
letter-spacing: -0.03em;
color: var(--app-heading-color, var(--mat-sys-on-surface));
overflow-wrap: anywhere;
}
.account-stat__value--warning {
color: #d18b09;
}
.account-stat__meter {
position: relative;
overflow: hidden;
height: 4px;
border-radius: 999px;
background: var(--app-separator);
}
.account-stat__meter span {
display: block;
height: 100%;
border-radius: inherit;
background: var(--account-dialog-accent, var(--mat-sys-primary));
}
// ─── Content area ───────────────────────────────────────────────────────────
.account-dialog__content {
margin: 0;
padding: 20px;
background: var(--app-widget-bg, var(--mat-sys-surface));
}
.account-dialog__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
// ─── Detail panels ──────────────────────────────────────────────────────────
.account-panel {
display: grid;
gap: 14px;
padding: 16px;
border-radius: 16px;
border: 1px solid var(--app-widget-border, transparent);
background: var(--app-widget-bg, var(--mat-sys-surface-container-low));
box-shadow: var(--app-widget-shadow, none);
outline: 1px solid rgba(255, 255, 255, 0.04);
outline-offset: -1px;
align-content: start;
}
.account-panel__header {
display: grid;
gap: 4px;
}
.account-panel__header h3 {
margin: 0;
font-size: 1rem;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--app-heading-color, var(--mat-sys-on-surface));
overflow-wrap: anywhere;
}
.account-panel__eyebrow {
margin: 0;
color: var(--app-eyebrow-color, var(--mat-sys-on-surface-variant));
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
}
// ─── Detail rows ────────────────────────────────────────────────────────────
.account-detail-list {
display: grid;
gap: 0;
margin: 0;
}
.account-detail-row {
display: grid;
grid-template-columns: minmax(120px, 140px) minmax(0, 1fr);
gap: 12px;
align-items: baseline;
padding: 10px 0;
border-bottom: 1px solid var(--app-separator);
}
.account-detail-row:last-child {
border-bottom: 0;
padding-bottom: 0;
}
.account-detail-row dt {
margin: 0;
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.account-detail-row dd {
margin: 0;
color: var(--app-body-color, var(--mat-sys-on-surface));
font-size: 0.9rem;
font-weight: 500;
word-break: break-word;
}
.account-detail-row__value--mono {
font-family: 'JetBrains Mono', 'Roboto Mono', monospace;
font-size: 0.84rem;
}
.account-detail-row__value--accent {
color: var(--account-dialog-accent, var(--mat-sys-primary));
}
.account-detail-row__value--positive {
color: #2db36f;
}
.account-detail-row__value--warning {
color: #d18b09;
}
// ─── Chip sections ──────────────────────────────────────────────────────────
.account-chip-section {
display: grid;
gap: 10px;
}
.account-chip-section__label {
margin: 0;
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.account-chip-group {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.account-chip {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
border: 1px solid
color-mix(
in srgb,
var(--account-dialog-accent, var(--mat-sys-primary)) 16%,
transparent
);
background: color-mix(
in srgb,
var(--account-dialog-accent, var(--mat-sys-primary)) 8%,
transparent
);
color: var(--app-body-color, var(--mat-sys-on-surface));
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
}
.account-chip--mono {
font-family: 'JetBrains Mono', 'Roboto Mono', monospace;
text-transform: none;
}
.account-chip--muted {
border-color: var(--app-separator);
background: var(--mat-sys-surface-container);
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
}
// ─── Empty account block ────────────────────────────────────────────────────
.account-dialog__empty-copy {
display: grid;
gap: 6px;
h4 {
margin: 0;
font-size: 0.92rem;
font-weight: 600;
color: var(--app-heading-color, var(--mat-sys-on-surface));
}
p {
margin: 0;
max-width: 46ch;
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
font-size: 0.85rem;
line-height: 1.5;
}
}
// ─── Error state ────────────────────────────────────────────────────────────
.account-dialog__error-state {
display: grid;
justify-items: center;
gap: 12px;
padding: 36px 20px;
border-radius: 16px;
border: 1px dashed var(--app-separator);
background: var(--app-widget-bg, var(--mat-sys-surface-container-low));
text-align: center;
}
.account-dialog__error-state h3,
.account-dialog__error-state p {
margin: 0;
}
.account-dialog__error-state p {
max-width: 52ch;
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
line-height: 1.5;
font-size: 0.88rem;
}
.account-dialog__error-icon {
width: 48px;
height: 48px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 14px;
background: color-mix(in srgb, var(--mat-sys-error) 12%, transparent);
color: var(--mat-sys-error);
}
// ─── Skeleton loading ───────────────────────────────────────────────────────
.account-panel--loading {
gap: 12px;
}
.account-skeleton {
position: relative;
overflow: hidden;
border-radius: 999px;
background: var(--app-separator);
}
.account-skeleton::after {
content: '';
position: absolute;
inset: 0;
transform: translateX(-100%);
background: linear-gradient(
90deg,
transparent,
color-mix(
in srgb,
var(--app-heading-color, var(--mat-sys-on-surface)) 10%,
transparent
),
transparent
);
animation: account-dialog-shimmer 1.4s infinite;
}
.account-skeleton--stat {
min-height: 110px;
border-radius: 12px;
}
.account-skeleton--heading {
width: 54%;
height: 20px;
}
.account-skeleton--row {
width: 100%;
height: 16px;
}
.account-skeleton--row.short {
width: 62%;
}
// ─── Footer actions ─────────────────────────────────────────────────────────
.account-dialog__actions {
padding: 0 20px 20px;
display: flex;
justify-content: flex-end;
gap: 10px;
border-top: 1px solid
var(--app-widget-header-border, rgba(255, 255, 255, 0.05));
background: var(--app-widget-bg, var(--mat-sys-surface));
}
.account-dialog__close {
min-width: 100px;
border-radius: 999px;
}
@keyframes account-dialog-shimmer {
100% {
transform: translateX(100%);
}
}
// ─── Responsive breakpoints ─────────────────────────────────────────────────
@media (max-width: 980px) {
.account-dialog__hero-stats,
.account-dialog__grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
.account-dialog__hero,
.account-dialog__content,
.account-dialog__actions {
padding-left: 16px;
padding-right: 16px;
}
.account-dialog__headline-row {
flex-direction: column;
align-items: flex-start;
}
.account-dialog__status-cluster {
justify-content: flex-start;
}
.account-dialog__hero-stats,
.account-dialog__grid {
grid-template-columns: 1fr;
}
.account-detail-row {
grid-template-columns: 1fr;
gap: 4px;
}
}
+3
View File
@@ -29,6 +29,9 @@
align-items: baseline;
gap: $gap;
flex: 1;
// `flex: 1` lets this shrink to zero width, and the nowrap subtitle then
// painted outside the box and on top of whatever sits beside it.
overflow: hidden;
}
@mixin standard-panel-title($font-size: 0.95rem) {
+63
View File
@@ -178,4 +178,67 @@
width: 100% !important;
}
}
// ─── Phone layout ────────────────────────────────────────────────────────
// Side by side, the 400px channel list leaves nothing for the player on a
// phone, so the two stack instead. The width needs `!important` because
// ResizableDirective writes the persisted desktop width inline; dragging
// is meaningless at full width, so the handle goes too.
@media (max-width: 640px) {
:host {
flex-direction: column;
}
.sidebar {
width: 100% !important;
min-width: 0 !important;
max-width: none;
flex-shrink: 1;
max-height: 42vh;
// Without a floor of its own the player's minimum can take the
// whole body on a short screen and the list vanishes while still
// marked expanded, leaving no way to pick another channel.
min-height: 72px;
border-right: none;
border-bottom: 1px solid var(--app-separator);
&.sidebar-collapsed {
max-height: 0;
min-height: 0;
border-bottom-color: transparent;
}
.resize-handle {
display: none;
}
}
.content-container {
flex: 1 1 auto;
}
// Inside that container the guide is `flex: 0 0 <basis>` while the
// video is `flex: 1 1 0`, so shrinking is distributed by basis and
// the video — basis zero — absorbed none of it and collapsed. Here
// the video keeps a floor and the guide is what gives way.
.video-player {
min-height: 96px;
}
.epg.epg--inline {
flex-shrink: 1;
min-height: 0;
}
}
// The player's floor, not the list's ceiling, is what has to hold — but
// only where the screen can afford it. A landscape phone already spends
// 30vh on the stacked categories panel, so claiming 240px more here left
// nothing for the channel list; there, the two panes just share what is
// left. Keyed on height because that, not width, is what runs out.
@media (max-width: 640px) and (min-height: 600px) {
.content-container {
min-height: 240px;
}
}
}