mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 11:06:16 -08:00
* 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
153 lines
4.9 KiB
TypeScript
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);
|
|
}
|
|
}
|