diff --git a/apps/web/src/app/xtream-tauri/serial-details/serial-details.component.html b/apps/web/src/app/xtream-tauri/serial-details/serial-details.component.html index ffc0151e1..3ab6e4de6 100644 --- a/apps/web/src/app/xtream-tauri/serial-details/serial-details.component.html +++ b/apps/web/src/app/xtream-tauri/serial-details/serial-details.component.html @@ -74,6 +74,11 @@ (episodeClicked)="playEpisode($event)" /> +} @else if (detailsError()) { + } @else { } diff --git a/apps/web/src/app/xtream-tauri/serial-details/serial-details.component.ts b/apps/web/src/app/xtream-tauri/serial-details/serial-details.component.ts index 07a6a41b1..c38ca3ef9 100644 --- a/apps/web/src/app/xtream-tauri/serial-details/serial-details.component.ts +++ b/apps/web/src/app/xtream-tauri/serial-details/serial-details.component.ts @@ -28,6 +28,8 @@ export class SerialDetailsComponent implements OnInit, OnDestroy { readonly selectedItem = this.xtreamStore.selectedItem; readonly selectedContentType = this.xtreamStore.selectedContentType; readonly isFavorite = this.xtreamStore.isFavorite; + readonly isLoadingDetails = this.xtreamStore.isLoadingDetails; + readonly detailsError = this.xtreamStore.detailsError; ngOnInit(): void { const { categoryId, serialId } = this.route.snapshot.params; diff --git a/apps/web/src/app/xtream-tauri/services/xtream-api.service.ts b/apps/web/src/app/xtream-tauri/services/xtream-api.service.ts index b2f5274c9..d50246601 100644 --- a/apps/web/src/app/xtream-tauri/services/xtream-api.service.ts +++ b/apps/web/src/app/xtream-tauri/services/xtream-api.service.ts @@ -271,6 +271,14 @@ export class XtreamApiService { url, params: serializedParams, }); + + // The IPC layer catches errors and returns { type: 'ERROR', message, status } + // instead of rejecting. Convert that back into a thrown error so callers + // can handle it with .catch() / try-catch. + if (response?.type === 'ERROR' || (!response?.payload && response?.message)) { + throw new Error(response?.message ?? 'Request failed'); + } + return response?.payload; } } diff --git a/apps/web/src/app/xtream-tauri/stores/features/with-selection.feature.ts b/apps/web/src/app/xtream-tauri/stores/features/with-selection.feature.ts index 0a6f509ac..55c22cfb4 100644 --- a/apps/web/src/app/xtream-tauri/stores/features/with-selection.feature.ts +++ b/apps/web/src/app/xtream-tauri/stores/features/with-selection.feature.ts @@ -18,6 +18,7 @@ export interface SelectionState { page: number; limit: number; isLoadingDetails: boolean; + detailsError: string | null; } /** @@ -30,6 +31,7 @@ const initialSelectionState: SelectionState = { page: 0, limit: Number(localStorage.getItem('xtream-page-size') ?? 25), isLoadingDetails: false, + detailsError: null, }; /** @@ -308,6 +310,13 @@ export function withSelection() { patchState(store, { isLoadingDetails: isLoading }); }, + /** + * Set the details error state + */ + setDetailsError(error: string | null): void { + patchState(store, { detailsError: error }); + }, + /** * Set the current page */ diff --git a/apps/web/src/app/xtream-tauri/stores/xtream.store.ts b/apps/web/src/app/xtream-tauri/stores/xtream.store.ts index a7ad98b40..5516500e5 100644 --- a/apps/web/src/app/xtream-tauri/stores/xtream.store.ts +++ b/apps/web/src/app/xtream-tauri/stores/xtream.store.ts @@ -131,6 +131,7 @@ export const XtreamStore = signalStore( if (!playlist) return; store.setIsLoadingDetails(true); + store.setDetailsError(null); xtreamApiService.getVodInfo({ serverUrl: playlist.serverUrl, username: playlist.username, @@ -143,6 +144,9 @@ export const XtreamStore = signalStore( }); }).catch((error: unknown) => { console.error('Error fetching VOD details:', error); + store.setDetailsError( + error instanceof Error ? error.message : 'Unknown error' + ); }).finally(() => { store.setIsLoadingDetails(false); }); @@ -159,6 +163,7 @@ export const XtreamStore = signalStore( if (!playlist) return; store.setIsLoadingDetails(true); + store.setDetailsError(null); xtreamApiService.getSeriesInfo({ serverUrl: playlist.serverUrl, username: playlist.username, @@ -171,6 +176,9 @@ export const XtreamStore = signalStore( }); }).catch((error: unknown) => { console.error('Error fetching series details:', error); + store.setDetailsError( + error instanceof Error ? error.message : 'Unknown error' + ); }).finally(() => { store.setIsLoadingDetails(false); }); diff --git a/apps/web/src/app/xtream-tauri/vod-details/vod-details-route.component.html b/apps/web/src/app/xtream-tauri/vod-details/vod-details-route.component.html index 2efde06a6..385a3e29a 100644 --- a/apps/web/src/app/xtream-tauri/vod-details/vod-details-route.component.html +++ b/apps/web/src/app/xtream-tauri/vod-details/vod-details-route.component.html @@ -135,6 +135,11 @@ } +} @else if (detailsError()) { + } @else { } diff --git a/apps/web/src/app/xtream-tauri/vod-details/vod-details-route.component.ts b/apps/web/src/app/xtream-tauri/vod-details/vod-details-route.component.ts index b31b0ef4c..dedcd17f4 100644 --- a/apps/web/src/app/xtream-tauri/vod-details/vod-details-route.component.ts +++ b/apps/web/src/app/xtream-tauri/vod-details/vod-details-route.component.ts @@ -45,6 +45,8 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { readonly isFavorite = this.xtreamStore.isFavorite; readonly selectedItem = this.xtreamStore.selectedItem; + readonly isLoadingDetails = this.xtreamStore.isLoadingDetails; + readonly detailsError = this.xtreamStore.detailsError; readonly hasPlaybackPosition = computed(() => { const vodId = this.route.snapshot.params.vodId; diff --git a/libs/ui/components/src/lib/content-hero/content-hero.component.html b/libs/ui/components/src/lib/content-hero/content-hero.component.html index 872e143ed..9c3bb8e1e 100644 --- a/libs/ui/components/src/lib/content-hero/content-hero.component.html +++ b/libs/ui/components/src/lib/content-hero/content-hero.component.html @@ -4,7 +4,13 @@ - @if (isLoading) { + @if (errorMessage()) { +
+ cloud_off +

{{ errorMessage() }}

+ +
+ } @else if (isLoading()) {
@@ -85,12 +91,12 @@
@@ -102,10 +108,10 @@
- @if (posterUrl) { + @if (posterUrl()) { -

{{ title }}

+

{{ title() }}

@@ -127,9 +133,9 @@
- @if (description) { + @if (description()) {

- {{ description }} + {{ description() }}

} @@ -148,4 +154,4 @@
} - \ No newline at end of file + diff --git a/libs/ui/components/src/lib/content-hero/content-hero.component.scss b/libs/ui/components/src/lib/content-hero/content-hero.component.scss index d1fe1cb28..93d725ce6 100644 --- a/libs/ui/components/src/lib/content-hero/content-hero.component.scss +++ b/libs/ui/components/src/lib/content-hero/content-hero.component.scss @@ -179,6 +179,29 @@ display: none; } } + + // Error state + &__error { + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + gap: 16px; + + .error-icon { + font-size: 64px; + width: 64px; + height: 64px; + color: var(--text-muted); + } + + .error-title { + font-size: 1.25rem; + font-weight: 500; + color: var(--text-secondary); + margin: 0; + } + } } // ============================================================================ diff --git a/libs/ui/components/src/lib/content-hero/content-hero.component.ts b/libs/ui/components/src/lib/content-hero/content-hero.component.ts index 2dcfff209..75fb970c2 100644 --- a/libs/ui/components/src/lib/content-hero/content-hero.component.ts +++ b/libs/ui/components/src/lib/content-hero/content-hero.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { Component, input, output } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { NgxSkeletonLoaderComponent } from 'ngx-skeleton-loader'; @@ -17,13 +17,14 @@ import { NgxSkeletonLoaderComponent } from 'ngx-skeleton-loader'; styleUrls: ['./content-hero.component.scss'], }) export class ContentHeroComponent { - @Input() title: string | undefined; - @Input() description: string | undefined; - @Input() posterUrl: string | undefined; - @Input() backdropUrl: string | undefined; - @Input() isLoading = false; + readonly title = input(); + readonly description = input(); + readonly posterUrl = input(); + readonly backdropUrl = input(); + readonly isLoading = input(false); + readonly errorMessage = input(); - @Output() backClicked = new EventEmitter(); + readonly backClicked = output(); onBack(): void { this.backClicked.emit();