mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36:15 -08:00
feat(ui): add PortalDetailShellComponent with browse/watch states
Two-state detail layout shell: owns page scroll, hero collapse animation (~300ms, reduced-motion aware), Escape-to-close-player handling, and an About block that re-stamps host-provided *detailTags/*detailMeta templates in watch state. The [detail-player] slot is never wrapped in a shell conditional so the host-owned player subtree survives state changes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3a0aa07840
commit
329be253eb
8 files changed
+653
No files matched your search
@@ -8,6 +8,9 @@ export * from './lib/confirm-dialog/confirm-dialog.component';
|
||||
export * from './lib/confirm-dialog/dialog.service';
|
||||
export * from './lib/content-hero/content-hero.component';
|
||||
export * from './lib/external-playback-dock/external-playback-dock.component';
|
||||
export * from './lib/portal-detail-shell/content-about.component';
|
||||
export * from './lib/portal-detail-shell/detail-template.directives';
|
||||
export * from './lib/portal-detail-shell/portal-detail-shell.component';
|
||||
export * from './lib/progress-capsule/progress-capsule.component';
|
||||
export * from './lib/resizable/resizable.directive';
|
||||
export * from './lib/season-container/season-container.component';
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.about {
|
||||
max-width: var(--content-max-width, 1400px);
|
||||
margin: 0 auto;
|
||||
padding: 24px 40px 40px;
|
||||
border-top: 1px solid var(--tag-border, rgba(255, 255, 255, 0.12));
|
||||
|
||||
&__heading {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 16px 0;
|
||||
color: var(--text-primary, #fff);
|
||||
}
|
||||
|
||||
&__body {
|
||||
display: flex;
|
||||
gap: 22px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
&__poster {
|
||||
flex-shrink: 0;
|
||||
width: 120px;
|
||||
aspect-ratio: 2 / 3;
|
||||
object-fit: cover;
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--poster-shadow, 0 8px 40px rgba(0, 0, 0, 0.5));
|
||||
}
|
||||
|
||||
&__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 10px 0;
|
||||
color: var(--text-primary, #fff);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
&__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
&__description {
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.6;
|
||||
color: var(--text-secondary, rgba(255, 255, 255, 0.7));
|
||||
margin: 0 0 14px 0;
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px 32px;
|
||||
}
|
||||
}
|
||||
|
||||
// The stamped host templates reuse the hero markup classes; style them here
|
||||
// because component styles do not cross the template-outlet boundary.
|
||||
.about ::ng-deep .details__tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 12px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
background: var(--tag-bg, rgba(255, 255, 255, 0.1));
|
||||
border: 1px solid var(--tag-border, rgba(255, 255, 255, 0.2));
|
||||
border-radius: 4px;
|
||||
|
||||
&--rating {
|
||||
color: #ffd700;
|
||||
border-color: rgba(255, 215, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.about ::ng-deep .details__meta-item {
|
||||
font-size: 0.875rem;
|
||||
|
||||
.label {
|
||||
color: var(--text-muted, rgba(255, 255, 255, 0.5));
|
||||
font-weight: 400;
|
||||
margin-bottom: 2px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: var(--text-primary, #fff);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.about {
|
||||
padding: 20px 20px 32px;
|
||||
|
||||
&__poster {
|
||||
width: 92px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { NgTemplateOutlet } from '@angular/common';
|
||||
import { Component, TemplateRef, input } from '@angular/core';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
|
||||
/**
|
||||
* Watch-state metadata block ("About the show / movie"). Rendered by
|
||||
* PortalDetailShellComponent below the episodes slot so the hero metadata
|
||||
* stays reachable while the player occupies the hero position.
|
||||
*
|
||||
* Degradation rule: anything missing simply is not rendered — no "N/A"
|
||||
* placeholders. Chips and credits are stamped from the host-provided
|
||||
* *detailTags / *detailMeta templates, so the host's own @if guards apply.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-content-about',
|
||||
standalone: true,
|
||||
imports: [NgTemplateOutlet, TranslateModule],
|
||||
template: `
|
||||
<section class="about">
|
||||
<h3 class="about__heading">{{ 'PORTALS.ABOUT' | translate }}</h3>
|
||||
<div class="about__body">
|
||||
@if (posterUrl()) {
|
||||
<img
|
||||
class="about__poster"
|
||||
[src]="posterUrl()"
|
||||
[alt]="title() ?? ''"
|
||||
loading="lazy"
|
||||
/>
|
||||
}
|
||||
<div class="about__info">
|
||||
@if (title()) {
|
||||
<h4 class="about__title">{{ title() }}</h4>
|
||||
}
|
||||
@if (tagsTemplate(); as tags) {
|
||||
<div class="about__tags details__tags">
|
||||
<ng-container [ngTemplateOutlet]="tags" />
|
||||
</div>
|
||||
}
|
||||
@if (description()) {
|
||||
<p class="about__description">{{ description() }}</p>
|
||||
}
|
||||
@if (metaTemplate(); as meta) {
|
||||
<div class="about__meta details__meta">
|
||||
<ng-container [ngTemplateOutlet]="meta" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
`,
|
||||
styleUrls: ['./content-about.component.scss'],
|
||||
})
|
||||
export class ContentAboutComponent {
|
||||
readonly title = input<string>();
|
||||
readonly posterUrl = input<string>();
|
||||
readonly description = input<string>();
|
||||
readonly tagsTemplate = input<TemplateRef<unknown> | null>(null);
|
||||
readonly metaTemplate = input<TemplateRef<unknown> | null>(null);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Directive, TemplateRef, inject } from '@angular/core';
|
||||
|
||||
/**
|
||||
* Structural template markers used by PortalDetailShellComponent.
|
||||
*
|
||||
* Hosts wrap their hero chips/meta/actions markup in these templates so the
|
||||
* shell can stamp the same content twice: once into the hero (browse state)
|
||||
* and once into the About block (watch state). A projected node can only
|
||||
* appear once, a TemplateRef can be stamped any number of times.
|
||||
*/
|
||||
@Directive({
|
||||
selector: '[detailTags]',
|
||||
standalone: true,
|
||||
})
|
||||
export class DetailTagsTemplateDirective {
|
||||
readonly templateRef = inject(TemplateRef);
|
||||
}
|
||||
|
||||
@Directive({
|
||||
selector: '[detailMeta]',
|
||||
standalone: true,
|
||||
})
|
||||
export class DetailMetaTemplateDirective {
|
||||
readonly templateRef = inject(TemplateRef);
|
||||
}
|
||||
|
||||
@Directive({
|
||||
selector: '[detailActions]',
|
||||
standalone: true,
|
||||
})
|
||||
export class DetailActionsTemplateDirective {
|
||||
readonly templateRef = inject(TemplateRef);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<!-- Hero (browse state) — collapses when playback is active -->
|
||||
<div class="shell__hero" [class.shell__hero--collapsed]="isWatch()">
|
||||
<div class="shell__hero-inner">
|
||||
<app-content-hero
|
||||
[title]="title()"
|
||||
[description]="description()"
|
||||
[posterUrl]="posterUrl()"
|
||||
[backdropUrl]="backdropUrl()"
|
||||
[isLoading]="isLoading()"
|
||||
[errorMessage]="errorMessage()"
|
||||
(backClicked)="backClicked.emit()"
|
||||
>
|
||||
<ng-container hero-tags>
|
||||
@if (tagsTemplate(); as tags) {
|
||||
<ng-container
|
||||
[ngTemplateOutlet]="tags.templateRef"
|
||||
></ng-container>
|
||||
}
|
||||
</ng-container>
|
||||
<ng-container hero-meta>
|
||||
@if (metaTemplate(); as meta) {
|
||||
<ng-container
|
||||
[ngTemplateOutlet]="meta.templateRef"
|
||||
></ng-container>
|
||||
}
|
||||
</ng-container>
|
||||
<ng-container hero-actions>
|
||||
@if (actionsTemplate(); as actions) {
|
||||
<ng-container
|
||||
[ngTemplateOutlet]="actions.templateRef"
|
||||
></ng-container>
|
||||
}
|
||||
</ng-container>
|
||||
<ng-container hero-error-actions>
|
||||
<ng-content select="[hero-error-actions]"></ng-content>
|
||||
</ng-container>
|
||||
</app-content-hero>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Player region (watch state) — the slot itself is never wrapped in @if,
|
||||
so the host-owned player subtree survives every shell state change -->
|
||||
<div class="shell__player" [class.shell__player--active]="isWatch()">
|
||||
<ng-content select="[detail-player]"></ng-content>
|
||||
</div>
|
||||
|
||||
<!-- Episodes / seasons — both states -->
|
||||
<div class="shell__episodes">
|
||||
<ng-content select="[detail-episodes]"></ng-content>
|
||||
</div>
|
||||
|
||||
<!-- About block — watch state only, metadata moves here -->
|
||||
@if (isWatch() && !isLoading() && !errorMessage()) {
|
||||
<app-content-about
|
||||
class="shell__about"
|
||||
[title]="title()"
|
||||
[posterUrl]="posterUrl()"
|
||||
[description]="description()"
|
||||
[tagsTemplate]="tagsTemplate()?.templateRef ?? null"
|
||||
[metaTemplate]="metaTemplate()?.templateRef ?? null"
|
||||
/>
|
||||
}
|
||||
|
||||
<!-- Extras (trailer, similar rail) — both states -->
|
||||
<div class="shell__extras">
|
||||
<ng-content select="[detail-extras]"></ng-content>
|
||||
</div>
|
||||
@@ -0,0 +1,137 @@
|
||||
// ============================================================================
|
||||
// Portal detail shell — two-state (browse ↔ watch) layout + page scroll owner
|
||||
// ============================================================================
|
||||
|
||||
:host {
|
||||
// Shared surface palette (mirrors content-hero) so the area below the
|
||||
// natural-height hero and the About block match the page background.
|
||||
--surface-bg: #fffaf5;
|
||||
--text-primary: #1a1a1a;
|
||||
--text-secondary: rgba(26, 26, 26, 0.75);
|
||||
--text-muted: rgba(26, 26, 26, 0.5);
|
||||
--tag-bg: rgba(0, 0, 0, 0.06);
|
||||
--tag-border: rgba(0, 0, 0, 0.12);
|
||||
--poster-shadow: 0 8px 40px rgba(0, 0, 0, 0.15);
|
||||
--content-max-width: 1400px;
|
||||
|
||||
display: block;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
background: var(--surface-bg);
|
||||
color: var(--text-primary);
|
||||
|
||||
// Hide scrollbar but keep functionality (parity with the old hero scroll)
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
:host-context(.dark-theme) {
|
||||
--surface-bg: #141414;
|
||||
--text-primary: #fff;
|
||||
--text-secondary: rgba(255, 255, 255, 0.7);
|
||||
--text-muted: rgba(255, 255, 255, 0.5);
|
||||
--tag-bg: rgba(255, 255, 255, 0.1);
|
||||
--tag-border: rgba(255, 255, 255, 0.2);
|
||||
--poster-shadow: 0 8px 40px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Hero collapse (browse ↔ watch morph, ~300ms, no height measuring)
|
||||
// ============================================================================
|
||||
|
||||
.shell__hero {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr;
|
||||
transition:
|
||||
grid-template-rows 300ms ease,
|
||||
opacity 250ms ease;
|
||||
|
||||
&--collapsed {
|
||||
grid-template-rows: 0fr;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
// Keep the hero visible while it collapses, hide it afterwards so it
|
||||
// cannot receive focus in watch state.
|
||||
transition:
|
||||
grid-template-rows 300ms ease,
|
||||
opacity 250ms ease,
|
||||
visibility 0s linear 300ms;
|
||||
}
|
||||
}
|
||||
|
||||
.shell__hero-inner {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Player region (full content width in watch state)
|
||||
// ============================================================================
|
||||
|
||||
.shell__player {
|
||||
max-width: var(--content-max-width);
|
||||
margin: 0 auto;
|
||||
|
||||
&--active {
|
||||
padding: 16px 40px 0;
|
||||
animation: shell-fade-in 300ms ease 50ms both;
|
||||
}
|
||||
}
|
||||
|
||||
.shell__episodes {
|
||||
max-width: var(--content-max-width);
|
||||
margin: 0 auto;
|
||||
padding: 0 40px;
|
||||
}
|
||||
|
||||
.shell__about {
|
||||
animation: shell-fade-in 250ms ease 100ms both;
|
||||
}
|
||||
|
||||
.shell__extras {
|
||||
max-width: var(--content-max-width);
|
||||
margin: 0 auto;
|
||||
padding: 0 40px 40px;
|
||||
}
|
||||
|
||||
@keyframes shell-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.shell__player--active {
|
||||
padding: 8px 12px 0;
|
||||
}
|
||||
|
||||
.shell__episodes {
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.shell__extras {
|
||||
padding: 0 20px 32px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.shell__hero,
|
||||
.shell__hero--collapsed {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.shell__player--active,
|
||||
.shell__about {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { Component, signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import {
|
||||
DetailActionsTemplateDirective,
|
||||
DetailMetaTemplateDirective,
|
||||
DetailTagsTemplateDirective,
|
||||
} from './detail-template.directives';
|
||||
import { PortalDetailShellComponent } from './portal-detail-shell.component';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [
|
||||
PortalDetailShellComponent,
|
||||
DetailTagsTemplateDirective,
|
||||
DetailMetaTemplateDirective,
|
||||
DetailActionsTemplateDirective,
|
||||
],
|
||||
template: `
|
||||
<app-portal-detail-shell
|
||||
[title]="'Show Title'"
|
||||
[description]="'Show description'"
|
||||
[posterUrl]="'poster.jpg'"
|
||||
[playbackActive]="playbackActive()"
|
||||
(closePlayerRequested)="closeRequests = closeRequests + 1"
|
||||
>
|
||||
<ng-template detailTags>
|
||||
<span class="details__tag">2026</span>
|
||||
</ng-template>
|
||||
<ng-template detailMeta>
|
||||
<div class="details__meta-item">Cast entry</div>
|
||||
</ng-template>
|
||||
<ng-template detailActions>
|
||||
<button class="play-btn">Play</button>
|
||||
</ng-template>
|
||||
@if (playbackActive()) {
|
||||
<div detail-player class="fake-player">player</div>
|
||||
}
|
||||
<div detail-episodes class="fake-episodes">episodes</div>
|
||||
<div detail-extras class="fake-extras">extras</div>
|
||||
</app-portal-detail-shell>
|
||||
`,
|
||||
})
|
||||
class HostComponent {
|
||||
readonly playbackActive = signal(false);
|
||||
closeRequests = 0;
|
||||
}
|
||||
|
||||
describe('PortalDetailShellComponent', () => {
|
||||
let fixture: ComponentFixture<HostComponent>;
|
||||
let host: HostComponent;
|
||||
|
||||
const query = (selector: string): HTMLElement | null =>
|
||||
(fixture.nativeElement as HTMLElement).querySelector(selector);
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [HostComponent, TranslateModule.forRoot()],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(HostComponent);
|
||||
host = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('renders hero with stamped tags/meta/actions in browse state', () => {
|
||||
expect(query('.shell__hero--collapsed')).toBeNull();
|
||||
expect(query('app-content-hero')).toBeTruthy();
|
||||
expect(query('.details__tags .details__tag')?.textContent).toContain(
|
||||
'2026'
|
||||
);
|
||||
expect(query('.details__meta .details__meta-item')).toBeTruthy();
|
||||
expect(query('.action-buttons .play-btn')).toBeTruthy();
|
||||
expect(query('app-content-about')).toBeNull();
|
||||
});
|
||||
|
||||
it('collapses hero and shows About with re-stamped templates in watch state', () => {
|
||||
host.playbackActive.set(true);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(query('.shell__hero--collapsed')).toBeTruthy();
|
||||
const about = query('app-content-about');
|
||||
expect(about).toBeTruthy();
|
||||
expect(about?.querySelector('.details__tag')?.textContent).toContain(
|
||||
'2026'
|
||||
);
|
||||
expect(about?.querySelector('.details__meta-item')).toBeTruthy();
|
||||
// Actions are intentionally NOT repeated in About
|
||||
expect(about?.querySelector('.play-btn')).toBeNull();
|
||||
expect(about?.textContent).toContain('Show description');
|
||||
});
|
||||
|
||||
it('keeps the player slot outside any shell conditional', () => {
|
||||
host.playbackActive.set(true);
|
||||
fixture.detectChanges();
|
||||
const player = query('.fake-player');
|
||||
expect(player).toBeTruthy();
|
||||
|
||||
// Toggling unrelated shell state must not recreate the projected node
|
||||
host.playbackActive.set(false);
|
||||
fixture.detectChanges();
|
||||
// player disappears only because the HOST @if removed it
|
||||
expect(query('.fake-player')).toBeNull();
|
||||
});
|
||||
|
||||
it('emits closePlayerRequested on Escape only during playback', () => {
|
||||
const escape = () =>
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })
|
||||
);
|
||||
|
||||
escape();
|
||||
expect(host.closeRequests).toBe(0);
|
||||
|
||||
host.playbackActive.set(true);
|
||||
fixture.detectChanges();
|
||||
escape();
|
||||
expect(host.closeRequests).toBe(1);
|
||||
});
|
||||
|
||||
it('ignores Escape when the event was already handled', () => {
|
||||
host.playbackActive.set(true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
event.preventDefault();
|
||||
document.dispatchEvent(event);
|
||||
expect(host.closeRequests).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { NgTemplateOutlet } from '@angular/common';
|
||||
import {
|
||||
Component,
|
||||
ElementRef,
|
||||
computed,
|
||||
contentChild,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import { ContentHeroComponent } from '../content-hero/content-hero.component';
|
||||
import { ContentAboutComponent } from './content-about.component';
|
||||
import {
|
||||
DetailActionsTemplateDirective,
|
||||
DetailMetaTemplateDirective,
|
||||
DetailTagsTemplateDirective,
|
||||
} from './detail-template.directives';
|
||||
|
||||
/**
|
||||
* Two-state layout shell for portal VOD/series detail pages.
|
||||
*
|
||||
* Browse state: hero (poster + metadata + actions) on top, episodes below.
|
||||
* Watch state (`playbackActive`): the hero collapses, the host-projected
|
||||
* player takes the full content width, and the metadata reappears in an
|
||||
* About block below the episodes slot.
|
||||
*
|
||||
* The shell owns the page scroll, the browse↔watch animation, Escape
|
||||
* handling, and never conditionally wraps the `[detail-player]` slot — the
|
||||
* host's own `@if (inlinePlayback())` is the only thing that creates or
|
||||
* destroys the player, so shell state changes cannot recreate it.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-portal-detail-shell',
|
||||
standalone: true,
|
||||
imports: [ContentHeroComponent, ContentAboutComponent, NgTemplateOutlet],
|
||||
templateUrl: './portal-detail-shell.component.html',
|
||||
styleUrls: ['./portal-detail-shell.component.scss'],
|
||||
host: {
|
||||
'[class.shell-host--watch]': 'isWatch()',
|
||||
'(document:keydown.escape)': 'onEscape($event)',
|
||||
},
|
||||
})
|
||||
export class PortalDetailShellComponent {
|
||||
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
|
||||
readonly title = input<string>();
|
||||
readonly description = input<string>();
|
||||
readonly posterUrl = input<string>();
|
||||
readonly backdropUrl = input<string>();
|
||||
readonly isLoading = input(false);
|
||||
readonly errorMessage = input<string>();
|
||||
/** True while inline playback is active — flips the layout to watch state. */
|
||||
readonly playbackActive = input(false);
|
||||
|
||||
readonly backClicked = output<void>();
|
||||
/** Emitted when Escape is pressed during inline playback. */
|
||||
readonly closePlayerRequested = output<void>();
|
||||
|
||||
protected readonly tagsTemplate = contentChild(DetailTagsTemplateDirective);
|
||||
protected readonly metaTemplate = contentChild(DetailMetaTemplateDirective);
|
||||
protected readonly actionsTemplate = contentChild(
|
||||
DetailActionsTemplateDirective
|
||||
);
|
||||
|
||||
readonly isWatch = computed(() => this.playbackActive());
|
||||
|
||||
constructor() {
|
||||
let wasWatch = false;
|
||||
effect(() => {
|
||||
const watch = this.isWatch();
|
||||
if (watch && !wasWatch) {
|
||||
this.scrollToTop();
|
||||
}
|
||||
wasWatch = watch;
|
||||
});
|
||||
}
|
||||
|
||||
onEscape(event: KeyboardEvent): void {
|
||||
if (!this.playbackActive()) return;
|
||||
if (event.defaultPrevented) return;
|
||||
// Browser fullscreen owns Escape (exits fullscreen first).
|
||||
if (document.fullscreenElement) return;
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (
|
||||
target &&
|
||||
(target.isContentEditable ||
|
||||
['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.closePlayerRequested.emit();
|
||||
}
|
||||
|
||||
private scrollToTop(): void {
|
||||
const element = this.host.nativeElement;
|
||||
if (typeof element.scrollTo !== 'function') {
|
||||
element.scrollTop = 0;
|
||||
return;
|
||||
}
|
||||
const reducedMotion =
|
||||
typeof window !== 'undefined' &&
|
||||
window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
||||
element.scrollTo({
|
||||
top: 0,
|
||||
behavior: reducedMotion ? 'auto' : 'smooth',
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user