feat: indicate loading while fetching details

This commit is contained in:
4gray committed 2026-01-11 21:20:50 +01:00
1 parent 5ad74e44ab
commit afd8da0c25
9 files changed
+198 -63

No files matched your search

@@ -72,5 +72,7 @@
(episodeClicked)="playEpisode($event)"
/>
</app-content-hero>
} @else {
<app-content-hero [isLoading]="true" (backClicked)="goBack()" />
}
@@ -17,6 +17,7 @@ export interface SelectionState {
selectedItem: any | null;
page: number;
limit: number;
isLoadingDetails: boolean;
}
/**
@@ -28,6 +29,7 @@ const initialSelectionState: SelectionState = {
selectedItem: null,
page: 0,
limit: Number(localStorage.getItem('xtream-page-size') ?? 25),
isLoadingDetails: false,
};
/**
@@ -242,6 +244,13 @@ export function withSelection() {
patchState(store, { selectedItem: item });
},
/**
* Set the loading details state
*/
setIsLoadingDetails(isLoading: boolean): void {
patchState(store, { isLoadingDetails: isLoading });
},
/**
* Set the current page
*/
@@ -121,6 +121,7 @@ export const XtreamStore = signalStore(
const playlist = ngrxStore.selectSignal(selectActivePlaylist)();
if (!playlist) return;
store.setIsLoadingDetails(true);
dataService.fetchData(
`${playlist.serverUrl}/player_api.php`,
{
@@ -137,6 +138,8 @@ export const XtreamStore = signalStore(
});
}).catch((error: unknown) => {
console.error('Error fetching VOD details:', error);
}).finally(() => {
store.setIsLoadingDetails(false);
});
},
@@ -150,6 +153,7 @@ export const XtreamStore = signalStore(
const playlist = ngrxStore.selectSignal(selectActivePlaylist)();
if (!playlist) return;
store.setIsLoadingDetails(true);
dataService.fetchData(
`${playlist.serverUrl}/player_api.php`,
{
@@ -166,6 +170,8 @@ export const XtreamStore = signalStore(
});
}).catch((error: unknown) => {
console.error('Error fetching series details:', error);
}).finally(() => {
store.setIsLoadingDetails(false);
});
},
@@ -100,5 +100,7 @@
</div>
}
</app-content-hero>
} @else {
<app-content-hero [isLoading]="true" (backClicked)="goBack()" />
}
@@ -24,7 +24,7 @@
</div>
<div class="main-content">
@if (!xtreamStore.selectedItem()) {
@if (!xtreamStore.selectedItem() && !xtreamStore.isLoadingDetails()) {
<div class="content-header">
<h2>
<button mat-icon-button disabled>
@@ -36,7 +36,7 @@
}
<div
class="scrollable-content"
[class.no-header]="xtreamStore.selectedItem()"
[class.no-header]="xtreamStore.selectedItem() || xtreamStore.isLoadingDetails()"
>
<router-outlet />
</div>
@@ -54,7 +54,7 @@ export class XtreamShellComponent {
constructor() {
// Subscribe to route params to handle switching between playlists
this.route.params.pipe(takeUntilDestroyed()).subscribe((params) => {
this.route.params.pipe(takeUntilDestroyed()).subscribe(async (params) => {
const newPlaylistId = params['id'];
if (this.isFirstLoad) {
@@ -72,8 +72,11 @@ export class XtreamShellComponent {
playlistId: newPlaylistId,
})
);
this.xtreamStore.fetchXtreamPlaylist();
this.xtreamStore.checkPortalStatus();
// Must await fetchXtreamPlaylist before checking status
// because checkPortalStatus needs currentPlaylist to be set
await this.xtreamStore.fetchXtreamPlaylist();
await this.xtreamStore.checkPortalStatus();
});
effect(
@@ -4,69 +4,148 @@
<button class="hero__back-button" (click)="onBack()">
<mat-icon>arrow_back</mat-icon>
</button>
<!-- Backdrop Image -->
<div
class="hero__backdrop"
[class.hero__backdrop--blurred]="!backdropUrl"
[style.backgroundImage]="
backdropUrl
? 'url(' + backdropUrl + ')'
: posterUrl
? 'url(' + posterUrl + ')'
: 'none'
"
></div>
<!-- Gradient Vignette Overlay -->
<div class="hero__vignette"></div>
<!-- Hero Content -->
<div class="hero__content">
<!-- Poster Card -->
<div class="poster">
@if (posterUrl) {
<img
class="poster__image"
[src]="posterUrl"
(error)="
$event.target.src = './assets/images/default-poster.png'
"
alt="Poster"
@if (isLoading) {
<div class="hero__content">
<!-- Skeleton Poster -->
<div class="poster">
<ngx-skeleton-loader
count="1"
[theme]="{
height: '100%',
width: '100%',
'border-radius': '8px',
'aspect-ratio': '2/3',
display: 'block'
}"
/>
} @else {
<div class="poster__placeholder">No Poster</div>
}
</div>
<!-- Skeleton Details -->
<div class="details">
<!-- Title -->
<ngx-skeleton-loader
count="1"
[theme]="{
height: '40px',
width: '60%',
'margin-bottom': '16px',
display: 'block'
}"
/>
<!-- Tags -->
<div style="display: flex; gap: 8px; margin-bottom: 16px">
<ngx-skeleton-loader
count="3"
[theme]="{
height: '24px',
width: '60px',
display: 'block'
}"
/>
</div>
<!-- Description -->
<ngx-skeleton-loader
count="3"
[theme]="{
height: '16px',
'margin-bottom': '8px',
width: '100%',
display: 'block'
}"
/>
<!-- Meta -->
<div style="margin-top: 20px; display: flex; gap: 32px">
<ngx-skeleton-loader
count="2"
[theme]="{
height: '40px',
width: '100px',
display: 'block'
}"
/>
</div>
<!-- Buttons -->
<div style="margin-top: 24px; display: flex; gap: 12px">
<ngx-skeleton-loader
count="2"
[theme]="{
height: '48px',
width: '140px',
display: 'block'
}"
/>
</div>
</div>
</div>
} @else {
<!-- Backdrop Image -->
<div
class="hero__backdrop"
[class.hero__backdrop--blurred]="!backdropUrl"
[style.backgroundImage]="
backdropUrl
? 'url(' + backdropUrl + ')'
: posterUrl
? 'url(' + posterUrl + ')'
: 'none'
"
></div>
<!-- Details -->
<div class="details">
<h1 class="details__title">{{ title }}</h1>
<!-- Gradient Vignette Overlay -->
<div class="hero__vignette"></div>
<!-- Tags Slot -->
<div class="details__tags">
<ng-content select="[hero-tags]"></ng-content>
<!-- Hero Content -->
<div class="hero__content">
<!-- Poster Card -->
<div class="poster">
@if (posterUrl) {
<img
class="poster__image"
[src]="posterUrl"
(error)="
$event.target.src =
'./assets/images/default-poster.png'
"
alt="Poster"
/>
} @else {
<div class="poster__placeholder">No Poster</div>
}
</div>
<!-- Description -->
@if (description) {
<p class="details__description">
{{ description }}
</p>
}
<!-- Details -->
<div class="details">
<h1 class="details__title">{{ title }}</h1>
<!-- Metadata Grid Slot -->
<div class="details__meta">
<ng-content select="[hero-meta]"></ng-content>
<!-- Tags Slot -->
<div class="details__tags">
<ng-content select="[hero-tags]"></ng-content>
</div>
<!-- Description -->
@if (description) {
<p class="details__description">
{{ description }}
</p>
}
<!-- Metadata Grid Slot -->
<div class="details__meta">
<ng-content select="[hero-meta]"></ng-content>
</div>
<!-- Action Buttons Slot -->
<div class="action-buttons">
<ng-content select="[hero-actions]"></ng-content>
</div>
<!-- Additional Content (Seasons, Trailers, etc.) -->
<ng-content></ng-content>
</div>
<!-- Action Buttons Slot -->
<div class="action-buttons">
<ng-content select="[hero-actions]"></ng-content>
</div>
<!-- Additional Content (Seasons, Trailers, etc.) -->
<ng-content></ng-content>
</div>
</div>
</section>
}
</section>
@@ -471,3 +471,30 @@
.hero__content {
animation: fadeInUp 0.6s ease-out 0.2s both;
}
// Skeleton Loader Overrides
ngx-skeleton-loader {
display: block;
width: 100%;
// Ensure the loader inside poster takes up the correct aspect ratio space
::ng-deep .loader {
display: block;
height: 100%;
}
}
:host-context(.dark-theme) {
ngx-skeleton-loader {
::ng-deep .loader {
background-color: rgba(255, 255, 255, 0.1);
}
}
}
.poster {
// ... existing styles ...
// Ensure poster container has height/ratio for skeleton
min-height: 330px; // Fallback
aspect-ratio: 2 / 3;
}
@@ -2,11 +2,17 @@ import { CommonModule } from '@angular/common';
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { NgxSkeletonLoaderComponent } from 'ngx-skeleton-loader';
@Component({
selector: 'app-content-hero',
standalone: true,
imports: [CommonModule, MatIconModule, MatButtonModule],
imports: [
CommonModule,
MatIconModule,
MatButtonModule,
NgxSkeletonLoaderComponent,
],
templateUrl: './content-hero.component.html',
styleUrls: ['./content-hero.component.scss'],
})
@@ -15,6 +21,7 @@ export class ContentHeroComponent {
@Input() description: string | undefined;
@Input() posterUrl: string | undefined;
@Input() backdropUrl: string | undefined;
@Input() isLoading = false;
@Output() backClicked = new EventEmitter<void>();