mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:26:43 -08:00
feat: implement Stalker playback utilities and enhance live stream layout
- Add utility functions for handling Stalker playback, including building headers and checking cross-origin streams. - Update the live stream layout to improve the user experience with a new empty state design. - Refactor favorites and recently viewed components to support live channel selection and sorting. - Introduce a new live channel sorting feature in favorites and recently viewed sections. - Enhance the portal channels list component to handle channel overrides and sorting. - Remove unnecessary styles and improve the overall layout for better responsiveness.
This commit is contained in:
1 parent
e2cdcd5f22
commit
d847524d0d
26 files changed
+1845
-272
No files matched your search
+57
@@ -0,0 +1,57 @@
|
||||
<div
|
||||
class="sidebar"
|
||||
appResizable
|
||||
[minWidth]="250"
|
||||
[maxWidth]="600"
|
||||
[defaultWidth]="400"
|
||||
storageKey="sidebar-width"
|
||||
>
|
||||
<div class="sidebar-header category-content-header">
|
||||
<div class="category-meta">
|
||||
<h2 class="category-title">{{ title() }}</h2>
|
||||
<div class="category-subtitle">{{ subtitle() }}</div>
|
||||
</div>
|
||||
<ng-content select="[liveHeaderActions]" />
|
||||
</div>
|
||||
|
||||
<ng-content select="[liveChannels]" />
|
||||
</div>
|
||||
|
||||
<div class="content-container">
|
||||
@if (hasSelection()) {
|
||||
@if (streamUrl() && isEmbeddedPlayer()) {
|
||||
<div class="video-player">
|
||||
<app-web-player-view [streamUrl]="streamUrl()" />
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="epg">
|
||||
<div class="epg-content">
|
||||
@if (epgItems().length > 0) {
|
||||
<app-epg-view [epgItems]="epgItems()" />
|
||||
}
|
||||
|
||||
@if (isLoadingEpg()) {
|
||||
<div class="loading-indicator">
|
||||
<mat-spinner diameter="24" />
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (hasMoreEpg() && !isLoadingEpg()) {
|
||||
<div class="load-more-epg">
|
||||
<button mat-button (click)="onLoadMoreEpg()">
|
||||
{{ 'CHANNELS.LOAD_MORE' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="empty-state-container">
|
||||
<mat-icon class="empty-state-icon">live_tv</mat-icon>
|
||||
<h2 class="empty-state-title">
|
||||
{{ 'CHANNELS.SELECT_CHANNEL_PLAYBACK' | translate }}
|
||||
</h2>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
+187
@@ -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;
|
||||
}
|
||||
}
|
||||
+41
@@ -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<string>('Favorites');
|
||||
readonly subtitle = input<string>('Live TV');
|
||||
readonly count = input<number>(0);
|
||||
readonly streamUrl = input<string>('');
|
||||
readonly isEmbeddedPlayer = input<boolean>(true);
|
||||
readonly hasSelection = input<boolean>(false);
|
||||
readonly epgItems = input<EpgItem[]>([]);
|
||||
readonly isLoadingEpg = input<boolean>(false);
|
||||
readonly hasMoreEpg = input<boolean>(false);
|
||||
|
||||
readonly loadMoreEpgClicked = output<void>();
|
||||
|
||||
onLoadMoreEpg(): void {
|
||||
this.loadMoreEpgClicked.emit();
|
||||
}
|
||||
}
|
||||
+9
-3
@@ -1,6 +1,11 @@
|
||||
@if (showDetails()) {
|
||||
<ng-content select="[details]" />
|
||||
} @else {
|
||||
@switch (mode()) {
|
||||
@case ('detail') {
|
||||
<ng-content select="[details]" />
|
||||
}
|
||||
@case ('live') {
|
||||
<ng-content select="[live]" />
|
||||
}
|
||||
@default {
|
||||
<app-favorites-layout
|
||||
[categories]="categories()"
|
||||
[favorites]="items()"
|
||||
@@ -20,4 +25,5 @@
|
||||
(headerActionClicked)="headerActionClicked.emit()"
|
||||
(openItem)="openItem.emit($event)"
|
||||
/>
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -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<string>('Playlist');
|
||||
readonly selectedCategoryId = input<string>('all');
|
||||
readonly layout = input<PortalCollectionShellLayout>({});
|
||||
readonly showDetails = input<boolean>(false);
|
||||
readonly mode = input<PortalCollectionMode>('grid');
|
||||
|
||||
readonly categoryClicked = output<string>();
|
||||
readonly removeItem = output<any>();
|
||||
|
||||
@@ -1,25 +1,49 @@
|
||||
<app-portal-collection-shell
|
||||
[categories]="categories() ?? []"
|
||||
[items]="itemsToShow() ?? []"
|
||||
[playlistSubtitle]="playlistSubtitle"
|
||||
[playlistTitle]="playlistTitle()"
|
||||
[selectedCategoryId]="selectedCategoryId()"
|
||||
[layout]="layout"
|
||||
[showDetails]="showDetails()"
|
||||
(categoryClicked)="setCategoryId($event)"
|
||||
(removeItem)="removeFromRecentlyViewed($event)"
|
||||
(headerActionClicked)="clearAllRecentlyViewed()"
|
||||
(openItem)="openItem($event)"
|
||||
>
|
||||
<app-stalker-inline-detail
|
||||
details
|
||||
[categoryId]="inlineDetail().categoryId"
|
||||
[seriesItem]="inlineDetail().seriesItem"
|
||||
[isSeries]="inlineDetail().isSeries"
|
||||
[vodDetailsItem]="inlineDetail().vodDetailsItem"
|
||||
[isFavorite]="isSelectedVodFavorite()"
|
||||
(playClicked)="onVodPlay($event)"
|
||||
(favoriteToggled)="onVodFavoriteToggled($event)"
|
||||
(backClicked)="onVodBack()"
|
||||
/>
|
||||
</app-portal-collection-shell>
|
||||
@if (isLiveCategory()) {
|
||||
<app-portal-collection-live-shell
|
||||
[title]="'PORTALS.SIDEBAR.RECENT' | translate"
|
||||
[subtitle]="'PORTALS.SIDEBAR.LIVE_TV' | translate"
|
||||
[count]="liveItemsToShow().length"
|
||||
[streamUrl]="liveStreamUrl()"
|
||||
[isEmbeddedPlayer]="isEmbeddedPlayer()"
|
||||
[hasSelection]="!!selectedLiveItem()"
|
||||
[epgItems]="epgItems()"
|
||||
[isLoadingEpg]="isLoadingEpg()"
|
||||
[hasMoreEpg]="hasMoreEpg()"
|
||||
(loadMoreEpgClicked)="loadMoreEpg()"
|
||||
>
|
||||
<app-stalker-collection-channels-list
|
||||
liveChannels
|
||||
[items]="liveItemsToShow()"
|
||||
[selectedItemId]="selectedLiveItem()?.id ?? null"
|
||||
[favoriteIds]="liveFavoriteIds()"
|
||||
(playClicked)="selectLiveItem($event)"
|
||||
(favoriteToggled)="toggleLiveFavorite($event)"
|
||||
/>
|
||||
</app-portal-collection-live-shell>
|
||||
} @else {
|
||||
<app-portal-collection-shell
|
||||
[categories]="categories() ?? []"
|
||||
[items]="itemsToShow() ?? []"
|
||||
[playlistSubtitle]="playlistSubtitle"
|
||||
[playlistTitle]="playlistTitle()"
|
||||
[selectedCategoryId]="selectedCategoryId()"
|
||||
[layout]="layout"
|
||||
[mode]="mode"
|
||||
(categoryClicked)="setCategoryId($event)"
|
||||
(removeItem)="removeFromRecentlyViewed($event)"
|
||||
(headerActionClicked)="clearAllRecentlyViewed()"
|
||||
(openItem)="openItem($event)"
|
||||
>
|
||||
<app-stalker-inline-detail
|
||||
details
|
||||
[categoryId]="inlineDetail().categoryId"
|
||||
[seriesItem]="inlineDetail().seriesItem"
|
||||
[isSeries]="inlineDetail().isSeries"
|
||||
[vodDetailsItem]="inlineDetail().vodDetailsItem"
|
||||
[isFavorite]="isSelectedVodFavorite()"
|
||||
(playClicked)="onVodPlay($event)"
|
||||
(favoriteToggled)="onVodFavoriteToggled($event)"
|
||||
(backClicked)="onVodBack()"
|
||||
/>
|
||||
</app-portal-collection-shell>
|
||||
}
|
||||
@@ -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<boolean>(false);
|
||||
readonly selectedLiveItem = signal<
|
||||
(StalkerVodSource & { category_id: string }) | null
|
||||
>(null);
|
||||
readonly liveStreamUrl = signal('');
|
||||
readonly epgItems = signal<EpgItem[]>([]);
|
||||
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<StalkerVodSource, 'id'>) {
|
||||
@@ -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<string | number, boolean>();
|
||||
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 });
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
<div class="search-panel">
|
||||
<label class="search-field">
|
||||
<mat-icon>search</mat-icon>
|
||||
<input
|
||||
type="search"
|
||||
spellcheck="false"
|
||||
[placeholder]="'CHANNELS.SEARCH_CHANNEL' | translate"
|
||||
[(ngModel)]="searchString"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="channels-list">
|
||||
@if (filteredItems().length > 0) {
|
||||
@for (item of filteredItems(); track item.id) {
|
||||
<app-channel-list-item
|
||||
[name]="item.o_name || item.name || ''"
|
||||
[logo]="item.logo"
|
||||
[selected]="isSelected(item)"
|
||||
[epgProgram]="
|
||||
epgPrograms.get(normalizeStalkerEntityId(item.id)) ?? null
|
||||
"
|
||||
[progressPercentage]="
|
||||
currentProgramsProgress.get(normalizeStalkerEntityId(item.id)) ?? 0
|
||||
"
|
||||
[showFavoriteButton]="true"
|
||||
[showProgramInfoButton]="false"
|
||||
[isFavorite]="isFavorite(item)"
|
||||
(clicked)="onPlay(item)"
|
||||
(favoriteToggled)="onFavoriteToggle(item)"
|
||||
/>
|
||||
}
|
||||
} @else {
|
||||
<div class="empty-search-state">
|
||||
<div class="empty-icon-wrapper">
|
||||
<mat-icon>search_off</mat-icon>
|
||||
</div>
|
||||
<h3>{{ 'CHANNELS.NO_CHANNELS_FOUND' | translate }}</h3>
|
||||
<p>We couldn't find anything matching "{{ searchString() }}"</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
+126
@@ -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;
|
||||
}
|
||||
}
|
||||
+159
@@ -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<StalkerVodSource[]>([]);
|
||||
readonly selectedItemId = input<string | number | null>(null);
|
||||
readonly favoriteIds = input<Map<string | number, boolean>>(new Map());
|
||||
|
||||
readonly playClicked = output<any>();
|
||||
readonly favoriteToggled = output<any>();
|
||||
|
||||
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<string | number, EpgProgram>();
|
||||
readonly currentProgramsProgress = new Map<string | number, number>();
|
||||
private readonly requestedChannels = new Set<string | number>();
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,48 @@
|
||||
<app-portal-collection-shell
|
||||
[categories]="categories() ?? []"
|
||||
[items]="favoritesToShow() ?? []"
|
||||
[playlistSubtitle]="playlistSubtitle"
|
||||
[playlistTitle]="playlistTitle()"
|
||||
[selectedCategoryId]="selectedCategoryId()"
|
||||
[layout]="layout"
|
||||
[showDetails]="showDetails()"
|
||||
(categoryClicked)="setCategoryId($event)"
|
||||
(removeItem)="removeFromFavorites($event)"
|
||||
(openItem)="openItem($event)"
|
||||
>
|
||||
<app-stalker-inline-detail
|
||||
details
|
||||
[categoryId]="inlineDetail().categoryId"
|
||||
[seriesItem]="inlineDetail().seriesItem"
|
||||
[isSeries]="inlineDetail().isSeries"
|
||||
[vodDetailsItem]="inlineDetail().vodDetailsItem"
|
||||
[isFavorite]="isSelectedVodFavorite()"
|
||||
(playClicked)="onVodPlay($event)"
|
||||
(favoriteToggled)="onVodFavoriteToggled($event)"
|
||||
(backClicked)="onSeriesBack()"
|
||||
/>
|
||||
</app-portal-collection-shell>
|
||||
@if (isLiveCategory()) {
|
||||
<app-portal-collection-live-shell
|
||||
[title]="'CHANNELS.FAVORITES' | translate"
|
||||
[subtitle]="'PORTALS.SIDEBAR.LIVE_TV' | translate"
|
||||
[count]="liveItemsToShow().length"
|
||||
[streamUrl]="liveStreamUrl()"
|
||||
[isEmbeddedPlayer]="isEmbeddedPlayer()"
|
||||
[hasSelection]="!!selectedLiveItem()"
|
||||
[epgItems]="epgItems()"
|
||||
[isLoadingEpg]="isLoadingEpg()"
|
||||
[hasMoreEpg]="hasMoreEpg()"
|
||||
(loadMoreEpgClicked)="loadMoreEpg()"
|
||||
>
|
||||
<app-stalker-collection-channels-list
|
||||
liveChannels
|
||||
[items]="liveItemsToShow()"
|
||||
[selectedItemId]="selectedLiveItem()?.id ?? null"
|
||||
[favoriteIds]="liveFavoriteIds()"
|
||||
(playClicked)="selectLiveItem($event)"
|
||||
(favoriteToggled)="toggleLiveFavorite($event)"
|
||||
/>
|
||||
</app-portal-collection-live-shell>
|
||||
} @else {
|
||||
<app-portal-collection-shell
|
||||
[categories]="categories() ?? []"
|
||||
[items]="favoritesToShow() ?? []"
|
||||
[playlistSubtitle]="playlistSubtitle"
|
||||
[playlistTitle]="playlistTitle()"
|
||||
[selectedCategoryId]="selectedCategoryId()"
|
||||
[layout]="layout"
|
||||
[mode]="mode"
|
||||
(categoryClicked)="setCategoryId($event)"
|
||||
(removeItem)="removeFromFavorites($event)"
|
||||
(openItem)="openItem($event)"
|
||||
>
|
||||
<app-stalker-inline-detail
|
||||
details
|
||||
[categoryId]="inlineDetail().categoryId"
|
||||
[seriesItem]="inlineDetail().seriesItem"
|
||||
[isSeries]="inlineDetail().isSeries"
|
||||
[vodDetailsItem]="inlineDetail().vodDetailsItem"
|
||||
[isFavorite]="isSelectedVodFavorite()"
|
||||
(playClicked)="onVodPlay($event)"
|
||||
(favoriteToggled)="onVodFavoriteToggled($event)"
|
||||
(backClicked)="onSeriesBack()"
|
||||
/>
|
||||
</app-portal-collection-shell>
|
||||
}
|
||||
@@ -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<boolean>(false);
|
||||
readonly selectedLiveItem = signal<StalkerFavoriteItem | null>(null);
|
||||
readonly liveStreamUrl = signal('');
|
||||
readonly epgItems = signal<EpgItem[]>([]);
|
||||
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<StalkerFavoriteItem[]>(() =>
|
||||
((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<string | number, boolean>();
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -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<string, string> {
|
||||
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<string, string> = {
|
||||
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<Record<string, string>>(
|
||||
(acc, [name, value]) => {
|
||||
if (value?.trim()) {
|
||||
acc[name] = value;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
}
|
||||
+5
-5
@@ -128,11 +128,11 @@
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="no-channel-selected">
|
||||
<button mat-icon-button disabled>
|
||||
<mat-icon>category</mat-icon>
|
||||
</button>
|
||||
{{ 'PORTALS.SELECT_CATEGORY' | translate }}
|
||||
<div class="empty-state-container">
|
||||
<mat-icon class="empty-state-icon">live_tv</mat-icon>
|
||||
<h2 class="empty-state-title">
|
||||
{{ 'CHANNELS.SELECT_CHANNEL_PLAYBACK' | translate }}
|
||||
</h2>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
+46
-6
@@ -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;
|
||||
|
||||
+10
-134
@@ -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<string, string> {
|
||||
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<string, string> = {
|
||||
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<Record<string, string>>(
|
||||
(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
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
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<ResolvedPortalPlayback> => {
|
||||
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<ResolvedPortalPlayback> {
|
||||
return resolveItvPlaybackInternal(item);
|
||||
},
|
||||
/**
|
||||
* Play VOD or episode content
|
||||
* @param cmd The media command/path
|
||||
|
||||
@@ -1,11 +1,66 @@
|
||||
<app-portal-collection-shell
|
||||
[categories]="categories()"
|
||||
[items]="filteredFavoritesToShow()"
|
||||
[playlistSubtitle]="playlistSubtitle"
|
||||
[playlistTitle]="playlistTitle()"
|
||||
[selectedCategoryId]="selectedCategoryId()"
|
||||
[layout]="layout"
|
||||
(categoryClicked)="setCategoryId($event)"
|
||||
(removeItem)="removeFromFavorites($event)"
|
||||
(openItem)="openItem($event)"
|
||||
/>
|
||||
@if (isLiveCategory()) {
|
||||
<app-portal-collection-live-shell
|
||||
[title]="'CHANNELS.FAVORITES' | translate"
|
||||
[subtitle]="'PORTALS.SIDEBAR.LIVE_TV' | translate"
|
||||
[count]="liveItemsToShow().length"
|
||||
[streamUrl]="liveStreamUrl()"
|
||||
[isEmbeddedPlayer]="isEmbeddedPlayer()"
|
||||
[hasSelection]="!!selectedLiveItem()"
|
||||
[epgItems]="epgItems()"
|
||||
[isLoadingEpg]="isLoadingEpg()"
|
||||
>
|
||||
<ng-container liveHeaderActions>
|
||||
<button
|
||||
mat-icon-button
|
||||
[matMenuTriggerFor]="channelSortMenu"
|
||||
[matTooltip]="'Sort: ' + liveChannelSortLabel()"
|
||||
aria-label="Sort channels"
|
||||
>
|
||||
<mat-icon>sort_by_alpha</mat-icon>
|
||||
</button>
|
||||
<mat-menu #channelSortMenu="matMenu">
|
||||
<button mat-menu-item (click)="setLiveChannelSortMode('server')">
|
||||
<span>Server Order</span>
|
||||
@if (liveChannelSortMode() === 'server') {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
<button mat-menu-item (click)="setLiveChannelSortMode('name-asc')">
|
||||
<span>Name A-Z</span>
|
||||
@if (liveChannelSortMode() === 'name-asc') {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="setLiveChannelSortMode('name-desc')"
|
||||
>
|
||||
<span>Name Z-A</span>
|
||||
@if (liveChannelSortMode() === 'name-desc') {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
</mat-menu>
|
||||
</ng-container>
|
||||
|
||||
<app-portal-channels-list
|
||||
liveChannels
|
||||
[sortMode]="liveChannelSortMode()"
|
||||
[channelsOverride]="liveItemsToShow()"
|
||||
(playClicked)="selectLiveItem($event)"
|
||||
/>
|
||||
</app-portal-collection-live-shell>
|
||||
} @else {
|
||||
<app-portal-collection-shell
|
||||
[categories]="categories()"
|
||||
[items]="filteredFavoritesToShow()"
|
||||
[playlistSubtitle]="playlistSubtitle"
|
||||
[playlistTitle]="playlistTitle()"
|
||||
[selectedCategoryId]="selectedCategoryId()"
|
||||
[layout]="layout"
|
||||
[mode]="mode()"
|
||||
(categoryClicked)="setCategoryId($event)"
|
||||
(removeItem)="removeFromFavorites($event)"
|
||||
(openItem)="openItem($event)"
|
||||
/>
|
||||
}
|
||||
@@ -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<FavoriteItem | null>(null);
|
||||
readonly liveStreamUrl = signal('');
|
||||
readonly mode = computed<PortalCollectionMode>(() =>
|
||||
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<LiveChannelSortMode>('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<void>(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);
|
||||
}
|
||||
}
|
||||
+5
-7
@@ -111,13 +111,11 @@
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="no-channel-selected">
|
||||
@if (!selectedCategoryId()) {
|
||||
<button mat-icon-button disabled>
|
||||
<mat-icon>category</mat-icon>
|
||||
</button>
|
||||
{{ 'PORTALS.SELECT_CATEGORY' | translate }}
|
||||
}
|
||||
<div class="empty-state-container">
|
||||
<mat-icon class="empty-state-icon">live_tv</mat-icon>
|
||||
<h2 class="empty-state-title">
|
||||
{{ 'CHANNELS.SELECT_CHANNEL_PLAYBACK' | translate }}
|
||||
</h2>
|
||||
</div>
|
||||
}
|
||||
<!-- <app-mpv-player-bar /> -->
|
||||
|
||||
+46
-6
@@ -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 {
|
||||
|
||||
+10
-2
@@ -52,12 +52,20 @@ type LiveChannelSortMode = 'server' | 'name-asc' | 'name-desc';
|
||||
export class PortalChannelsListComponent implements AfterViewInit, OnDestroy {
|
||||
readonly playClicked = output<any>();
|
||||
readonly sortMode = input<LiveChannelSortMode>('server');
|
||||
readonly channelsOverride = input<any[] | null>(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();
|
||||
|
||||
@@ -119,16 +119,71 @@
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<app-portal-collection-shell
|
||||
[categories]="categories()"
|
||||
[items]="nonGlobalItemsToShow()"
|
||||
[playlistSubtitle]="playlistSubtitle"
|
||||
[playlistTitle]="playlistTitle()"
|
||||
[selectedCategoryId]="selectedCategoryId()"
|
||||
[layout]="nonGlobalLayout"
|
||||
(categoryClicked)="setCategoryId($event)"
|
||||
(removeItem)="onRemoveItem($event)"
|
||||
(headerActionClicked)="clearHistory()"
|
||||
(openItem)="openItem($event)"
|
||||
/>
|
||||
@if (isLiveCategory()) {
|
||||
<app-portal-collection-live-shell
|
||||
[title]="'PORTALS.SIDEBAR.RECENT' | translate"
|
||||
[subtitle]="'PORTALS.SIDEBAR.LIVE_TV' | translate"
|
||||
[count]="liveItemsToShow().length"
|
||||
[streamUrl]="liveStreamUrl()"
|
||||
[isEmbeddedPlayer]="isEmbeddedPlayer()"
|
||||
[hasSelection]="!!selectedLiveItem()"
|
||||
[epgItems]="epgItems()"
|
||||
[isLoadingEpg]="isLoadingEpg()"
|
||||
>
|
||||
<ng-container liveHeaderActions>
|
||||
<button
|
||||
mat-icon-button
|
||||
[matMenuTriggerFor]="channelSortMenu"
|
||||
[matTooltip]="'Sort: ' + liveChannelSortLabel()"
|
||||
aria-label="Sort channels"
|
||||
>
|
||||
<mat-icon>sort_by_alpha</mat-icon>
|
||||
</button>
|
||||
<mat-menu #channelSortMenu="matMenu">
|
||||
<button mat-menu-item (click)="setLiveChannelSortMode('server')">
|
||||
<span>Server Order</span>
|
||||
@if (liveChannelSortMode() === 'server') {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
<button mat-menu-item (click)="setLiveChannelSortMode('name-asc')">
|
||||
<span>Name A-Z</span>
|
||||
@if (liveChannelSortMode() === 'name-asc') {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="setLiveChannelSortMode('name-desc')"
|
||||
>
|
||||
<span>Name Z-A</span>
|
||||
@if (liveChannelSortMode() === 'name-desc') {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
</mat-menu>
|
||||
</ng-container>
|
||||
|
||||
<app-portal-channels-list
|
||||
liveChannels
|
||||
[sortMode]="liveChannelSortMode()"
|
||||
[channelsOverride]="liveItemsToShow()"
|
||||
(playClicked)="selectLiveItem($event)"
|
||||
/>
|
||||
</app-portal-collection-live-shell>
|
||||
} @else {
|
||||
<app-portal-collection-shell
|
||||
[categories]="categories()"
|
||||
[items]="nonGlobalItemsToShow()"
|
||||
[playlistSubtitle]="playlistSubtitle"
|
||||
[playlistTitle]="playlistTitle()"
|
||||
[selectedCategoryId]="selectedCategoryId()"
|
||||
[layout]="nonGlobalLayout"
|
||||
[mode]="nonGlobalMode()"
|
||||
(categoryClicked)="setCategoryId($event)"
|
||||
(removeItem)="onRemoveItem($event)"
|
||||
(headerActionClicked)="clearHistory()"
|
||||
(openItem)="openItem($event)"
|
||||
/>
|
||||
}
|
||||
}
|
||||
@@ -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<any | null>(null);
|
||||
readonly liveStreamUrl = signal('');
|
||||
readonly nonGlobalMode = computed<PortalCollectionMode>(() =>
|
||||
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<LiveChannelSortMode>('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<RecentlyViewedComponent>
|
||||
) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user