mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 21:26:15 -08:00
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:
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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
+69
@@ -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);
|
||||
});
|
||||
});
|
||||
+32
-4
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user