diff --git a/apps/web/src/app/shared/components/portal-collection-live-shell/portal-collection-live-shell.component.html b/apps/web/src/app/shared/components/portal-collection-live-shell/portal-collection-live-shell.component.html new file mode 100644 index 000000000..a8a514374 --- /dev/null +++ b/apps/web/src/app/shared/components/portal-collection-live-shell/portal-collection-live-shell.component.html @@ -0,0 +1,57 @@ + + +
+ @if (hasSelection()) { + @if (streamUrl() && isEmbeddedPlayer()) { +
+ +
+ } + +
+
+ @if (epgItems().length > 0) { + + } + + @if (isLoadingEpg()) { +
+ +
+ } + + @if (hasMoreEpg() && !isLoadingEpg()) { +
+ +
+ } +
+
+ } @else { +
+ live_tv +

+ {{ 'CHANNELS.SELECT_CHANNEL_PLAYBACK' | translate }} +

+
+ } +
diff --git a/apps/web/src/app/shared/components/portal-collection-live-shell/portal-collection-live-shell.component.scss b/apps/web/src/app/shared/components/portal-collection-live-shell/portal-collection-live-shell.component.scss new file mode 100644 index 000000000..d846b4126 --- /dev/null +++ b/apps/web/src/app/shared/components/portal-collection-live-shell/portal-collection-live-shell.component.scss @@ -0,0 +1,187 @@ +@use '@angular/material' as mat; + +:host { + display: flex; + flex-direction: row; + overflow: hidden; + height: 100%; + width: 100%; +} + +.sidebar { + width: 400px; + min-width: 250px; + max-width: 600px; + border-right: 1px solid #333; + display: flex; + flex-direction: column; + flex-shrink: 0; + overflow: hidden; + position: relative; +} + +.content-container { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + min-height: 0; +} + +.video-player { + flex: 1 1 0; + min-height: 0; + overflow: hidden; + display: flex; +} + +.epg { + flex: 1 1 0; + min-height: 0; + overflow: hidden; +} + +.video-player > app-web-player-view { + flex: 1; + min-height: 0; +} + +.epg-content { + height: 100%; + min-height: 0; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.epg-content > app-epg-view { + flex: 1; + min-height: 0; +} + +.category-content-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + min-height: 0; + padding: 11px; + flex-shrink: 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.12); + app-region: drag; + + button { + @include mat.form-field-density(-5); + app-region: no-drag; + } +} + +.category-meta { + min-width: 0; + display: flex; + align-items: baseline; + gap: 10px; + flex: 1; +} + +.category-title { + margin: 0; + font-size: 1.2rem; + font-weight: 500; + color: inherit; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.category-subtitle { + white-space: nowrap; + font-size: 0.76rem; + font-weight: 700; + letter-spacing: 0.02em; + text-transform: uppercase; + color: var(--app-selection-color); + background: linear-gradient( + 145deg, + var(--app-selection-surface-strong), + var(--app-selection-surface) + ); + box-shadow: + inset 0 0 0 1px var(--app-selection-border), + 0 10px 16px -18px var(--app-selection-glow); + padding: 4px 10px; + border-radius: 999px; + flex-shrink: 0; +} + +.empty-state-container { + display: flex; + flex: 1; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 0; + text-align: center; + color: rgba(255, 255, 255, 0.5); + animation: placeholder-fade-in 220ms ease-out; +} + +.empty-state-icon { + font-size: 72px; + width: 72px; + height: 72px; + margin-bottom: 24px; + opacity: 0.2; + animation: placeholder-icon-float 2.8s ease-in-out infinite; +} + +.empty-state-title { + font-family: 'DM Sans', sans-serif; + font-weight: 500; + font-size: 1.25rem; + margin: 0; + opacity: 0.5; +} + +.loading-indicator { + display: flex; + justify-content: center; + padding: 16px; +} + +.load-more-epg { + display: flex; + justify-content: center; + padding: 8px 0; +} + +@keyframes placeholder-fade-in { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes placeholder-icon-float { + 0%, + 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-4px); + } +} + +::ng-deep { + .video-player + > app-web-player-view + > app-art-player + > .artplayer-container { + height: 100% !important; + width: 100% !important; + } +} diff --git a/apps/web/src/app/shared/components/portal-collection-live-shell/portal-collection-live-shell.component.ts b/apps/web/src/app/shared/components/portal-collection-live-shell/portal-collection-live-shell.component.ts new file mode 100644 index 000000000..a27b5f967 --- /dev/null +++ b/apps/web/src/app/shared/components/portal-collection-live-shell/portal-collection-live-shell.component.ts @@ -0,0 +1,41 @@ +import { ChangeDetectionStrategy, Component, input, output } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { TranslatePipe } from '@ngx-translate/core'; +import { ResizableDirective } from 'components'; +import { EpgItem } from 'shared-interfaces'; +import { EpgViewComponent, WebPlayerViewComponent } from 'shared-portals'; + +@Component({ + selector: 'app-portal-collection-live-shell', + imports: [ + EpgViewComponent, + MatButtonModule, + MatIconModule, + MatProgressSpinnerModule, + ResizableDirective, + TranslatePipe, + WebPlayerViewComponent, + ], + templateUrl: './portal-collection-live-shell.component.html', + styleUrl: './portal-collection-live-shell.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PortalCollectionLiveShellComponent { + readonly title = input('Favorites'); + readonly subtitle = input('Live TV'); + readonly count = input(0); + readonly streamUrl = input(''); + readonly isEmbeddedPlayer = input(true); + readonly hasSelection = input(false); + readonly epgItems = input([]); + readonly isLoadingEpg = input(false); + readonly hasMoreEpg = input(false); + + readonly loadMoreEpgClicked = output(); + + onLoadMoreEpg(): void { + this.loadMoreEpgClicked.emit(); + } +} diff --git a/apps/web/src/app/shared/components/portal-collection-shell/portal-collection-shell.component.html b/apps/web/src/app/shared/components/portal-collection-shell/portal-collection-shell.component.html index 386409629..81f2d5a7d 100644 --- a/apps/web/src/app/shared/components/portal-collection-shell/portal-collection-shell.component.html +++ b/apps/web/src/app/shared/components/portal-collection-shell/portal-collection-shell.component.html @@ -1,6 +1,11 @@ -@if (showDetails()) { - -} @else { +@switch (mode()) { + @case ('detail') { + + } + @case ('live') { + + } + @default { + } } diff --git a/apps/web/src/app/shared/components/portal-collection-shell/portal-collection-shell.component.ts b/apps/web/src/app/shared/components/portal-collection-shell/portal-collection-shell.component.ts index cbe8bf844..3f54f713a 100644 --- a/apps/web/src/app/shared/components/portal-collection-shell/portal-collection-shell.component.ts +++ b/apps/web/src/app/shared/components/portal-collection-shell/portal-collection-shell.component.ts @@ -11,6 +11,8 @@ export interface PortalCollectionShellLayout { headerActionTooltip?: string; } +export type PortalCollectionMode = 'grid' | 'detail' | 'live'; + @Component({ selector: 'app-portal-collection-shell', imports: [FavoritesLayoutComponent], @@ -33,7 +35,7 @@ export class PortalCollectionShellComponent { readonly playlistTitle = input('Playlist'); readonly selectedCategoryId = input('all'); readonly layout = input({}); - readonly showDetails = input(false); + readonly mode = input('grid'); readonly categoryClicked = output(); readonly removeItem = output(); diff --git a/apps/web/src/app/stalker/recently-viewed/recently-viewed.component.html b/apps/web/src/app/stalker/recently-viewed/recently-viewed.component.html index d93985a2f..c228a603b 100644 --- a/apps/web/src/app/stalker/recently-viewed/recently-viewed.component.html +++ b/apps/web/src/app/stalker/recently-viewed/recently-viewed.component.html @@ -1,25 +1,49 @@ - - - +@if (isLiveCategory()) { + + + +} @else { + + + +} diff --git a/apps/web/src/app/stalker/recently-viewed/recently-viewed.component.ts b/apps/web/src/app/stalker/recently-viewed/recently-viewed.component.ts index 19f0debc3..0618c8403 100644 --- a/apps/web/src/app/stalker/recently-viewed/recently-viewed.component.ts +++ b/apps/web/src/app/stalker/recently-viewed/recently-viewed.component.ts @@ -5,11 +5,15 @@ import { inject, signal, } from '@angular/core'; +import { MatSnackBar } from '@angular/material/snack-bar'; import { ActivatedRoute, Router } from '@angular/router'; -import { TranslateService } from '@ngx-translate/core'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { PlaylistsService } from 'services'; -import { VodDetailsItem } from 'shared-interfaces'; +import { EpgItem, VodDetailsItem } from 'shared-interfaces'; +import { PlayerService } from '../../services/player.service'; +import { PortalCollectionLiveShellComponent } from '../../shared/components/portal-collection-live-shell/portal-collection-live-shell.component'; import { + PortalCollectionMode, PortalCollectionShellComponent, PortalCollectionShellLayout, } from '../../shared/components/portal-collection-shell/portal-collection-shell.component'; @@ -37,6 +41,7 @@ import { normalizeStalkerVodDetailsItem, toggleStalkerVodFavorite, } from '../stalker-vod.utils'; +import { StalkerCollectionChannelsListComponent } from '../stalker-collection-channels-list/stalker-collection-channels-list.component'; import { StalkerStore } from '../stalker.store'; const STALKER_RECENT_LAYOUT: Omit< @@ -53,7 +58,13 @@ const STALKER_RECENT_LAYOUT: Omit< @Component({ selector: 'app-recently-viewed', templateUrl: './recently-viewed.component.html', - imports: [PortalCollectionShellComponent, StalkerInlineDetailComponent], + imports: [ + PortalCollectionLiveShellComponent, + PortalCollectionShellComponent, + StalkerCollectionChannelsListComponent, + StalkerInlineDetailComponent, + TranslatePipe, + ], styles: [ ` :host { @@ -80,14 +91,25 @@ export class RecentlyViewedComponent { private readonly collectionRefresh = createRefreshTrigger(); private readonly favoritesRefresh = createRefreshTrigger(); private readonly stalkerStore = inject(StalkerStore); + private readonly playerService = inject(PlayerService); + private readonly snackBar = inject(MatSnackBar); private readonly router = inject(Router); private readonly translate = inject(TranslateService); private readonly logger = createLogger('StalkerRecentlyViewed'); private readonly favoritesCtx = inject(FavoritesContextService); + private previousSelectedCategoryId: string | null = null; itemDetails: StalkerSelectedVodItem | null = null; vodDetailsItem: VodDetailsItem | null = null; readonly isSelectedVodFavorite = signal(false); + readonly selectedLiveItem = signal< + (StalkerVodSource & { category_id: string }) | null + >(null); + readonly liveStreamUrl = signal(''); + readonly epgItems = signal([]); + readonly isLoadingEpg = signal(false); + readonly hasMoreEpg = signal(false); + readonly isResolvingPlayback = signal(false); readonly currentPlaylist = this.stalkerStore.currentPlaylist; readonly playlistSubtitle = 'Stalker Portal'; @@ -130,7 +152,6 @@ export class RecentlyViewedComponent { ctx: this.favoritesCtx, categories: this.categories, }); - readonly itemsToShow = computed(() => { return filterCollectionBucket({ selectedCategoryId: this.selectedCategoryId(), @@ -161,6 +182,32 @@ export class RecentlyViewedComponent { 'refresh', (value) => value ?? '' ); + readonly liveItemsToShow = computed< + (StalkerVodSource & { category_id: string })[] + >(() => + ((this.allFavorites.value() ?? []) as (StalkerVodSource & { + category_id: string; + })[]).filter((item) => { + if (item.category_id !== 'itv') { + return false; + } + + const term = this.searchTerm(); + if (!term) { + return true; + } + + return `${item?.name ?? ''} ${item?.o_name ?? ''}` + .toLowerCase() + .includes(term); + }) + ); + readonly isLiveCategory = computed(() => this.selectedCategoryId() === 'itv'); + readonly isEmbeddedPlayer = computed(() => + this.playerService.isEmbeddedPlayer() + ); + private epgPageSize = 10; + private epgChannelId: number | string | null = null; readonly series = computed(() => this.allFavorites @@ -174,12 +221,36 @@ export class RecentlyViewedComponent { this.allFavorites.value()?.filter((item) => item.category_id === 'itv') ); + get mode(): PortalCollectionMode { + if (this.isLiveCategory()) { + return 'live'; + } + + return this.showDetails() ? 'detail' : 'grid'; + } + constructor() { effect(() => { this.portalFavorites.value(); this.syncSelectedVodFavorite(); }); + effect(() => { + const selectedCategoryId = this.selectedCategoryId(); + const hasInlineDetail = this.inlineDetail().categoryId !== null; + const previousCategoryId = this.previousSelectedCategoryId; + + this.previousSelectedCategoryId = selectedCategoryId; + + if ( + hasInlineDetail && + previousCategoryId !== null && + previousCategoryId !== selectedCategoryId + ) { + this.clearDetailsView(); + } + }); + effect(() => { const refresh = this.refreshToken(); if (!refresh) { @@ -213,6 +284,29 @@ export class RecentlyViewedComponent { // no-op } }); + + effect(() => { + const selectedItem = this.selectedLiveItem(); + if (!selectedItem) { + return; + } + + const stillExists = this.liveItemsToShow().some( + (item) => String(item.id ?? '') === String(selectedItem.id ?? '') + ); + + if (!stillExists) { + this.clearLiveSelection(); + } + }); + + effect(() => { + if (this.isLiveCategory()) { + return; + } + + this.clearLiveSelection(); + }); } removeFromRecentlyViewed(item: Pick) { @@ -248,11 +342,12 @@ export class RecentlyViewedComponent { this.stalkerStore.setSelectedContentType(item.category_id); switch (item.category_id) { case 'itv': - this.createLinkToPlayVodItv( - item.cmd, - item.o_name || item.name, - item.logo - ); + this.setCategoryId('itv'); + const cleared = clearStalkerDetailViewState(); + this.itemDetails = cleared.itemDetails; + this.vodDetailsItem = cleared.vodDetailsItem; + this.isSelectedVodFavorite.set(false); + void this.selectLiveItem(item); break; case 'vod': { // Normalize the item to ensure is_series flag is properly set @@ -308,10 +403,54 @@ export class RecentlyViewedComponent { /** Handle back from vod-details component */ onVodBack(): void { - const cleared = clearStalkerDetailViewState(); - this.itemDetails = cleared.itemDetails; - this.vodDetailsItem = cleared.vodDetailsItem; - this.isSelectedVodFavorite.set(false); + this.clearDetailsView(); + } + + async selectLiveItem(item: StalkerVodSource & { category_id: string }) { + this.selectedLiveItem.set(item); + this.liveStreamUrl.set(''); + this.stalkerStore.setSelectedContentType('itv'); + this.stalkerStore.setSelectedItem(item as any); + this.isResolvingPlayback.set(true); + + try { + const playback = await this.stalkerStore.resolveItvPlayback( + item as any + ); + await this.loadEpgForChannel(item.id); + this.liveStreamUrl.set(playback.streamUrl); + + if (this.isEmbeddedPlayer()) { + } else { + this.playerService.openResolvedPlayback(playback, true); + } + } catch (error) { + this.logger.error('Failed to resolve ITV playback', error); + this.showPlaybackError(error); + } finally { + this.isResolvingPlayback.set(false); + } + } + + async loadMoreEpg() { + if (!this.epgChannelId || this.isLoadingEpg()) { + return; + } + + this.epgPageSize += 10; + this.isLoadingEpg.set(true); + try { + const items = await this.stalkerStore.fetchChannelEpg( + this.epgChannelId, + this.epgPageSize + ); + this.epgItems.set(items); + this.hasMoreEpg.set(items.length >= this.epgPageSize); + } catch { + this.hasMoreEpg.set(false); + } finally { + this.isLoadingEpg.set(false); + } } async createLinkToPlayVodItv( @@ -335,6 +474,43 @@ export class RecentlyViewedComponent { this.stalkerStore.removeFromFavorites(favoriteId, onDone); } + toggleLiveFavorite(item: StalkerVodSource & { category_id: string }): void { + const itemId = String(item.id ?? ''); + if (this.liveFavoriteIds().has(itemId)) { + this.removeFromFavorites(itemId); + return; + } + + this.addToFavorites({ + ...item, + category_id: 'itv', + title: item.o_name || item.name, + cover: item.logo || item.cover, + added_at: new Date().toISOString(), + }); + } + + readonly liveFavoriteIds = computed(() => { + const ids = new Map(); + for (const item of (this.portalFavorites.value() ?? []) as StalkerVodSource[]) { + if (item.category_id === 'itv') { + ids.set(String(item.id ?? ''), true); + } + } + return ids; + }); + + private clearLiveSelection() { + this.selectedLiveItem.set(null); + this.liveStreamUrl.set(''); + this.isResolvingPlayback.set(false); + this.epgItems.set([]); + this.isLoadingEpg.set(false); + this.hasMoreEpg.set(false); + this.epgChannelId = null; + this.stalkerStore.setSelectedItem(null); + } + /** * Normalize recent item to ensure is_series flag is properly set * and all required fields are present for the series view @@ -354,6 +530,13 @@ export class RecentlyViewedComponent { ); } + private clearDetailsView(): void { + const cleared = clearStalkerDetailViewState(); + this.itemDetails = cleared.itemDetails; + this.vodDetailsItem = cleared.vodDetailsItem; + this.isSelectedVodFavorite.set(false); + } + inlineDetail() { return createStalkerInlineDetailState( this.itemDetails, @@ -364,4 +547,39 @@ export class RecentlyViewedComponent { showDetails() { return this.inlineDetail().categoryId !== null; } + + private async loadEpgForChannel(channelId: number | string | undefined) { + if (channelId === undefined || channelId === null) { + this.epgItems.set([]); + this.hasMoreEpg.set(false); + return; + } + + this.epgChannelId = channelId; + this.epgPageSize = 10; + this.isLoadingEpg.set(true); + this.epgItems.set([]); + this.hasMoreEpg.set(false); + + try { + const items = await this.stalkerStore.fetchChannelEpg( + channelId, + this.epgPageSize + ); + this.epgItems.set(items); + this.hasMoreEpg.set(items.length >= this.epgPageSize); + } catch { + this.epgItems.set([]); + } finally { + this.isLoadingEpg.set(false); + } + } + + private showPlaybackError(error: unknown): void { + const errorMessage = + error instanceof Error && error.message === 'nothing_to_play' + ? this.translate.instant('PORTALS.CONTENT_NOT_AVAILABLE') + : this.translate.instant('PORTALS.PLAYBACK_ERROR'); + this.snackBar.open(errorMessage, undefined, { duration: 3000 }); + } } diff --git a/apps/web/src/app/stalker/stalker-collection-channels-list/stalker-collection-channels-list.component.html b/apps/web/src/app/stalker/stalker-collection-channels-list/stalker-collection-channels-list.component.html new file mode 100644 index 000000000..ef7b4e07f --- /dev/null +++ b/apps/web/src/app/stalker/stalker-collection-channels-list/stalker-collection-channels-list.component.html @@ -0,0 +1,42 @@ +
+ +
+ +
+ @if (filteredItems().length > 0) { + @for (item of filteredItems(); track item.id) { + + } + } @else { +
+
+ search_off +
+

{{ 'CHANNELS.NO_CHANNELS_FOUND' | translate }}

+

We couldn't find anything matching "{{ searchString() }}"

+
+ } +
diff --git a/apps/web/src/app/stalker/stalker-collection-channels-list/stalker-collection-channels-list.component.scss b/apps/web/src/app/stalker/stalker-collection-channels-list/stalker-collection-channels-list.component.scss new file mode 100644 index 000000000..9c27a6732 --- /dev/null +++ b/apps/web/src/app/stalker/stalker-collection-channels-list/stalker-collection-channels-list.component.scss @@ -0,0 +1,126 @@ +:host { + display: block; + height: 100%; + width: 100%; + overflow: hidden; + container-type: inline-size; +} + +.channels-list { + display: flex; + flex-direction: column; + height: calc(100% - 56px); + overflow-y: auto; + overflow-x: hidden; + width: 100%; + padding: 4px 8px 12px; + box-sizing: border-box; + gap: 2px; +} + +.search-panel { + padding: 10px 12px; + flex-shrink: 0; + height: 56px; + box-sizing: border-box; + display: flex; + align-items: center; +} + +.search-field { + flex: 1; + width: 100%; + height: 36px; + border-radius: 10px; + border: 1px solid rgba(127, 127, 127, 0.22); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0)), + rgba(127, 127, 127, 0.08); + display: flex; + align-items: center; + gap: 8px; + padding: 0 12px; + box-sizing: border-box; + cursor: text; + transition: + border-color 0.15s ease, + background-color 0.15s ease, + box-shadow 0.15s ease; + + &:focus-within { + border-color: var(--app-selection-border); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--app-selection-color) 16%, transparent); + } + + mat-icon { + width: 18px; + height: 18px; + font-size: 18px; + opacity: 0.68; + flex-shrink: 0; + } + + input { + flex: 1; + border: none; + outline: none; + background: transparent; + color: inherit; + font: inherit; + font-size: 0.875rem; + } +} + +.empty-search-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + padding: 32px 24px; + text-align: center; + + .empty-icon-wrapper { + width: 64px; + height: 64px; + border-radius: 20px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0)), + rgba(127, 127, 127, 0.1); + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 20px; + box-shadow: + 0 8px 24px -8px rgba(0, 0, 0, 0.1), + inset 0 1px 1px rgba(255, 255, 255, 0.05); + + mat-icon { + width: 32px; + height: 32px; + font-size: 32px; + opacity: 0.8; + } + } + + h3 { + font-size: 1.125rem; + font-weight: 600; + margin: 0 0 8px; + letter-spacing: -0.01em; + } + + p { + font-size: 0.875rem; + margin: 0; + opacity: 0.7; + line-height: 1.4; + max-width: 260px; + } +} + +@container (max-width: 350px) { + .channels-list { + padding-inline: 6px; + } +} diff --git a/apps/web/src/app/stalker/stalker-collection-channels-list/stalker-collection-channels-list.component.ts b/apps/web/src/app/stalker/stalker-collection-channels-list/stalker-collection-channels-list.component.ts new file mode 100644 index 000000000..cf9650e00 --- /dev/null +++ b/apps/web/src/app/stalker/stalker-collection-channels-list/stalker-collection-channels-list.component.ts @@ -0,0 +1,159 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + inject, + input, + output, + signal, +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatIconModule } from '@angular/material/icon'; +import { TranslatePipe } from '@ngx-translate/core'; +import { ChannelListItemComponent } from 'components'; +import { EpgItem, EpgProgram } from 'shared-interfaces'; +import { StalkerVodSource } from '../models'; +import { normalizeStalkerEntityId } from '../stalker-vod.utils'; +import { StalkerStore } from '../stalker.store'; + +@Component({ + selector: 'app-stalker-collection-channels-list', + imports: [ChannelListItemComponent, FormsModule, MatIconModule, TranslatePipe], + templateUrl: './stalker-collection-channels-list.component.html', + styleUrl: './stalker-collection-channels-list.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class StalkerCollectionChannelsListComponent { + readonly items = input([]); + readonly selectedItemId = input(null); + readonly favoriteIds = input>(new Map()); + + readonly playClicked = output(); + readonly favoriteToggled = output(); + + private readonly stalkerStore = inject(StalkerStore); + protected readonly normalizeStalkerEntityId = normalizeStalkerEntityId; + readonly searchString = signal(''); + readonly filteredItems = computed(() => { + const search = this.searchString().trim().toLowerCase(); + if (!search) { + return this.items(); + } + + return this.items().filter((item) => + `${item.o_name ?? ''} ${item.name ?? ''}` + .toLowerCase() + .includes(search) + ); + }); + + readonly epgPrograms = new Map(); + readonly currentProgramsProgress = new Map(); + private readonly requestedChannels = new Set(); + + constructor() { + effect(() => { + const items = this.items(); + if (!items.length) { + this.epgPrograms.clear(); + this.currentProgramsProgress.clear(); + this.requestedChannels.clear(); + return; + } + + void this.loadEpgPreviews(items); + }); + } + + onPlay(item: StalkerVodSource): void { + this.playClicked.emit(item); + } + + onFavoriteToggle(item: StalkerVodSource): void { + this.favoriteToggled.emit(item); + } + + isSelected(item: StalkerVodSource): boolean { + return String(this.selectedItemId() ?? '') === normalizeStalkerEntityId(item.id); + } + + isFavorite(item: StalkerVodSource): boolean { + return this.favoriteIds().get(normalizeStalkerEntityId(item.id)) ?? false; + } + + private async loadEpgPreviews(items: StalkerVodSource[]): Promise { + const newItems = items.filter((item) => { + const id = normalizeStalkerEntityId(item.id); + return id && !this.requestedChannels.has(id); + }); + + if (!newItems.length) { + return; + } + + for (const item of newItems) { + this.requestedChannels.add(normalizeStalkerEntityId(item.id)); + } + + const batchSize = 3; + for (let i = 0; i < newItems.length; i += batchSize) { + const batch = newItems.slice(i, i + batchSize); + await Promise.all( + batch.map((item) => this.loadSingleEpgPreview(item.id)) + ); + if (i + batchSize < newItems.length) { + await new Promise((resolve) => setTimeout(resolve, 150)); + } + } + } + + private async loadSingleEpgPreview(channelId: number | string): Promise { + try { + const items = await this.stalkerStore.fetchChannelEpg(channelId, 1); + if (!items.length) { + return; + } + + const id = normalizeStalkerEntityId(channelId); + const program = this.toPreviewProgram(items[0], id); + this.epgPrograms.set(id, program); + this.updateProgramProgress(id, items[0]); + } catch { + // Ignore preview failures for collection live lists. + } + } + + private updateProgramProgress( + channelId: string | number, + item: EpgItem + ): void { + const now = Date.now() / 1000; + const start = parseInt(item.start_timestamp, 10); + const end = parseInt(item.stop_timestamp, 10); + + if (start && end && now >= start && now <= end) { + this.currentProgramsProgress.set( + channelId, + ((now - start) / (end - start)) * 100 + ); + return; + } + + this.currentProgramsProgress.delete(channelId); + } + + private toPreviewProgram( + item: EpgItem, + channelId: string | number + ): EpgProgram { + return { + start: item.start, + stop: item.stop || item.end, + channel: String(channelId), + title: item.title, + desc: item.description || null, + category: null, + }; + } +} diff --git a/apps/web/src/app/stalker/stalker-favorites/stalker-favorites.component.html b/apps/web/src/app/stalker/stalker-favorites/stalker-favorites.component.html index 33783fbab..1fce9f6a6 100644 --- a/apps/web/src/app/stalker/stalker-favorites/stalker-favorites.component.html +++ b/apps/web/src/app/stalker/stalker-favorites/stalker-favorites.component.html @@ -1,24 +1,48 @@ - - - +@if (isLiveCategory()) { + + + +} @else { + + + +} diff --git a/apps/web/src/app/stalker/stalker-favorites/stalker-favorites.component.ts b/apps/web/src/app/stalker/stalker-favorites/stalker-favorites.component.ts index 129debfa3..744cd176f 100644 --- a/apps/web/src/app/stalker/stalker-favorites/stalker-favorites.component.ts +++ b/apps/web/src/app/stalker/stalker-favorites/stalker-favorites.component.ts @@ -5,11 +5,15 @@ import { inject, signal, } from '@angular/core'; +import { MatSnackBar } from '@angular/material/snack-bar'; import { ActivatedRoute, Router } from '@angular/router'; -import { TranslateService } from '@ngx-translate/core'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { PlaylistsService } from 'services'; -import { VodDetailsItem } from 'shared-interfaces'; +import { EpgItem, VodDetailsItem } from 'shared-interfaces'; +import { PlayerService } from '../../services/player.service'; +import { PortalCollectionLiveShellComponent } from '../../shared/components/portal-collection-live-shell/portal-collection-live-shell.component'; import { + PortalCollectionMode, PortalCollectionShellComponent, PortalCollectionShellLayout, } from '../../shared/components/portal-collection-shell/portal-collection-shell.component'; @@ -35,6 +39,7 @@ import { normalizeStalkerFavoriteItem, toggleStalkerVodFavorite, } from '../stalker-vod.utils'; +import { StalkerCollectionChannelsListComponent } from '../stalker-collection-channels-list/stalker-collection-channels-list.component'; import { StalkerStore } from '../stalker.store'; const STALKER_FAVORITES_LAYOUT: PortalCollectionShellLayout = {}; @@ -42,7 +47,13 @@ const STALKER_FAVORITES_LAYOUT: PortalCollectionShellLayout = {}; @Component({ selector: 'app-stalker-favorites', templateUrl: './stalker-favorites.component.html', - imports: [PortalCollectionShellComponent, StalkerInlineDetailComponent], + imports: [ + PortalCollectionLiveShellComponent, + PortalCollectionShellComponent, + StalkerCollectionChannelsListComponent, + StalkerInlineDetailComponent, + TranslatePipe, + ], styles: [ ` :host { @@ -75,13 +86,22 @@ export class StalkerFavoritesComponent { private readonly playlistService = inject(PlaylistsService); private readonly favoritesRefresh = createRefreshTrigger(); private readonly stalkerStore = inject(StalkerStore); + private readonly playerService = inject(PlayerService); + private readonly snackBar = inject(MatSnackBar); private readonly translate = inject(TranslateService); private readonly logger = createLogger('StalkerFavorites'); private readonly favoritesCtx = inject(FavoritesContextService); + private previousSelectedCategoryId: string | null = null; itemDetails: NormalizedStalkerFavoriteItem | null = null; vodDetailsItem: VodDetailsItem | null = null; readonly isSelectedVodFavorite = signal(false); + readonly selectedLiveItem = signal(null); + readonly liveStreamUrl = signal(''); + readonly epgItems = signal([]); + readonly isLoadingEpg = signal(false); + readonly hasMoreEpg = signal(false); + readonly isResolvingPlayback = signal(false); readonly currentPlaylist = this.stalkerStore.currentPlaylist; readonly layout = STALKER_FAVORITES_LAYOUT; @@ -118,6 +138,28 @@ export class StalkerFavoritesComponent { ctx: this.favoritesCtx, categories: this.categories, }); + readonly liveItemsToShow = computed(() => + ((this.allFavorites.value() ?? []) as StalkerFavoriteItem[]).filter((item) => { + if (item.category_id !== 'itv') { + return false; + } + + const term = this.searchTerm(); + if (!term) { + return true; + } + + return `${item?.name ?? ''} ${item?.o_name ?? ''}` + .toLowerCase() + .includes(term); + }) + ); + readonly isLiveCategory = computed(() => this.selectedCategoryId() === 'itv'); + readonly isEmbeddedPlayer = computed(() => + this.playerService.isEmbeddedPlayer() + ); + private epgPageSize = 10; + private epgChannelId: number | string | null = null; readonly favoritesToShow = computed(() => { return filterCollectionBucket({ @@ -152,12 +194,59 @@ export class StalkerFavoritesComponent { this.allFavorites.value()?.filter((item) => item.category_id === 'itv') ); + get mode(): PortalCollectionMode { + if (this.isLiveCategory()) { + return 'live'; + } + + return this.showDetails() ? 'detail' : 'grid'; + } + constructor() { effect(() => { this.allFavorites.value(); this.syncSelectedVodFavorite(); }); + effect(() => { + const selectedCategoryId = this.selectedCategoryId(); + const hasInlineDetail = this.inlineDetail().categoryId !== null; + const previousCategoryId = this.previousSelectedCategoryId; + + this.previousSelectedCategoryId = selectedCategoryId; + + if ( + hasInlineDetail && + previousCategoryId !== null && + previousCategoryId !== selectedCategoryId + ) { + this.resetDetailsView(false); + } + }); + + effect(() => { + const selectedItem = this.selectedLiveItem(); + if (!selectedItem) { + return; + } + + const stillExists = this.liveItemsToShow().some( + (item) => normalizeStalkerEntityId(item.id) === normalizeStalkerEntityId(selectedItem.id) + ); + + if (!stillExists) { + this.clearLiveSelection(); + } + }); + + effect(() => { + if (this.isLiveCategory()) { + return; + } + + this.clearLiveSelection(); + }); + effect(() => { const playlist = this.currentPlaylist(); if (!playlist?._id) { @@ -223,12 +312,9 @@ export class StalkerFavoritesComponent { switch (itemToOpen.category_id) { case 'itv': - this.stalkerStore.setSelectedContentType('itv'); - this.createLinkToPlayVodItv( - itemToOpen.cmd, - itemToOpen.o_name || itemToOpen.name, - itemToOpen.logo - ); + this.setCategoryId('itv'); + this.resetDetailsView(false); + void this.selectLiveItem(itemToOpen); break; case 'vod': { this.itemDetails = normalizedItem; @@ -323,6 +409,53 @@ export class StalkerFavoritesComponent { return this.inlineDetail().categoryId !== null; } + async selectLiveItem(item: StalkerFavoriteItem) { + this.selectedLiveItem.set(item); + this.liveStreamUrl.set(''); + this.stalkerStore.setSelectedContentType('itv'); + this.stalkerStore.setSelectedItem(item as any); + this.isResolvingPlayback.set(true); + + try { + const playback = await this.stalkerStore.resolveItvPlayback( + item as any + ); + await this.loadEpgForChannel(item.id); + this.liveStreamUrl.set(playback.streamUrl); + + if (this.isEmbeddedPlayer()) { + } else { + this.playerService.openResolvedPlayback(playback, true); + } + } catch (error) { + this.logger.error('Failed to resolve ITV playback', error); + this.showPlaybackError(error); + } finally { + this.isResolvingPlayback.set(false); + } + } + + async loadMoreEpg() { + if (!this.epgChannelId || this.isLoadingEpg()) { + return; + } + + this.epgPageSize += 10; + this.isLoadingEpg.set(true); + try { + const items = await this.stalkerStore.fetchChannelEpg( + this.epgChannelId, + this.epgPageSize + ); + this.epgItems.set(items); + this.hasMoreEpg.set(items.length >= this.epgPageSize); + } catch { + this.hasMoreEpg.set(false); + } finally { + this.isLoadingEpg.set(false); + } + } + async createLinkToPlayVodItv( cmd?: string, title?: string, @@ -335,4 +468,74 @@ export class StalkerFavoritesComponent { }); await this.stalkerStore.createLinkToPlayVod(cmd, title, thumbnail); } + + toggleLiveFavorite(item: StalkerFavoriteItem): void { + const itemId = normalizeStalkerEntityId(item.id); + if (this.liveFavoriteIds().has(itemId)) { + this.removeFromFavorites({ id: item.id }); + return; + } + + this.addToFavorites({ + ...item, + category_id: 'itv', + title: item.o_name || item.name, + cover: item.logo, + added_at: new Date().toISOString(), + }); + } + + readonly liveFavoriteIds = computed(() => { + const ids = new Map(); + for (const item of this.liveItemsToShow()) { + ids.set(normalizeStalkerEntityId(item.id), true); + } + return ids; + }); + + private clearLiveSelection() { + this.selectedLiveItem.set(null); + this.liveStreamUrl.set(''); + this.isResolvingPlayback.set(false); + this.epgItems.set([]); + this.isLoadingEpg.set(false); + this.hasMoreEpg.set(false); + this.epgChannelId = null; + this.stalkerStore.setSelectedItem(null); + } + + private async loadEpgForChannel(channelId: number | string | undefined) { + if (channelId === undefined || channelId === null) { + this.epgItems.set([]); + this.hasMoreEpg.set(false); + return; + } + + this.epgChannelId = channelId; + this.epgPageSize = 10; + this.isLoadingEpg.set(true); + this.epgItems.set([]); + this.hasMoreEpg.set(false); + + try { + const items = await this.stalkerStore.fetchChannelEpg( + channelId, + this.epgPageSize + ); + this.epgItems.set(items); + this.hasMoreEpg.set(items.length >= this.epgPageSize); + } catch { + this.epgItems.set([]); + } finally { + this.isLoadingEpg.set(false); + } + } + + private showPlaybackError(error: unknown): void { + const errorMessage = + error instanceof Error && error.message === 'nothing_to_play' + ? this.translate.instant('PORTALS.CONTENT_NOT_AVAILABLE') + : this.translate.instant('PORTALS.PLAYBACK_ERROR'); + this.snackBar.open(errorMessage, undefined, { duration: 3000 }); + } } diff --git a/apps/web/src/app/stalker/stalker-live-playback.utils.ts b/apps/web/src/app/stalker/stalker-live-playback.utils.ts new file mode 100644 index 000000000..88f23a907 --- /dev/null +++ b/apps/web/src/app/stalker/stalker-live-playback.utils.ts @@ -0,0 +1,94 @@ +import { Playlist } from 'shared-interfaces'; + +export const STALKER_MAG_USER_AGENT = + 'Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG250'; +export const STALKER_STREAM_USER_AGENT = 'KSPlayer'; + +export function getStalkerPortalOrigin( + playlist: Playlist | undefined | null +): string | undefined { + const portalUrl = playlist?.portalUrl; + if (!portalUrl) { + return undefined; + } + + try { + return new URL(portalUrl).origin; + } catch { + return undefined; + } +} + +export function isCrossOriginStalkerStream( + playlist: Playlist | undefined | null, + streamUrl?: string +): boolean { + const portalOrigin = getStalkerPortalOrigin(playlist); + if (!portalOrigin || !streamUrl) { + return false; + } + + try { + return new URL(streamUrl).origin !== portalOrigin; + } catch { + return false; + } +} + +export function buildStalkerExternalPlaybackHeaders( + playlist: Playlist | undefined | null, + token?: string | null, + streamUrl?: string +): Record { + if (!playlist?.macAddress) { + return {}; + } + + if (isCrossOriginStalkerStream(playlist, streamUrl)) { + return { + 'User-Agent': STALKER_STREAM_USER_AGENT, + Accept: '*/*', + Range: 'bytes=0-', + Connection: 'keep-alive', + 'Icy-MetaData': '1', + }; + } + + const cookieParts = [ + `mac=${playlist.macAddress}`, + 'stb_lang=en_US@rg=dezzzz', + 'timezone=Europe/Berlin', + ]; + + if (playlist.stalkerSerialNumber) { + cookieParts.push( + `__cfduid=${playlist.stalkerSerialNumber.toLowerCase()}e030245495acd6ebfc1` + ); + } + + const headers: Record = { + Cookie: cookieParts.join('; '), + 'X-User-Agent': STALKER_MAG_USER_AGENT, + SN: playlist.stalkerSerialNumber || '', + }; + + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + const origin = getStalkerPortalOrigin(playlist); + if (origin) { + headers['Origin'] = origin; + headers['Referer'] = origin; + } + + return Object.entries(headers).reduce>( + (acc, [name, value]) => { + if (value?.trim()) { + acc[name] = value; + } + return acc; + }, + {} + ); +} diff --git a/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.html b/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.html index f585dbfd1..133331282 100644 --- a/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.html +++ b/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.html @@ -128,11 +128,11 @@ } @else { -
- - {{ 'PORTALS.SELECT_CATEGORY' | translate }} +
+ live_tv +

+ {{ 'CHANNELS.SELECT_CHANNEL_PLAYBACK' | translate }} +

}
diff --git a/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.scss b/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.scss index 06f8d2a8c..ed5a1ca14 100644 --- a/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.scss +++ b/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.scss @@ -67,14 +67,33 @@ min-height: 0; } -.no-channel-selected { - padding: 16px; - margin: 0; - font-size: 1.2rem; - font-weight: 500; +.empty-state-container { display: flex; + flex: 1; + flex-direction: column; align-items: center; - gap: 10px; + justify-content: center; + min-height: 0; + text-align: center; + color: rgba(255, 255, 255, 0.5); + animation: placeholder-fade-in 220ms ease-out; +} + +.empty-state-icon { + font-size: 72px; + width: 72px; + height: 72px; + margin-bottom: 24px; + opacity: 0.2; + animation: placeholder-icon-float 2.8s ease-in-out infinite; +} + +.empty-state-title { + font-family: 'DM Sans', sans-serif; + font-weight: 500; + font-size: 1.25rem; + margin: 0; + opacity: 0.5; } .search-panel { @@ -123,6 +142,27 @@ padding: 8px 0; } +@keyframes placeholder-fade-in { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes placeholder-icon-float { + 0%, + 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-4px); + } +} + @container (max-width: 350px) { .channels-list { padding-inline: 6px; diff --git a/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.ts b/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.ts index fc7ed2271..f13c6350b 100644 --- a/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.ts +++ b/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.ts @@ -26,7 +26,7 @@ import { ResizableDirective, } from 'components'; import { PlaylistsService, StalkerSessionService } from 'services'; -import { EpgItem, EpgProgram, PlaylistMeta } from 'shared-interfaces'; +import { EpgItem, EpgProgram } from 'shared-interfaces'; import { EpgViewComponent, WebPlayerViewComponent } from 'shared-portals'; import { SettingsStore } from '../../services/settings-store.service'; import { PlayerService } from '../../services/player.service'; @@ -45,6 +45,12 @@ import { StalkerFavoriteItem, StalkerItvChannel, } from '../models'; +import { + buildStalkerExternalPlaybackHeaders, + getStalkerPortalOrigin, + isCrossOriginStalkerStream, + STALKER_MAG_USER_AGENT, +} from '../stalker-live-playback.utils'; import { normalizeStalkerEntityId, } from '../stalker-vod.utils'; @@ -86,9 +92,6 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { private readonly snackBar = inject(MatSnackBar); private readonly translate = inject(TranslateService); private readonly logger = createLogger('StalkerLiveStream'); - private readonly stalkerMagUserAgent = - 'Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG250'; - private readonly stalkerStreamUserAgent = 'KSPlayer'; /** Categories */ readonly categories = this.stalkerStore.getCategoryResource; @@ -279,57 +282,15 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { this.player() === 'artplayer'; try { - const url = await this.stalkerStore.fetchLinkToPlay( - this.currentPlaylist().portalUrl, - this.currentPlaylist().macAddress, - item.cmd - ); + const playback = await this.stalkerStore.resolveItvPlayback(item); this.loadEpgForChannel(item.id); if (isEmbeddedPlayer) { - this.streamUrl = url; + this.streamUrl = playback.streamUrl; } else { - const playlist = this.currentPlaylist(); - const portalOrigin = this.getPortalOrigin(playlist); - const crossOriginStream = this.isCrossOriginStream( - playlist, - url - ); - const playbackHeaders = - this.buildExternalPlaybackHeaders(playlist, url); - const playbackUserAgent = - playbackHeaders['User-Agent'] || - playlist?.userAgent || - this.stalkerMagUserAgent; - const playbackReferer = crossOriginStream - ? undefined - : playlist?.referrer || portalOrigin; - const playbackOrigin = crossOriginStream - ? undefined - : playlist?.origin || portalOrigin; - this.playerService.openPlayer( - url, - item.o_name || item.name, - item.logo, - true, - true, - playbackUserAgent, - playbackReferer, - playbackOrigin, - undefined, - undefined, - playbackHeaders - ); + this.playerService.openResolvedPlayback(playback, true); } - - // Add to recently viewed - this.stalkerStore.addToRecentlyViewed({ - ...item, - id: item.id, - cover: item.logo, - title: item.o_name || item.name, - }); } catch (error) { this.logger.error('Playback failed', error); const errorMessage = @@ -508,91 +469,6 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { } } - private getPortalOrigin( - playlist: PlaylistMeta | undefined | null - ): string | undefined { - const portalUrl = playlist?.portalUrl; - if (!portalUrl) return undefined; - try { - return new URL(portalUrl).origin; - } catch { - return undefined; - } - } - - private buildExternalPlaybackHeaders( - playlist: PlaylistMeta | undefined | null, - streamUrl?: string - ): Record { - if (!playlist?.macAddress) { - return {}; - } - - if (this.isCrossOriginStream(playlist, streamUrl)) { - return { - 'User-Agent': this.stalkerStreamUserAgent, - Accept: '*/*', - Range: 'bytes=0-', - Connection: 'keep-alive', - 'Icy-MetaData': '1', - }; - } - - const cookieParts = [ - `mac=${playlist.macAddress}`, - 'stb_lang=en_US@rg=dezzzz', - 'timezone=Europe/Berlin', - ]; - if (playlist.stalkerSerialNumber) { - cookieParts.push( - `__cfduid=${playlist.stalkerSerialNumber.toLowerCase()}e030245495acd6ebfc1` - ); - } - - const headers: Record = { - Cookie: cookieParts.join('; '), - 'X-User-Agent': this.stalkerMagUserAgent, - SN: playlist.stalkerSerialNumber || '', - }; - - const token = this.stalkerSession.getCachedToken(playlist._id); - if (token) { - headers['Authorization'] = `Bearer ${token}`; - } - - const origin = this.getPortalOrigin(playlist); - if (origin) { - headers['Origin'] = origin; - headers['Referer'] = origin; - } - - return Object.entries(headers).reduce>( - (acc, [name, value]) => { - if (value?.trim()) { - acc[name] = value; - } - return acc; - }, - {} - ); - } - - private isCrossOriginStream( - playlist: PlaylistMeta | undefined | null, - streamUrl?: string - ): boolean { - const portalOrigin = this.getPortalOrigin(playlist); - if (!portalOrigin || !streamUrl) { - return false; - } - - try { - return new URL(streamUrl).origin !== portalOrigin; - } catch { - return false; - } - } - private toPreviewProgram( item: EpgItem, channelId: string | number diff --git a/apps/web/src/app/stalker/stores/features/with-stalker-player.feature.ts b/apps/web/src/app/stalker/stores/features/with-stalker-player.feature.ts index c2848d1d2..145f65be1 100644 --- a/apps/web/src/app/stalker/stores/features/with-stalker-player.feature.ts +++ b/apps/web/src/app/stalker/stores/features/with-stalker-player.feature.ts @@ -11,6 +11,12 @@ import { } from 'shared-interfaces'; import { PlayerService } from '../../../services/player.service'; import { createLogger } from '../../../shared/utils/logger'; +import { + buildStalkerExternalPlaybackHeaders, + getStalkerPortalOrigin, + isCrossOriginStalkerStream, + STALKER_MAG_USER_AGENT, +} from '../../stalker-live-playback.utils'; import { StalkerContentTypes } from '../../stalker-content-types'; import { normalizeStalkerEntityId, @@ -19,7 +25,7 @@ import { type StalkerContentType = 'itv' | 'vod' | 'series'; -interface StalkerPlayableItem extends Record { +interface StalkerPlayableItem { id?: string | number; cmd?: string; has_files?: unknown; @@ -64,7 +70,8 @@ export function withStalkerPlayer() { portalUrl: string, macAddress: string, cmd: string, - series?: number + series?: number, + forcedContentType?: StalkerContentType ) => { const normalizeCmdValue = (value: string): string => { const trimmed = String(value ?? '').trim(); @@ -91,7 +98,7 @@ export function withStalkerPlayer() { }; const selectedContentType = - storeState.selectedContentType(); + forcedContentType ?? storeState.selectedContentType(); const type = series ? 'vod' : selectedContentType; // Always use create_link to get the tokenized streaming URL @@ -323,6 +330,72 @@ export function withStalkerPlayer() { }; }; + const resolveItvPlaybackInternal = async ( + item: StalkerPlayableItem + ): Promise => { + const playlist = storeState.currentPlaylist(); + if (!playlist || !item?.cmd) { + throw new Error('nothing_to_play'); + } + + const streamUrl = await fetchLinkToPlayInternal( + playlist.portalUrl, + playlist.macAddress, + item.cmd, + undefined, + 'itv' + ); + const token = stalkerSession.getCachedToken(playlist._id); + const headers = buildStalkerExternalPlaybackHeaders( + playlist, + token, + streamUrl + ); + const crossOriginStream = isCrossOriginStalkerStream( + playlist, + streamUrl + ); + const portalOrigin = getStalkerPortalOrigin(playlist); + + if (typeof storeState.addToRecentlyViewed === 'function') { + storeState.addToRecentlyViewed({ + ...item, + id: item.id, + cover: + (item as { logo?: string }).logo ?? + (item as { cover?: string }).cover, + title: + (item as { o_name?: string }).o_name || + (item as { name?: string }).name || + (item as { title?: string }).title, + }); + } + + return { + streamUrl, + title: + (item as { o_name?: string }).o_name || + (item as { name?: string }).name || + (item as { title?: string }).title || + '', + thumbnail: + (item as { logo?: string }).logo ?? + (item as { cover?: string }).cover ?? + null, + headers, + userAgent: + headers['User-Agent'] || + playlist.userAgent || + STALKER_MAG_USER_AGENT, + referer: crossOriginStream + ? undefined + : playlist.referrer || portalOrigin, + origin: crossOriginStream + ? undefined + : playlist.origin || portalOrigin, + }; + }; + const addToRecentlyViewedInternal = ( item: StalkerPlayableItem, cmd?: string, @@ -452,6 +525,11 @@ export function withStalkerPlayer() { startTime ); }, + async resolveItvPlayback( + item: StalkerPlayableItem + ): Promise { + return resolveItvPlaybackInternal(item); + }, /** * Play VOD or episode content * @param cmd The media command/path diff --git a/apps/web/src/app/xtream-electron/favorites/favorites.component.html b/apps/web/src/app/xtream-electron/favorites/favorites.component.html index 424710da6..2e8ac6e93 100644 --- a/apps/web/src/app/xtream-electron/favorites/favorites.component.html +++ b/apps/web/src/app/xtream-electron/favorites/favorites.component.html @@ -1,11 +1,66 @@ - +@if (isLiveCategory()) { + + + + + + + + + + + + +} @else { + +} diff --git a/apps/web/src/app/xtream-electron/favorites/favorites.component.ts b/apps/web/src/app/xtream-electron/favorites/favorites.component.ts index 2478cb0bf..408104fa3 100644 --- a/apps/web/src/app/xtream-electron/favorites/favorites.component.ts +++ b/apps/web/src/app/xtream-electron/favorites/favorites.component.ts @@ -1,20 +1,29 @@ import { Component, + effect, OnInit, computed, inject, signal, } from '@angular/core'; -import { MatCardModule } from '@angular/material/card'; +import { MatIconModule } from '@angular/material/icon'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatTooltipModule } from '@angular/material/tooltip'; import { ActivatedRoute, Router } from '@angular/router'; import { Store } from '@ngrx/store'; +import { TranslatePipe } from '@ngx-translate/core'; import { selectActivePlaylist } from 'm3u-state'; import { BehaviorSubject, switchMap } from 'rxjs'; +import { PlayerService } from '../../services/player.service'; import { FavoritesContextService } from '../../workspace/favorites-context.service'; import { + PortalCollectionMode, PortalCollectionShellComponent, PortalCollectionShellLayout, } from '../../shared/components/portal-collection-shell/portal-collection-shell.component'; +import { + PortalCollectionLiveShellComponent, +} from '../../shared/components/portal-collection-live-shell/portal-collection-live-shell.component'; import { queryParamSignal } from '../../shared/navigation/portal-route.utils'; import { createPortalCollectionContext } from '../../shared/utils/portal-collection-context'; import { @@ -23,6 +32,7 @@ import { } from '../../shared/utils/portal-collection-items'; import { FavoriteItem } from '../services/favorite-item.interface'; import { FavoritesService } from '../services/favorites.service'; +import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component'; import { XtreamStore } from '../stores/xtream.store'; const XTREAM_FAVORITES_LAYOUT: PortalCollectionShellLayout = {}; @@ -32,10 +42,21 @@ const XTREAM_COLLECTION_LABELS = { live: 'Live TV', series: 'Series', }; +type LiveChannelSortMode = 'server' | 'name-asc' | 'name-desc'; +const XTREAM_FAVORITES_LIVE_SORT_STORAGE_KEY = + 'xtream-favorites-live-channel-sort-mode'; @Component({ selector: 'app-favorites', - imports: [PortalCollectionShellComponent, MatCardModule], + imports: [ + MatIconModule, + MatMenuModule, + MatTooltipModule, + PortalChannelsListComponent, + PortalCollectionLiveShellComponent, + PortalCollectionShellComponent, + TranslatePipe, + ], templateUrl: './favorites.component.html', styleUrls: [ './favorites.component.scss', @@ -48,6 +69,7 @@ export class FavoritesComponent implements OnInit { private router = inject(Router); private store = inject(Store); private xtreamStore = inject(XtreamStore); + private playerService = inject(PlayerService); private readonly favoritesCtx = inject(FavoritesContextService); readonly currentPlaylist = this.xtreamStore.currentPlaylist; @@ -83,6 +105,34 @@ export class FavoritesComponent implements OnInit { ctx: this.favoritesCtx, categories: this.categories, }); + readonly selectedLiveItem = signal(null); + readonly liveStreamUrl = signal(''); + readonly mode = computed(() => + this.isLiveCategory() ? 'live' : 'grid' + ); + readonly liveItemsToShow = computed(() => + this.live().filter((item) => { + const term = this.searchTerm(); + if (!term) { + return true; + } + + return `${item?.title ?? ''}`.toLowerCase().includes(term); + }) + ); + readonly isLiveCategory = computed(() => this.selectedCategoryId() === 'live'); + readonly isEmbeddedPlayer = computed(() => + this.playerService.isEmbeddedPlayer() + ); + readonly liveChannelSortMode = signal('server'); + readonly liveChannelSortLabel = computed(() => { + const mode = this.liveChannelSortMode(); + if (mode === 'name-asc') return 'Name A-Z'; + if (mode === 'name-desc') return 'Name Z-A'; + return 'Server Order'; + }); + readonly epgItems = this.xtreamStore.epgItems; + readonly isLoadingEpg = this.xtreamStore.isLoadingEpg; /** Synced with the workspace context service so panel clicks are reactive */ readonly selectedCategoryId = this.collectionContext.selectedCategoryId; @@ -104,8 +154,44 @@ export class FavoritesComponent implements OnInit { private favoritesRefresh$ = new BehaviorSubject(undefined); + constructor() { + effect(() => { + const selectedItem = this.selectedLiveItem(); + if (!selectedItem) { + return; + } + + const stillExists = this.liveItemsToShow().some( + (item) => + Number(item.xtream_id) === Number(selectedItem.xtream_id) + ); + + if (!stillExists) { + this.clearLiveSelection(); + } + }); + + effect(() => { + if (this.isLiveCategory()) { + return; + } + + this.clearLiveSelection(); + }); + } + ngOnInit() { this.xtreamStore.setSelectedContentType(undefined); + const savedSortMode = localStorage.getItem( + XTREAM_FAVORITES_LIVE_SORT_STORAGE_KEY + ); + if ( + savedSortMode === 'server' || + savedSortMode === 'name-asc' || + savedSortMode === 'name-desc' + ) { + this.liveChannelSortMode.set(savedSortMode); + } const playlistId = this.store.selectSignal(selectActivePlaylist)()._id; this.favoritesRefresh$ .pipe( @@ -137,8 +223,8 @@ export class FavoritesComponent implements OnInit { const type = item.type === 'movie' ? 'vod' : item.type; this.xtreamStore.setSelectedContentType(type); if (type === 'live') { - const streamUrl = this.xtreamStore.constructStreamUrl(item); - this.xtreamStore.openPlayer(streamUrl, item.title, item.poster_url); + this.setCategoryId('live'); + this.selectLiveItem(item); } else { const routePlaylistId = this.route.snapshot.params['id']; const hasGlobalContext = this.router.url.includes( @@ -170,4 +256,33 @@ export class FavoritesComponent implements OnInit { ); } } + + selectLiveItem(item: FavoriteItem) { + this.xtreamStore.setSelectedContentType('live'); + this.selectedLiveItem.set(item); + const streamUrl = this.xtreamStore.constructStreamUrl(item); + this.liveStreamUrl.set(streamUrl); + + if (this.isEmbeddedPlayer()) { + return; + } + + this.xtreamStore.openPlayer( + streamUrl, + item.title, + item.poster_url || item.stream_icon || null + ); + } + + setLiveChannelSortMode(mode: LiveChannelSortMode): void { + this.liveChannelSortMode.set(mode); + localStorage.setItem(XTREAM_FAVORITES_LIVE_SORT_STORAGE_KEY, mode); + } + + private clearLiveSelection() { + this.selectedLiveItem.set(null); + this.liveStreamUrl.set(''); + this.xtreamStore.clearEpg(); + this.xtreamStore.setSelectedItem(null); + } } diff --git a/apps/web/src/app/xtream-electron/live-stream-layout/live-stream-layout.component.html b/apps/web/src/app/xtream-electron/live-stream-layout/live-stream-layout.component.html index f041da746..e3df891f5 100644 --- a/apps/web/src/app/xtream-electron/live-stream-layout/live-stream-layout.component.html +++ b/apps/web/src/app/xtream-electron/live-stream-layout/live-stream-layout.component.html @@ -111,13 +111,11 @@ } @else { -
- @if (!selectedCategoryId()) { - - {{ 'PORTALS.SELECT_CATEGORY' | translate }} - } +
+ live_tv +

+ {{ 'CHANNELS.SELECT_CHANNEL_PLAYBACK' | translate }} +

} diff --git a/apps/web/src/app/xtream-electron/live-stream-layout/live-stream-layout.component.scss b/apps/web/src/app/xtream-electron/live-stream-layout/live-stream-layout.component.scss index 7450a5b04..df72fd2d4 100644 --- a/apps/web/src/app/xtream-electron/live-stream-layout/live-stream-layout.component.scss +++ b/apps/web/src/app/xtream-electron/live-stream-layout/live-stream-layout.component.scss @@ -90,14 +90,54 @@ margin: 0; } -.no-channel-selected { - padding: 16px; - margin: 0; - font-size: 1.2rem; - font-weight: 500; +.empty-state-container { display: flex; + flex: 1; + flex-direction: column; align-items: center; - gap: 10px; + justify-content: center; + min-height: 0; + text-align: center; + color: rgba(255, 255, 255, 0.5); + animation: placeholder-fade-in 220ms ease-out; +} + +.empty-state-icon { + font-size: 72px; + width: 72px; + height: 72px; + margin-bottom: 24px; + opacity: 0.2; + animation: placeholder-icon-float 2.8s ease-in-out infinite; +} + +.empty-state-title { + font-family: 'DM Sans', sans-serif; + font-weight: 500; + font-size: 1.25rem; + margin: 0; + opacity: 0.5; +} + +@keyframes placeholder-fade-in { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes placeholder-icon-float { + 0%, + 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-4px); + } } ::ng-deep { diff --git a/apps/web/src/app/xtream-electron/portal-channels-list/portal-channels-list.component.ts b/apps/web/src/app/xtream-electron/portal-channels-list/portal-channels-list.component.ts index 381b50524..3b1efbad4 100644 --- a/apps/web/src/app/xtream-electron/portal-channels-list/portal-channels-list.component.ts +++ b/apps/web/src/app/xtream-electron/portal-channels-list/portal-channels-list.component.ts @@ -52,12 +52,20 @@ type LiveChannelSortMode = 'server' | 'name-asc' | 'name-desc'; export class PortalChannelsListComponent implements AfterViewInit, OnDestroy { readonly playClicked = output(); readonly sortMode = input('server'); + readonly channelsOverride = input(null); readonly xtreamStore = inject(XtreamStore); private readonly favoritesService = inject(FavoritesService); private readonly epgQueueService = inject(EpgQueueService); private readonly route = inject(ActivatedRoute); - readonly channels = this.xtreamStore.selectItemsFromSelectedCategory; + readonly channels = computed(() => { + const override = this.channelsOverride(); + if (Array.isArray(override)) { + return override; + } + + return this.xtreamStore.selectItemsFromSelectedCategory(); + }); readonly sortedChannels = computed(() => { const mode = this.sortMode(); const channels = this.channels(); @@ -111,7 +119,7 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy { ngOnInit(): void { const { categoryId } = this.route.snapshot.params; - if (categoryId) + if (categoryId && !this.channelsOverride()) this.xtreamStore.setSelectedCategory(Number(categoryId)); const playlist = this.xtreamStore.currentPlaylist(); diff --git a/apps/web/src/app/xtream-electron/recently-viewed/recently-viewed.component.html b/apps/web/src/app/xtream-electron/recently-viewed/recently-viewed.component.html index 0779cac69..3781767f3 100644 --- a/apps/web/src/app/xtream-electron/recently-viewed/recently-viewed.component.html +++ b/apps/web/src/app/xtream-electron/recently-viewed/recently-viewed.component.html @@ -119,16 +119,71 @@ }
} @else { - + @if (isLiveCategory()) { + + + + + + + + + + + + + } @else { + + } } diff --git a/apps/web/src/app/xtream-electron/recently-viewed/recently-viewed.component.ts b/apps/web/src/app/xtream-electron/recently-viewed/recently-viewed.component.ts index 1f05c93f1..bdbdfa622 100644 --- a/apps/web/src/app/xtream-electron/recently-viewed/recently-viewed.component.ts +++ b/apps/web/src/app/xtream-electron/recently-viewed/recently-viewed.component.ts @@ -2,6 +2,7 @@ import { KeyValuePipe } from '@angular/common'; import { Component, computed, + effect, inject, Optional, signal, @@ -12,12 +13,20 @@ import { MatButton, MatIconButton } from '@angular/material/button'; import { MatCheckboxModule } from '@angular/material/checkbox'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MatIcon } from '@angular/material/icon'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatTooltipModule } from '@angular/material/tooltip'; import { ActivatedRoute, Router } from '@angular/router'; +import { TranslatePipe } from '@ngx-translate/core'; import groupBy from 'lodash/groupBy'; import { firstValueFrom } from 'rxjs'; import { DatabaseService, PlaylistsService } from 'services'; +import { PlayerService } from '../../services/player.service'; import { ContentCardComponent } from '../../shared/components/content-card/content-card.component'; import { + PortalCollectionLiveShellComponent, +} from '../../shared/components/portal-collection-live-shell/portal-collection-live-shell.component'; +import { + PortalCollectionMode, PortalCollectionShellComponent, PortalCollectionShellLayout, } from '../../shared/components/portal-collection-shell/portal-collection-shell.component'; @@ -32,6 +41,7 @@ import { } from '../../shared/utils/portal-collection-items'; import { createLogger } from '../../shared/utils/logger'; import { FavoritesContextService } from '../../workspace/favorites-context.service'; +import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component'; import { XtreamStore } from '../stores/xtream.store'; const XTREAM_RECENT_LAYOUT: Omit< @@ -50,6 +60,9 @@ const XTREAM_COLLECTION_LABELS = { live: 'Live TV', series: 'Series', }; +type LiveChannelSortMode = 'server' | 'name-asc' | 'name-desc'; +const XTREAM_RECENT_LIVE_SORT_STORAGE_KEY = + 'xtream-recent-live-channel-sort-mode'; @Component({ selector: 'app-recently-viewed', @@ -61,7 +74,12 @@ const XTREAM_COLLECTION_LABELS = { MatCheckboxModule, MatIcon, MatIconButton, + MatMenuModule, + MatTooltipModule, + PortalChannelsListComponent, + PortalCollectionLiveShellComponent, PortalCollectionShellComponent, + TranslatePipe, ], providers: [], templateUrl: './recently-viewed.component.html', @@ -79,6 +97,7 @@ export class RecentlyViewedComponent { private router = inject(Router); private readonly dbService = inject(DatabaseService); private readonly playlistsService = inject(PlaylistsService); + private readonly playerService = inject(PlayerService); private dialogData = inject(MAT_DIALOG_DATA, { optional: true }); private readonly logger = createLogger('XtreamRecentlyViewed'); private readonly favoritesCtx = inject(FavoritesContextService); @@ -151,6 +170,11 @@ export class RecentlyViewedComponent { categories: this.categories, enabled: () => !this.isGlobal, }); + readonly selectedLiveItem = signal(null); + readonly liveStreamUrl = signal(''); + readonly nonGlobalMode = computed(() => + this.isLiveCategory() ? 'live' : 'grid' + ); readonly selectedCategoryId = this.collectionContext.selectedCategoryId; readonly recentSearchTerm = signal(''); readonly workspaceSearchTerm = queryParamSignal( @@ -202,6 +226,36 @@ export class RecentlyViewedComponent { `${item?.title ?? ''} ${item?.playlist_name ?? ''}`, }); }); + readonly liveItemsToShow = computed(() => + this.visibleRecentItems().filter((item: any) => { + if (item?.type !== 'live') { + return false; + } + + const term = + this.isWorkspaceLayout && !this.isGlobal + ? this.workspaceSearchTerm() + : this.recentSearchTerm().trim().toLowerCase(); + if (!term) { + return true; + } + + return `${item?.title ?? ''} ${item?.playlist_name ?? ''}` + .toLowerCase() + .includes(term); + }) + ); + readonly isLiveCategory = computed(() => this.selectedCategoryId() === 'live'); + readonly isEmbeddedPlayer = computed(() => + this.playerService.isEmbeddedPlayer() + ); + readonly liveChannelSortMode = signal('server'); + readonly liveChannelSortLabel = computed(() => { + const mode = this.liveChannelSortMode(); + if (mode === 'name-asc') return 'Name A-Z'; + if (mode === 'name-desc') return 'Name Z-A'; + return 'Server Order'; + }); readonly currentPlaylist = this.xtreamStore.currentPlaylist; readonly playlistTitle = computed( () => @@ -209,11 +263,52 @@ export class RecentlyViewedComponent { this.currentPlaylist()?.title || 'Playlist' ); + readonly epgItems = this.xtreamStore.epgItems; + readonly isLoadingEpg = this.xtreamStore.isLoadingEpg; constructor( @Optional() public dialogRef?: MatDialogRef ) { this.xtreamStore.setSelectedContentType(undefined); + const savedSortMode = localStorage.getItem( + XTREAM_RECENT_LIVE_SORT_STORAGE_KEY + ); + if ( + savedSortMode === 'server' || + savedSortMode === 'name-asc' || + savedSortMode === 'name-desc' + ) { + this.liveChannelSortMode.set(savedSortMode); + } + + effect(() => { + if (this.isGlobal) { + return; + } + + const selectedItem = this.selectedLiveItem(); + if (!selectedItem) { + return; + } + + const stillExists = this.liveItemsToShow().some( + (item) => + Number(item.xtream_id ?? item.id) === + Number(selectedItem.xtream_id ?? selectedItem.id) + ); + + if (!stillExists) { + this.clearLiveSelection(); + } + }); + + effect(() => { + if (this.isGlobal || this.isLiveCategory()) { + return; + } + + this.clearLiveSelection(); + }); if (this.isGlobal) { this.loadGlobalItems(); @@ -268,6 +363,12 @@ export class RecentlyViewedComponent { const type = item.type === 'movie' ? 'vod' : item.type; this.xtreamStore.setSelectedContentType(type); + if (!this.isGlobal && type === 'live') { + this.setCategoryId('live'); + this.selectLiveItem(item); + return; + } + if (this.isGlobal) { this.dialogRef?.close(); @@ -352,4 +453,33 @@ export class RecentlyViewedComponent { setCategoryId(categoryId: string): void { this.collectionContext.setCategoryId(categoryId); } + + selectLiveItem(item: any): void { + this.xtreamStore.setSelectedContentType('live'); + this.selectedLiveItem.set(item); + const streamUrl = this.xtreamStore.constructStreamUrl(item); + this.liveStreamUrl.set(streamUrl); + + if (this.isEmbeddedPlayer()) { + return; + } + + this.xtreamStore.openPlayer( + streamUrl, + item.title, + item.poster_url || item.stream_icon || null + ); + } + + setLiveChannelSortMode(mode: LiveChannelSortMode): void { + this.liveChannelSortMode.set(mode); + localStorage.setItem(XTREAM_RECENT_LIVE_SORT_STORAGE_KEY, mode); + } + + private clearLiveSelection(): void { + this.selectedLiveItem.set(null); + this.liveStreamUrl.set(''); + this.xtreamStore.clearEpg(); + this.xtreamStore.setSelectedItem(null); + } } diff --git a/libs/ui/shared-portals/src/lib/epg-view/epg-view.component.scss b/libs/ui/shared-portals/src/lib/epg-view/epg-view.component.scss index 54ba6824a..df8a8aa08 100644 --- a/libs/ui/shared-portals/src/lib/epg-view/epg-view.component.scss +++ b/libs/ui/shared-portals/src/lib/epg-view/epg-view.component.scss @@ -16,10 +16,6 @@ position: sticky; top: 0; z-index: 2; - background: - linear-gradient(180deg, rgba(18, 24, 38, 0.98), rgba(18, 24, 38, 0.94)), - rgba(12, 16, 24, 0.96); - border-bottom: 1px solid rgba(255, 255, 255, 0.06); } .epg-list { diff --git a/libs/ui/shared-portals/src/lib/web-player-view/web-player-view.component.ts b/libs/ui/shared-portals/src/lib/web-player-view/web-player-view.component.ts index 420e599b6..4ec4847df 100644 --- a/libs/ui/shared-portals/src/lib/web-player-view/web-player-view.component.ts +++ b/libs/ui/shared-portals/src/lib/web-player-view/web-player-view.component.ts @@ -45,11 +45,10 @@ export class WebPlayerViewComponent { constructor() { effect(() => { - this.player = this.settings()?.player; + this.player = this.settings()?.player ?? VideoPlayer.VideoJs; this.setChannel(this.streamUrl()); - if (this.player === VideoPlayer.VideoJs) - this.setVjsOptions(this.streamUrl()); + this.setVjsOptions(this.streamUrl()); }); }