{
it('finds media inside either video or radio player hosts', () => {
const videoHost = document.createElement('div');
- videoHost.className = 'web-player-view';
+ videoHost.setAttribute('data-cast-scope', '');
const videoControl = document.createElement('app-cast-control');
const video = document.createElement('video');
videoHost.append(video, videoControl);
const radioHost = document.createElement('div');
- radioHost.className = 'radio-hero';
+ radioHost.setAttribute('data-cast-scope', '');
const radioControl = document.createElement('app-cast-control');
const audio = document.createElement('audio');
radioHost.append(radioControl, audio);
diff --git a/libs/ui/playback/src/lib/casting/cast-media.utils.ts b/libs/ui/playback/src/lib/casting/cast-media.utils.ts
index 7f71db11e..d237699d1 100644
--- a/libs/ui/playback/src/lib/casting/cast-media.utils.ts
+++ b/libs/ui/playback/src/lib/casting/cast-media.utils.ts
@@ -11,8 +11,11 @@ type RemotePlaybackMediaElement = HTMLMediaElement & {
export function findCastMediaElement(
controlElement: Element
): RemotePlaybackMediaElement | null {
+ // Scoped via an explicit [data-cast-scope] attribute on the player host
+ // rather than CSS class names, so a layout/class refactor cannot silently
+ // break Cast media discovery.
const host =
- controlElement.closest('.web-player-view, .radio-hero') ??
+ controlElement.closest('[data-cast-scope]') ??
controlElement.parentElement;
return (
diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts
index 22d46bf30..23fc738bb 100644
--- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts
+++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts
@@ -53,6 +53,7 @@ type PlaybackDiagnosticDetail = {
styleUrls: ['./web-player-view.component.scss'],
host: {
class: 'web-player-view',
+ 'data-cast-scope': '',
'(pointerenter)': 'castControlVisibility.showTemporarily()',
'(pointermove)': 'castControlVisibility.showTemporarily()',
'(keydown)': 'castControlVisibility.showTemporarily()',
diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html
index 8bbeb45b5..c227eed27 100644
--- a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html
+++ b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html
@@ -79,11 +79,13 @@
(closeClicked)="facade.closeActiveExternalSession()"
(artworkClicked)="facade.openActiveExternalSessionTarget()"
>
-
+ @if (castPlayback(); as playback) {
+
+ }
}
diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.ts
index 034e8b5eb..f3875c898 100644
--- a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.ts
+++ b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.ts
@@ -1,9 +1,6 @@
-import { Component, inject, signal } from '@angular/core';
+import { Component, computed, inject, signal } from '@angular/core';
import { RouterOutlet } from '@angular/router';
-import {
- ExternalPlayerSession,
- ResolvedPortalPlayback,
-} from '@iptvnator/shared/interfaces';
+import { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces';
import { ExternalPlaybackDockComponent } from '@iptvnator/ui/components';
import { CastControlComponent } from '@iptvnator/ui/playback';
import {
@@ -54,14 +51,20 @@ export class WorkspaceShellComponent {
readonly keyboardShortcuts = inject(WorkspaceKeyboardShortcutsService);
readonly railExpanded = signal(false);
- toCastPlayback(session: ExternalPlayerSession): ResolvedPortalPlayback {
- return {
- streamUrl: session.streamUrl,
- title: session.title,
- thumbnail: session.thumbnail,
- isLive: !session.contentInfo,
- requiresRequestHeaders: session.requiresRequestHeaders,
- contentInfo: session.contentInfo,
- };
- }
+ // Stable reference for the OnPush cast-control: recomputes only when the
+ // active session changes, instead of allocating a new object on every
+ // change-detection pass.
+ readonly castPlayback = computed
(() => {
+ const session = this.facade.externalPlaybackSession();
+ return session
+ ? {
+ streamUrl: session.streamUrl,
+ title: session.title,
+ thumbnail: session.thumbnail,
+ isLive: !session.contentInfo,
+ requiresRequestHeaders: session.requiresRequestHeaders,
+ contentInfo: session.contentInfo,
+ }
+ : null;
+ });
}
From 0c0df9eb5ec0504eaeb15bb67542f2f36c3593b4 Mon Sep 17 00:00:00 2001
From: SalemOurabi <40477317+SalemOurabi@users.noreply.github.com>
Date: Tue, 16 Jun 2026 03:25:48 +0200
Subject: [PATCH 17/19] fix(csp): allow YouTube trailer embeds in frame-src
The VOD/series details view embeds the trailer via
@if (visiblePlaybackDiagnostic(); as issue) {
diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.scss b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.scss
index 976e9bdb1..63bf9a685 100644
--- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.scss
+++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.scss
@@ -19,6 +19,9 @@ app-html-video-player {
top: 12px;
right: 12px;
z-index: 7;
+ display: flex;
+ align-items: center;
+ gap: 6px;
opacity: 0;
pointer-events: none;
transform: translateY(-4px);
@@ -27,6 +30,13 @@ app-html-video-player {
transform 180ms ease-out;
}
+.web-player-fullscreen-toggle {
+ color: #fff;
+ background: rgb(0 0 0 / 58%);
+ border: 1px solid rgb(255 255 255 / 18%);
+ backdrop-filter: blur(12px);
+}
+
.web-player-cast-control--visible {
opacity: 1;
pointer-events: auto;
diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts
index f1065b1aa..590dcaff6 100644
--- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts
+++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts
@@ -256,6 +256,86 @@ describe('WebPlayerViewComponent', () => {
}
});
+ it('routes fullscreen through the player container and reflects fullscreen state', () => {
+ fixture.detectChanges();
+
+ const host = fixture.nativeElement as HTMLElement;
+ const requestFullscreen = jest.fn().mockResolvedValue(undefined);
+ const exitFullscreen = jest.fn().mockResolvedValue(undefined);
+ host.requestFullscreen = requestFullscreen;
+
+ const originalFsDescriptor = Object.getOwnPropertyDescriptor(
+ document,
+ 'fullscreenElement'
+ );
+ const originalExit = (
+ document as unknown as { exitFullscreen?: () => Promise