Files
iptvnator/libs/ui/components/src/lib/content-hero/content-hero.component.ts
T
4gray 760099358b feat(downloads): redesign download manager (#1313)
* docs(downloads): specify manager MVP redesign

* docs(downloads): plan manager MVP implementation

* docs(downloads): tighten manager validation plan

* fix(downloads): keep renderer download state global

* fix(downloads): make active count accessible

* feat(downloads): derive queue and library view model

* test(downloads): close view model coverage gaps

* fix(downloads): stabilize malformed view model data

* refactor(downloads): isolate library navigation

* fix(downloads): report library navigation failures

* feat(downloads): add ready-to-watch library

* feat(downloads): add active download queue

* feat(downloads): finish manager MVP

* docs(downloads): clarify detail-first offline behavior

* docs(downloads): plan detail navigation follow-up

* fix(downloads): open completed movies in details

* test(downloads): cover pending series navigation

* fix(downloads): honor the global cover size

* fix(downloads): prefer local playback in shared details

* fix(downloads): preserve external launch priority

* fix(downloads): prefer local playback in Xtream details

* test(downloads): cover offline detail journey

* docs(downloads): document offline detail behavior

* docs(downloads): format detail navigation plan

* fix(downloads): open Stalker items in provider details

* docs(downloads): clarify Stalker navigation fallback

* fix(xtream): isolate reused detail identities

* fix(xtream): ignore stale VOD positions

* fix(downloads): keep offline Xtream playback available

* docs(downloads): clarify provider playback availability

* docs(downloads): design missing-file recovery

* docs(downloads): plan missing-file recovery

* feat(downloads): derive completed file availability

* feat(downloads): recover missing completed files

* feat(downloads): refresh missing local files

* feat(downloads): separate missing files from ready media

* feat(downloads): surface missing files for recovery

* refactor(downloads): simplify ready cards

* test(downloads): cover missing-file and series journeys

* feat(downloads): finish missing-file recovery

* docs(downloads): design offline detail views

* docs(downloads): plan offline detail views

* feat(downloads): persist offline metadata snapshots

* fix(downloads): complete metadata snapshot bridge contract

* feat(downloads): manage offline metadata snapshots

* fix(downloads): harden metadata snapshot updates

* fix(downloads): restrict snapshot artwork

* fix(downloads): guard restart artwork URL

* fix(downloads): refine artwork URL checks

* feat(downloads): expose offline metadata updates

* fix(downloads): keep metadata service change focused

* fix(downloads): preserve metadata error conventions

* feat(downloads): derive offline detail content

* fix(downloads): preserve unknown episode coordinates

* feat(downloads): add focused offline detail routes

* fix(downloads): ignore fragments in shell route state

* fix(downloads): normalize fragments before queries

* feat(downloads): open ready cards in offline details

* fix(downloads): use native disabled card styles

* feat(downloads): enrich offline detail metadata

* fix(downloads): harden offline metadata resolution

* fix(downloads): preserve stalker provider titles

* fix(downloads): distinguish stalker metadata seeds

* fix(downloads): stabilize offline metadata refresh

* fix(downloads): throttle sparse metadata refreshes

* fix(downloads): type metadata language settings

* feat(downloads): render offline movie and series details

* fix(downloads): harden offline detail interactions

* fix(downloads): close offline detail edge cases

* feat(downloads): hand off to provider-only details

* fix(downloads): preserve stalker provider handoff

* feat(downloads): capture metadata at download time

* fix(downloads): preserve snapshot source semantics

* fix(downloads): preserve episode snapshot identity

* docs(downloads): document offline details flow

* docs(downloads): clarify stalker provider fallback

* test(downloads): cover offline detail journeys

* test(downloads): stabilize offline detail selectors

* style(downloads): format changed files

* docs(downloads): clean design spec formatting

* fix(downloads): preserve offline library ownership

* test(downloads): fix Windows workspace navigation

* test(database): preserve Electron tsconfig resolution

* perf(downloads): avoid blocking file availability probes
2026-08-01 18:09:31 +02:00

153 lines
4.9 KiB
TypeScript

import {
Component,
DestroyRef,
computed,
effect,
inject,
input,
output,
signal,
untracked,
viewChild,
ElementRef,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { TranslateModule } from '@ngx-translate/core';
import { NgxSkeletonLoaderComponent } from 'ngx-skeleton-loader';
@Component({
selector: 'app-content-hero',
standalone: true,
imports: [
MatIconModule,
MatButtonModule,
NgxSkeletonLoaderComponent,
TranslateModule,
],
templateUrl: './content-hero.component.html',
styleUrls: ['./content-hero.component.scss'],
})
export class ContentHeroComponent {
private readonly destroyRef = inject(DestroyRef);
readonly title = input<string>();
readonly description = input<string>();
readonly posterUrl = input<string>();
readonly backdropUrl = input<string>();
readonly isLoading = input(false);
readonly errorMessage = input<string>();
readonly backLabel = input<string>();
readonly backClicked = output<void>();
readonly posterError = signal(false);
private readonly failedBackdropUrl = signal<string | undefined>(undefined);
readonly backdropSourceUrl = computed(
() => this.backdropUrl() || this.posterUrl()
);
readonly backdropError = computed(() => {
const source = this.backdropSourceUrl();
return !!source && this.failedBackdropUrl() === source;
});
readonly backdropImageUrl = computed(() =>
this.backdropError() ? undefined : this.backdropSourceUrl()
);
readonly usesPosterBackdrop = computed(
() => !this.backdropUrl() && !!this.posterUrl() && !this.backdropError()
);
readonly descriptionEl =
viewChild<ElementRef<HTMLElement>>('descriptionEl');
readonly isDescriptionExpanded = signal(false);
readonly hasDescriptionOverflow = signal(false);
private resizeObserver?: ResizeObserver;
constructor() {
effect(() => {
this.posterUrl();
untracked(() => this.posterError.set(false));
});
effect(() => {
// Re-measure whenever description content or the element changes.
this.description();
const el = this.descriptionEl()?.nativeElement;
if (!el) return;
// untracked: measureOverflow reads isDescriptionExpanded();
// tracking it would re-run this effect (and rebuild the
// ResizeObserver) on every expand/collapse click.
untracked(() => {
this.measureOverflow(el);
this.observeOverflow(el);
});
});
this.destroyRef.onDestroy(() => this.resizeObserver?.disconnect());
}
onPosterError(): void {
this.posterError.set(true);
}
onBackdropError(): void {
this.failedBackdropUrl.set(this.backdropSourceUrl());
}
readonly formattedTitle = computed(() => {
const t = this.title();
if (!t) return '';
// Replace underscores with spaces for cleaner UX on slug/filename style titles
return t.replace(/_/g, ' ').replace(/\s+/g, ' ').trim();
});
private calculateHue(text: string): number {
if (!text) return 0;
let hash = 0;
for (let i = 0; i < text.length; i++) {
hash = text.charCodeAt(i) + ((hash << 5) - hash);
hash = hash & hash;
}
return Math.abs(hash) % 360;
}
readonly fallbackPosterBackground = computed(() => {
const hue = this.calculateHue(this.title() || 'placeholder');
const h2 = (hue + 40) % 360;
return `linear-gradient(135deg, hsl(${hue}, 40%, 25%) 0%, hsl(${h2}, 50%, 15%) 100%)`;
});
readonly fallbackBackdropBackground = computed(() => {
const hue = this.calculateHue(this.title() || 'placeholder');
const h2 = (hue + 60) % 360;
return `linear-gradient(135deg, hsl(${hue}, 50%, 15%) 0%, hsl(${h2}, 80%, 5%) 100%)`;
});
onBack(): void {
this.backClicked.emit();
}
toggleDescription(): void {
this.isDescriptionExpanded.update((v) => !v);
}
private measureOverflow(el: HTMLElement): void {
// Measure only in the clamped state; if already expanded, clamped overflow
// is implied when the element previously overflowed.
if (this.isDescriptionExpanded()) return;
this.hasDescriptionOverflow.set(el.scrollHeight > el.clientHeight + 1);
}
private observeOverflow(el: HTMLElement): void {
this.resizeObserver?.disconnect();
if (typeof ResizeObserver === 'undefined') {
this.measureOverflow(el);
return;
}
this.resizeObserver = new ResizeObserver(() =>
this.measureOverflow(el)
);
this.resizeObserver.observe(el);
}
}