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:
4gray committed 2026-03-07 12:07:13 +01:00
1 parent e2cdcd5f22
commit d847524d0d
26 files changed
+1845 -272

No files matched your search

@@ -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>
@@ -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;
}
}
@@ -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();
}
}
@@ -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)"
/>
}
}
@@ -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 });
}
}
@@ -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>
@@ -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;
}
}
@@ -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;
},
{}
);
}
@@ -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>
@@ -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;
@@ -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);
}
}
@@ -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 /> -->
@@ -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 {
@@ -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());
});
}