diff --git a/.changes/portal-about-broken-poster.md b/.changes/portal-about-broken-poster.md new file mode 100644 index 000000000..cf9a12a88 --- /dev/null +++ b/.changes/portal-about-broken-poster.md @@ -0,0 +1,6 @@ +--- +type: fix +area: portal +--- + +The About section under the player no longer shows a broken image when a movie or series poster cannot be loaded; the poster is simply left out, as it is when the provider sends none. diff --git a/libs/ui/components/src/lib/portal-detail-shell/content-about.component.spec.ts b/libs/ui/components/src/lib/portal-detail-shell/content-about.component.spec.ts new file mode 100644 index 000000000..d033fe3a7 --- /dev/null +++ b/libs/ui/components/src/lib/portal-detail-shell/content-about.component.spec.ts @@ -0,0 +1,39 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { ContentAboutComponent } from './content-about.component'; + +describe('ContentAboutComponent', () => { + let fixture: ComponentFixture; + + const poster = (): HTMLImageElement | null => + (fixture.nativeElement as HTMLElement).querySelector('.about__poster'); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ContentAboutComponent, TranslateModule.forRoot()], + }).compileComponents(); + fixture = TestBed.createComponent(ContentAboutComponent); + fixture.componentRef.setInput('title', 'Movie Title'); + }); + + it('omits the poster when there is no URL', () => { + fixture.detectChanges(); + expect(poster()).toBeNull(); + expect(fixture.nativeElement.textContent).toContain('Movie Title'); + }); + + it('drops a poster that fails to load and retries a new URL', () => { + fixture.componentRef.setInput('posterUrl', 'https://img.test/a.jpg'); + fixture.detectChanges(); + expect(poster()?.getAttribute('src')).toBe('https://img.test/a.jpg'); + + poster()?.dispatchEvent(new Event('error')); + fixture.detectChanges(); + expect(poster()).toBeNull(); + expect(fixture.nativeElement.textContent).toContain('Movie Title'); + + fixture.componentRef.setInput('posterUrl', 'https://img.test/b.jpg'); + fixture.detectChanges(); + expect(poster()?.getAttribute('src')).toBe('https://img.test/b.jpg'); + }); +}); diff --git a/libs/ui/components/src/lib/portal-detail-shell/content-about.component.ts b/libs/ui/components/src/lib/portal-detail-shell/content-about.component.ts index 449a89385..1d55c39f6 100644 --- a/libs/ui/components/src/lib/portal-detail-shell/content-about.component.ts +++ b/libs/ui/components/src/lib/portal-detail-shell/content-about.component.ts @@ -2,7 +2,9 @@ import { NgTemplateOutlet } from '@angular/common'; import { Component, TemplateRef, + computed, input, + signal, ChangeDetectionStrategy, } from '@angular/core'; import { TranslateModule } from '@ngx-translate/core'; @@ -13,7 +15,7 @@ import { TranslateModule } from '@ngx-translate/core'; * 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 + * placeholders. A poster URL that fails to load counts as missing. Chips and credits are stamped from the host-provided * *appDetailTags / *appDetailMeta templates, so the host's own @if guards apply. */ @Component({ @@ -24,12 +26,13 @@ import { TranslateModule } from '@ngx-translate/core';

{{ 'PORTALS.ABOUT' | translate }}

- @if (posterUrl()) { + @if (visiblePosterUrl(); as poster) { }
@@ -63,4 +66,11 @@ export class ContentAboutComponent { readonly description = input(); readonly tagsTemplate = input | null>(null); readonly metaTemplate = input | null>(null); + + // Keyed by URL so a different poster gets its own load attempt. + protected readonly failedPosterUrl = signal(undefined); + protected readonly visiblePosterUrl = computed(() => { + const url = this.posterUrl(); + return url && url !== this.failedPosterUrl() ? url : undefined; + }); }