feat: add portal inline player component and integrate with existing views

- Introduced `PortalInlinePlayerComponent` with SCSS styles for inline playback.
- Updated `StalkerSeriesViewComponent` to utilize the new inline player for episode playback.
- Enhanced `CategoryContentViewComponent`, `SerialDetailsComponent`, and `VodDetailsRouteComponent` to support inline playback functionality.
- Implemented time update handling and stream URL copy notifications across components.
- Created `ResolvedPortalPlayback` and `PlayerContentInfo` interfaces for structured playback data.
- Updated relevant services and stores to manage playback state and positions.
This commit is contained in:
4gray committed 2026-03-06 23:30:16 +01:00
1 parent 7bb61ab64c
commit e2cdcd5f22
20 files changed
+874 -127

No files matched your search

@@ -0,0 +1,63 @@
import { TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { DataService } from 'services';
import { VideoPlayer } from 'shared-interfaces';
import { SettingsStore } from './settings-store.service';
import { PlayerService } from './player.service';
describe('PlayerService', () => {
let service: PlayerService;
const dialog = {
open: jest.fn(),
};
const dataService = {
sendIpcEvent: jest.fn(),
};
const settingsStore = {
player: jest.fn(() => VideoPlayer.VideoJs),
};
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
PlayerService,
{
provide: MatDialog,
useValue: dialog,
},
{
provide: DataService,
useValue: dataService,
},
{
provide: SettingsStore,
useValue: settingsStore,
},
],
});
service = TestBed.inject(PlayerService);
dialog.open.mockReset();
dataService.sendIpcEvent.mockReset();
settingsStore.player.mockReset();
settingsStore.player.mockReturnValue(VideoPlayer.VideoJs);
});
it('identifies embedded players', () => {
expect(service.isEmbeddedPlayer(VideoPlayer.VideoJs)).toBe(true);
expect(service.isEmbeddedPlayer(VideoPlayer.Html5Player)).toBe(true);
expect(service.isEmbeddedPlayer(VideoPlayer.ArtPlayer)).toBe(true);
expect(service.isEmbeddedPlayer(VideoPlayer.MPV)).toBe(false);
expect(service.isEmbeddedPlayer(VideoPlayer.VLC)).toBe(false);
});
it('uses the dialog fallback for embedded players', () => {
service.openResolvedPlayback({
streamUrl: 'https://example.com/video.mp4',
title: 'Example Video',
});
expect(dialog.open).toHaveBeenCalled();
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
});
});
+44 -1
View File
@@ -4,6 +4,8 @@ import { DataService } from 'services';
import {
OPEN_MPV_PLAYER,
OPEN_VLC_PLAYER,
PlayerContentInfo,
ResolvedPortalPlayback,
VideoPlayer,
} from 'shared-interfaces';
import { ExternalPlayerInfoDialogComponent } from '../shared/components/external-player-info-dialog/external-player-info-dialog.component';
@@ -21,6 +23,16 @@ export class PlayerService {
private dataService = inject(DataService);
private settingsStore = inject(SettingsStore);
isEmbeddedPlayer(
player = this.settingsStore.player() ?? VideoPlayer.VideoJs
): boolean {
return (
player === VideoPlayer.VideoJs ||
player === VideoPlayer.Html5Player ||
player === VideoPlayer.ArtPlayer
);
}
openPlayer(
streamUrl: string,
title: string,
@@ -30,11 +42,42 @@ export class PlayerService {
userAgent?: string,
referer?: string,
origin?: string,
contentInfo?: any,
contentInfo?: PlayerContentInfo,
startTime?: number,
headers?: Record<string, string>
) {
this.openResolvedPlayback(
{
streamUrl,
title,
thumbnail,
startTime,
contentInfo,
headers,
userAgent,
referer,
origin,
},
hideExternalInfoDialog
);
}
openResolvedPlayback(
playback: ResolvedPortalPlayback,
hideExternalInfoDialog = true
) {
const player = this.settingsStore.player() ?? VideoPlayer.VideoJs;
const {
streamUrl,
title,
thumbnail,
userAgent,
referer,
origin,
headers,
contentInfo,
startTime,
} = playback;
if (player === VideoPlayer.MPV) {
if (!hideExternalInfoDialog) {
@@ -0,0 +1,40 @@
@if (hasPlayback()) {
<section class="player-shell" aria-label="Inline player">
<header class="player-shell__header">
<div class="player-shell__eyebrow">Now Playing</div>
<div class="player-shell__title-row">
<h3 class="player-shell__title" [matTooltip]="title()">
{{ title() }}
</h3>
<div class="player-shell__actions">
<button
mat-button
type="button"
[cdkCopyToClipboard]="streamUrl()"
[matTooltip]="streamUrl()"
(click)="onCopied()"
>
<mat-icon>content_copy</mat-icon>
Copy Stream URL
</button>
<button
mat-stroked-button
type="button"
(click)="onClose()"
>
<mat-icon>close</mat-icon>
Close Player
</button>
</div>
</div>
</header>
<div class="player-shell__viewport">
<app-web-player-view
[streamUrl]="streamUrl()"
[startTime]="startTime()"
(timeUpdate)="onTimeUpdate($event)"
/>
</div>
</section>
}
@@ -0,0 +1,106 @@
:host {
display: block;
width: 100%;
}
.player-shell {
display: grid;
gap: 14px;
padding: 18px;
margin-top: 8px;
border-radius: 18px;
border: 1px solid rgba(20, 20, 20, 0.08);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.78), rgba(255, 250, 245, 0.92)),
rgba(255, 250, 245, 0.9);
box-shadow:
0 20px 56px rgba(25, 20, 20, 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.8);
backdrop-filter: blur(18px);
}
.player-shell__header {
display: grid;
gap: 8px;
}
.player-shell__eyebrow {
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
color: rgba(26, 26, 26, 0.58);
}
.player-shell__title-row {
display: flex;
gap: 12px;
align-items: flex-start;
justify-content: space-between;
}
.player-shell__title {
margin: 0;
font-size: clamp(1.05rem, 1vw + 0.9rem, 1.4rem);
font-weight: 700;
line-height: 1.2;
color: #161616;
}
.player-shell__actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: flex-end;
}
.player-shell__viewport {
overflow: hidden;
min-height: 320px;
max-height: min(70vh, 720px);
aspect-ratio: 16 / 9;
border-radius: 14px;
background: #000;
}
.player-shell__viewport > app-web-player-view {
display: block;
width: 100%;
height: 100%;
}
:host-context(.dark-theme) .player-shell {
border-color: rgba(255, 255, 255, 0.12);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.08), transparent),
rgba(12, 12, 12, 0.72);
box-shadow:
0 24px 80px rgba(0, 0, 0, 0.28),
inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
:host-context(.dark-theme) .player-shell__eyebrow {
color: rgba(255, 255, 255, 0.6);
}
:host-context(.dark-theme) .player-shell__title {
color: #fff;
}
@media (max-width: 900px) {
.player-shell {
padding: 14px;
}
.player-shell__title-row {
flex-direction: column;
}
.player-shell__actions {
justify-content: flex-start;
}
.player-shell__viewport {
min-height: 220px;
}
}
@@ -0,0 +1,63 @@
import { ClipboardModule } from '@angular/cdk/clipboard';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import {
PlayerContentInfo,
ResolvedPortalPlayback,
} from 'shared-interfaces';
import { WebPlayerViewComponent } from 'shared-portals';
@Component({
selector: 'app-portal-inline-player',
templateUrl: './portal-inline-player.component.html',
styleUrl: './portal-inline-player.component.scss',
imports: [
ClipboardModule,
MatButtonModule,
MatIconModule,
MatTooltipModule,
WebPlayerViewComponent,
],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'portal-inline-player',
'[attr.data-has-player]': 'hasPlayback()',
},
})
export class PortalInlinePlayerComponent {
readonly playback = input<ResolvedPortalPlayback | null>(null);
readonly title = computed(() => this.playback()?.title ?? '');
readonly streamUrl = computed(() => this.playback()?.streamUrl ?? '');
readonly startTime = computed(() => this.playback()?.startTime ?? 0);
readonly contentInfo = computed<PlayerContentInfo | undefined>(
() => this.playback()?.contentInfo
);
readonly hasPlayback = computed(() => !!this.playback()?.streamUrl);
readonly closed = output<void>();
readonly timeUpdate = output<{
currentTime: number;
duration: number;
}>();
readonly streamUrlCopied = output<void>();
onClose(): void {
this.closed.emit();
}
onTimeUpdate(event: { currentTime: number; duration: number }): void {
this.timeUpdate.emit(event);
}
onCopied(): void {
this.streamUrlCopied.emit();
}
}
@@ -57,6 +57,15 @@
/>
</ng-container>
@if (inlinePlayback(); as playback) {
<app-portal-inline-player
[playback]="playback"
(timeUpdate)="handleInlineTimeUpdate($event)"
(closed)="closeInlinePlayer()"
(streamUrlCopied)="showCopyNotification()"
/>
}
<!-- Seasons & Episodes -->
@if (mappedSeasons()) {
<app-season-container
@@ -7,12 +7,16 @@ import {
output,
signal,
} from '@angular/core';
import { TranslatePipe } from '@ngx-translate/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { FavoritesButtonComponent } from '../favorites-button/favorites-button.component';
import { ContentHeroComponent } from 'components';
import { StalkerStore } from '../stalker.store';
import { SeasonContainerComponent } from '../../xtream-electron/season-container/season-container.component';
import { XtreamSerieEpisode } from 'shared-interfaces';
import {
ResolvedPortalPlayback,
XtreamSerieEpisode,
} from 'shared-interfaces';
import { XtreamStore } from '../../xtream-electron/stores/xtream.store';
import { createLogger } from '../../shared/utils/logger';
import {
@@ -28,6 +32,8 @@ import {
} from '../stalker-series.adapters';
import { StalkerSelectedVodItem, StalkerVodSource } from '../models';
import { normalizeStalkerVodDetailsItem } from '../stalker-vod.utils';
import { PlayerService } from '../../services/player.service';
import { PortalInlinePlayerComponent } from '../../shared/components/portal-inline-player/portal-inline-player.component';
/**
* Component for displaying series/episodes for Stalker portal content.
@@ -43,6 +49,7 @@ import { normalizeStalkerVodDetailsItem } from '../stalker-vod.utils';
imports: [
FavoritesButtonComponent,
ContentHeroComponent,
PortalInlinePlayerComponent,
TranslatePipe,
SeasonContainerComponent,
],
@@ -50,8 +57,13 @@ import { normalizeStalkerVodDetailsItem } from '../stalker-vod.utils';
export class StalkerSeriesViewComponent {
readonly stalkerStore = inject(StalkerStore);
private readonly xtreamStore = inject(XtreamStore);
private readonly playerService = inject(PlayerService);
private readonly snackBar = inject(MatSnackBar);
private readonly translateService = inject(TranslateService);
readonly backClicked = output<void>();
private readonly logger = createLogger('StalkerSeriesView');
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
private lastSaveTime = 0;
/**
* Optional input for VOD items with embedded series array (vclub mode)
@@ -282,13 +294,12 @@ export class StalkerSeriesViewComponent {
? this.xtreamStore.playbackPositions().get(`episode_${trackingId}`)
: undefined;
const startTime = position?.positionSeconds;
this.stalkerStore.createLinkToPlayVod(
void this.startPlayback(
cmd,
item.info.name,
item.info.movie_image,
episodeNum,
trackingId, // unique episode ID for playback tracking
trackingId,
startTime
);
}
@@ -322,18 +333,18 @@ export class StalkerSeriesViewComponent {
.playbackPositions()
.get(`episode_${trackingId}`);
const startTime = position?.positionSeconds;
this.stalkerStore.createLinkToPlayVod(
void this.startPlayback(
cmd,
`${item.info.name} - ${episodeName}`,
item.info.movie_image,
episode.series_number, // episode number for API
trackingId, // unique episode ID for playback tracking
episode.series_number,
trackingId,
startTime
);
}
goBack() {
this.closeInlinePlayer();
this.backClicked.emit();
this.stalkerStore.clearSelectedItem();
}
@@ -345,4 +356,78 @@ export class StalkerSeriesViewComponent {
const parsed = Number(primary);
return Number.isFinite(parsed) ? parsed : 0;
}
closeInlinePlayer(): void {
this.inlinePlayback.set(null);
this.lastSaveTime = 0;
}
handleInlineTimeUpdate(event: {
currentTime: number;
duration: number;
}): void {
const playback = this.inlinePlayback();
if (!playback?.contentInfo) return;
const now = Date.now();
if (now - this.lastSaveTime <= 15000) return;
this.lastSaveTime = now;
void this.xtreamStore.savePosition(playback.contentInfo.playlistId, {
...playback.contentInfo,
positionSeconds: Math.floor(event.currentTime),
durationSeconds: Math.floor(event.duration),
});
}
showCopyNotification(): void {
this.snackBar.open(
this.translateService.instant('PORTALS.STREAM_URL_COPIED'),
null,
{
duration: 2000,
}
);
}
private async startPlayback(
cmd?: string,
title?: string,
thumbnail?: string,
episodeNum?: number,
episodeId?: number,
startTime?: number
): Promise<void> {
try {
const playback = await this.stalkerStore.resolveVodPlayback(
cmd,
title,
thumbnail,
episodeNum,
episodeId,
startTime
);
this.lastSaveTime = 0;
if (this.playerService.isEmbeddedPlayer()) {
this.inlinePlayback.set(playback);
return;
}
this.closeInlinePlayer();
this.playerService.openResolvedPlayback(playback, true);
} catch (error) {
this.logger.error('Failed to start inline series playback', error);
const errorMessage =
error instanceof Error &&
error.message === 'nothing_to_play'
? this.translateService.instant(
'PORTALS.CONTENT_NOT_AVAILABLE'
)
: this.translateService.instant('PORTALS.PLAYBACK_ERROR');
this.snackBar.open(errorMessage, null, {
duration: 3000,
});
}
}
}
@@ -35,6 +35,7 @@ describe('StalkerStore API compatibility smoke', () => {
provide: PlayerService,
useValue: {
openPlayer: jest.fn(),
openResolvedPlayback: jest.fn(),
},
},
{
@@ -138,6 +139,7 @@ describe('StalkerStore API compatibility smoke', () => {
'addToFavorites',
'removeFromFavorites',
'fetchMovieFileId',
'resolveVodPlayback',
'createLinkToPlayVod',
'addToRecentlyViewed',
'removeFromRecentlyViewed',
@@ -5,6 +5,7 @@ import { TranslateService } from '@ngx-translate/core';
import { DataService, PlaylistsService, StalkerSessionService } from 'services';
import {
Playlist,
ResolvedPortalPlayback,
STALKER_REQUEST,
StalkerPortalActions,
} from 'shared-interfaces';
@@ -234,6 +235,94 @@ export function withStalkerPlayer() {
return null;
};
const resolveVodPlaybackInternal = async (
cmd?: string,
title?: string,
thumbnail?: string,
episodeNum?: number,
episodeId?: number,
startTime?: number
): Promise<ResolvedPortalPlayback> => {
const item = storeState.selectedItem();
let cmdToUse = cmd ?? item?.cmd;
if (!cmdToUse) {
throw new Error('nothing_to_play');
}
// For items with has_files and relative path, we need to fetch the file id first
if (
item?.has_files !== undefined &&
cmdToUse &&
!cmdToUse.includes('://') &&
cmdToUse.includes('/media/') &&
!cmdToUse.includes('/media/file_')
) {
const fileId = await fetchMovieFileIdInternal(
normalizeStalkerEntityId(item.id)
);
if (fileId) {
cmdToUse = `/media/file_${fileId}.mpg`;
}
}
const playlist = storeState.currentPlaylist();
if (!playlist) {
throw new Error('nothing_to_play');
}
const streamUrl = await fetchLinkToPlayInternal(
playlist.portalUrl,
playlist.macAddress,
cmdToUse,
episodeNum
);
if (typeof storeState.addToRecentlyViewed === 'function') {
storeState.addToRecentlyViewed({
...item,
id: item?.id,
cmd: cmd,
cover: thumbnail,
title,
});
} else {
addToRecentlyViewedInternal(
item,
cmd,
thumbnail,
title
);
}
const isEpisode =
episodeNum !== undefined || episodeId !== undefined;
const selectedItemId =
normalizeStalkerEntityIdAsNumber(item?.id) ?? 0;
return {
streamUrl,
title: title ?? '',
thumbnail,
startTime,
userAgent: playlist.userAgent,
referer: playlist.referrer,
origin: playlist.origin,
contentInfo: {
playlistId: playlist._id,
// For episodes, use episodeId if provided, otherwise fall back to item.id
contentXtreamId:
isEpisode && episodeId
? episodeId
: selectedItemId,
contentType: isEpisode ? 'episode' : 'vod',
seriesXtreamId: isEpisode
? selectedItemId
: undefined,
},
};
};
const addToRecentlyViewedInternal = (
item: StalkerPlayableItem,
cmd?: string,
@@ -346,6 +435,23 @@ export function withStalkerPlayer() {
): Promise<string | null> {
return fetchMovieFileIdInternal(movieId);
},
async resolveVodPlayback(
cmd?: string,
title?: string,
thumbnail?: string,
episodeNum?: number,
episodeId?: number,
startTime?: number
): Promise<ResolvedPortalPlayback> {
return resolveVodPlaybackInternal(
cmd,
title,
thumbnail,
episodeNum,
episodeId,
startTime
);
},
/**
* Play VOD or episode content
* @param cmd The media command/path
@@ -364,88 +470,19 @@ export function withStalkerPlayer() {
startTime?: number
) {
try {
const item = storeState.selectedItem();
let cmdToUse = cmd ?? item?.cmd;
if (!cmdToUse) {
throw new Error('nothing_to_play');
}
// For items with has_files and relative path, we need to fetch the file id first
if (
item?.has_files !== undefined &&
cmdToUse &&
!cmdToUse.includes('://') &&
cmdToUse.includes('/media/') &&
!cmdToUse.includes('/media/file_')
) {
const fileId = await fetchMovieFileIdInternal(
normalizeStalkerEntityId(item.id)
);
if (fileId) {
cmdToUse = `/media/file_${fileId}.mpg`;
}
}
const playlist = storeState.currentPlaylist();
if (!playlist) {
throw new Error('nothing_to_play');
}
const url = await fetchLinkToPlayInternal(
playlist.portalUrl,
playlist.macAddress,
cmdToUse,
episodeNum
);
if (
typeof storeState.addToRecentlyViewed ===
'function'
) {
storeState.addToRecentlyViewed({
...item,
id: item?.id,
cmd: cmd,
cover: thumbnail,
title,
});
} else {
addToRecentlyViewedInternal(
item,
const playback =
await resolveVodPlaybackInternal(
cmd,
title,
thumbnail,
title
episodeNum,
episodeId,
startTime
);
}
const isEpisode =
episodeNum !== undefined ||
episodeId !== undefined;
const selectedItemId =
normalizeStalkerEntityIdAsNumber(item?.id) ?? 0;
const contentInfo = {
playlistId: playlist._id,
// For episodes, use episodeId if provided, otherwise fall back to item.id
contentXtreamId:
isEpisode && episodeId
? episodeId
: selectedItemId,
contentType: isEpisode ? 'episode' : 'vod',
seriesXtreamId: isEpisode
? selectedItemId
: undefined,
};
playerService.openPlayer(
url,
title,
thumbnail,
true,
false,
playlist?.userAgent,
playlist?.referrer,
playlist?.origin,
contentInfo,
startTime
playerService.openResolvedPlayback(
playback,
true
);
} catch (error) {
logger.error('Failed to get playback URL', error);
@@ -13,11 +13,15 @@
[item]="vodDetailsItem()!"
[isFavorite]="isSelectedVodFavorite()"
[playbackPosition]="selectedVodPlaybackPosition()"
[inlinePlayback]="inlinePlayback()"
(playClicked)="onVodPlay($event)"
(resumeClicked)="onVodResume($event)"
(favoriteToggled)="onVodFavoriteToggled($event)"
(downloadRequested)="onVodDownload($event)"
(backClicked)="onVodBack()"
(inlineTimeUpdated)="handleInlineTimeUpdate($event)"
(inlinePlaybackClosed)="closeInlinePlayer()"
(streamUrlCopied)="showCopyNotification()"
/>
}
}
@@ -8,17 +8,20 @@ import {
signal,
} from '@angular/core';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { MatSnackBar } from '@angular/material/snack-bar';
import { ActivatedRoute, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslatePipe } from '@ngx-translate/core';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { PlaylistsService } from 'services';
import {
createStalkerVodItem,
PlaybackPositionData,
ResolvedPortalPlayback,
StalkerVodDetails,
VodDetailsItem,
} from 'shared-interfaces';
import { DownloadsService } from '../../services/downloads.service';
import { PlayerService } from '../../services/player.service';
import { GridListComponent } from '../../shared/components/grid-list/grid-list.component';
import { PlaylistErrorViewComponent } from '../../shared/components/playlist-error-view/playlist-error-view.component';
import { createLogger } from '../../shared/utils/logger';
@@ -86,6 +89,9 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
private readonly activatedRoute = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly dataSource = inject(XTREAM_DATA_SOURCE);
private readonly playerService = inject(PlayerService);
private readonly snackBar = inject(MatSnackBar);
private readonly translateService = inject(TranslateService);
readonly isStalker = this.activatedRoute.snapshot.data['api'] === 'stalker';
private readonly logger = createLogger('CategoryContentView');
@@ -100,6 +106,8 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
private readonly playlistService = inject(PlaylistsService);
private readonly downloadsService = inject(DownloadsService);
private readonly favoritesRefresh = createRefreshTrigger();
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
private lastInlineSaveTime = 0;
readonly limit = this.store.limit;
readonly pageIndex = this.store.page;
@@ -207,6 +215,11 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
}
);
}
effect(() => {
this.selectedItem()?.id;
this.closeInlinePlayer();
});
}
}
@@ -507,7 +520,7 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
/** Handle play from vod-details component */
onVodPlay(item: VodDetailsItem): void {
if (item.type === 'stalker') {
this.createLinkToPlayVod(
void this.startStalkerVodPlayback(
item.cmd,
item.data.info?.name,
item.data.info?.movie_image
@@ -521,15 +534,10 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
positionSeconds: number;
}): void {
if (event.item.type === 'stalker') {
const stalkerStore = this.store as InstanceType<
typeof StalkerStore
>;
stalkerStore.createLinkToPlayVod(
void this.startStalkerVodPlayback(
event.item.cmd,
event.item.data.info?.name,
event.item.data.info?.movie_image,
undefined, // episodeNum
undefined, // episodeId
event.positionSeconds
);
}
@@ -552,9 +560,53 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
/** Handle back from vod-details component */
onVodBack(): void {
this.closeInlinePlayer();
this.store.setSelectedItem(null);
}
handleInlineTimeUpdate(event: {
currentTime: number;
duration: number;
}): void {
const playback = this.inlinePlayback();
if (!playback?.contentInfo) return;
const now = Date.now();
if (now - this.lastInlineSaveTime <= 15000) return;
this.lastInlineSaveTime = now;
const position: PlaybackPositionData = {
...playback.contentInfo,
positionSeconds: Math.floor(event.currentTime),
durationSeconds: Math.floor(event.duration),
};
void this.dataSource.savePlaybackPosition(
playback.contentInfo.playlistId,
position
);
const key = `vod_${position.contentXtreamId}`;
const updated = new Map(this.stalkerPositions());
updated.set(key, position);
this.stalkerPositions.set(updated);
}
closeInlinePlayer(): void {
this.inlinePlayback.set(null);
this.lastInlineSaveTime = 0;
}
showCopyNotification(): void {
this.snackBar.open(
this.translateService.instant('PORTALS.STREAM_URL_COPIED'),
null,
{
duration: 2000,
}
);
}
/** Handle download from vod-details component */
async onVodDownload(item: VodDetailsItem): Promise<void> {
if (item.type !== 'stalker') return;
@@ -612,6 +664,47 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
}
ngOnDestroy(): void {
this.closeInlinePlayer();
this.unsubscribePositionUpdates?.();
}
private async startStalkerVodPlayback(
cmd?: string,
title?: string,
thumbnail?: string,
startTime?: number
): Promise<void> {
try {
const stalkerStore = this.store as InstanceType<typeof StalkerStore>;
const playback = await stalkerStore.resolveVodPlayback(
cmd,
title,
thumbnail,
undefined,
undefined,
startTime
);
this.lastInlineSaveTime = 0;
if (this.playerService.isEmbeddedPlayer()) {
this.inlinePlayback.set(playback);
return;
}
this.closeInlinePlayer();
this.playerService.openResolvedPlayback(playback, true);
} catch (error) {
this.logger.error('Failed to start inline VOD playback', error);
const errorMessage =
error instanceof Error &&
error.message === 'nothing_to_play'
? this.translateService.instant(
'PORTALS.CONTENT_NOT_AVAILABLE'
)
: this.translateService.instant('PORTALS.PLAYBACK_ERROR');
this.snackBar.open(errorMessage, null, {
duration: 3000,
});
}
}
}
@@ -6,13 +6,14 @@ import { MatIcon } from '@angular/material/icon';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTooltip } from '@angular/material/tooltip';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { PlayerContentInfo } from 'shared-interfaces';
import { WebPlayerViewComponent } from 'shared-portals';
import { XtreamStore } from '../stores/xtream.store';
export interface PlayerDialogData {
streamUrl: string;
title: string;
contentInfo?: any;
contentInfo?: PlayerContentInfo;
startTime?: number;
}
@@ -66,6 +66,15 @@
}
</ng-container>
@if (inlinePlayback(); as playback) {
<app-portal-inline-player
[playback]="playback"
(timeUpdate)="handleInlineTimeUpdate($event)"
(closed)="closeInlinePlayer()"
(streamUrlCopied)="showCopyNotification()"
/>
}
<!-- Seasons & Episodes -->
<app-season-container
[seasons]="item.episodes"
@@ -82,4 +91,3 @@
} @else {
<app-content-hero [isLoading]="true" (backClicked)="goBack()" />
}
@@ -1,10 +1,17 @@
import { Location, SlicePipe } from '@angular/common';
import { Component, inject, OnDestroy, OnInit } from '@angular/core';
import { Component, inject, OnDestroy, OnInit, signal } from '@angular/core';
import { MatIcon } from '@angular/material/icon';
import { MatSnackBar } from '@angular/material/snack-bar';
import { ActivatedRoute } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { ContentHeroComponent } from 'components';
import { XtreamSerieEpisode } from 'shared-interfaces';
import {
PlayerContentInfo,
ResolvedPortalPlayback,
XtreamSerieEpisode,
} from 'shared-interfaces';
import { PlayerService } from '../../services/player.service';
import { PortalInlinePlayerComponent } from '../../shared/components/portal-inline-player/portal-inline-player.component';
import { SeasonContainerComponent } from '../season-container/season-container.component';
import { XtreamStore } from '../stores/xtream.store';
@@ -15,6 +22,7 @@ import { XtreamStore } from '../stores/xtream.store';
imports: [
ContentHeroComponent,
MatIcon,
PortalInlinePlayerComponent,
SeasonContainerComponent,
SlicePipe,
TranslatePipe,
@@ -24,12 +32,17 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
private readonly location = inject(Location);
private readonly route = inject(ActivatedRoute);
private readonly xtreamStore = inject(XtreamStore);
private readonly playerService = inject(PlayerService);
private readonly snackBar = inject(MatSnackBar);
private readonly translateService = inject(TranslateService);
readonly selectedItem = this.xtreamStore.selectedItem;
readonly selectedContentType = this.xtreamStore.selectedContentType;
readonly isFavorite = this.xtreamStore.isFavorite;
readonly isLoadingDetails = this.xtreamStore.isLoadingDetails;
readonly detailsError = this.xtreamStore.detailsError;
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
private lastSaveTime = 0;
ngOnInit(): void {
const { categoryId, serialId } = this.route.snapshot.params;
@@ -48,6 +61,7 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
}
ngOnDestroy(): void {
this.closeInlinePlayer();
this.xtreamStore.setSelectedItem(null);
}
@@ -55,7 +69,7 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
this.addToRecentlyViewed(this.route.snapshot.params.serialId);
const streamUrl = this.xtreamStore.constructEpisodeStreamUrl(episode);
const contentInfo = {
const contentInfo: PlayerContentInfo = {
playlistId: this.xtreamStore.currentPlaylist().id,
contentXtreamId: Number(episode.id),
contentType: 'episode',
@@ -68,13 +82,15 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
.playbackPositions()
.get(`episode_${episode.id}`);
this.xtreamStore.openPlayer(
const playback: ResolvedPortalPlayback = {
streamUrl,
episode.title,
this.selectedItem().info.cover,
position?.positionSeconds,
contentInfo
);
title: episode.title,
thumbnail: this.selectedItem().info.cover,
startTime: position?.positionSeconds,
contentInfo,
};
this.startPlayback(playback);
}
private addToRecentlyViewed(xtreamId: number) {
@@ -92,6 +108,51 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
}
goBack() {
this.closeInlinePlayer();
this.location.back();
}
closeInlinePlayer(): void {
this.inlinePlayback.set(null);
this.lastSaveTime = 0;
}
handleInlineTimeUpdate(event: {
currentTime: number;
duration: number;
}): void {
const playback = this.inlinePlayback();
if (!playback?.contentInfo) return;
const now = Date.now();
if (now - this.lastSaveTime <= 15000) return;
this.lastSaveTime = now;
void this.xtreamStore.savePosition(playback.contentInfo.playlistId, {
...playback.contentInfo,
positionSeconds: Math.floor(event.currentTime),
durationSeconds: Math.floor(event.duration),
});
}
showCopyNotification(): void {
this.snackBar.open(
this.translateService.instant('PORTALS.STREAM_URL_COPIED'),
null,
{
duration: 2000,
}
);
}
private startPlayback(playback: ResolvedPortalPlayback): void {
this.lastSaveTime = 0;
if (this.playerService.isEmbeddedPlayer()) {
this.inlinePlayback.set(playback);
return;
}
this.closeInlinePlayer();
this.playerService.openResolvedPlayback(playback, true);
}
}
@@ -116,6 +116,15 @@
}
</ng-container>
@if (inlinePlayback(); as playback) {
<app-portal-inline-player
[playback]="playback"
(timeUpdate)="handleInlineTimeUpdate($event)"
(closed)="closeInlinePlayer()"
(streamUrlCopied)="showCopyNotification()"
/>
}
<!-- YouTube Trailer -->
@if (item.info?.youtube_trailer) {
<div class="youtube-trailer">
@@ -143,4 +152,3 @@
} @else {
<app-content-hero [isLoading]="true" (backClicked)="goBack()" />
}
@@ -10,12 +10,19 @@ import {
} from '@angular/core';
import { MatIcon } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBar } from '@angular/material/snack-bar';
import { ActivatedRoute } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { ContentHeroComponent } from 'components';
import { XtreamVodDetails } from 'shared-interfaces';
import {
PlayerContentInfo,
ResolvedPortalPlayback,
XtreamVodDetails,
} from 'shared-interfaces';
import { DownloadsService } from '../../services/downloads.service';
import { PlayerService } from '../../services/player.service';
import { SettingsStore } from '../../services/settings-store.service';
import { PortalInlinePlayerComponent } from '../../shared/components/portal-inline-player/portal-inline-player.component';
import { XtreamStore } from '../stores/xtream.store';
import { SafePipe } from '@iptvnator/pipes';
import { createLogger } from '../../shared/utils/logger';
@@ -40,6 +47,7 @@ import { createLogger } from '../../shared/utils/logger';
SlicePipe,
TranslateModule,
MatProgressSpinnerModule,
PortalInlinePlayerComponent,
],
})
export class VodDetailsRouteComponent implements OnInit, OnDestroy {
@@ -48,8 +56,12 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
private route = inject(ActivatedRoute);
private readonly xtreamStore = inject(XtreamStore);
private readonly downloadsService = inject(DownloadsService);
private readonly playerService = inject(PlayerService);
private readonly snackBar = inject(MatSnackBar);
private readonly translateService = inject(TranslateService);
private readonly logger = createLogger('VodDetailsRoute');
private readonly detailsInitDone = signal(false);
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
readonly theme = this.settingsStore.theme;
readonly isElectron = this.downloadsService.isAvailable;
@@ -58,6 +70,7 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
readonly selectedItem = this.xtreamStore.selectedItem;
readonly isLoadingDetails = this.xtreamStore.isLoadingDetails;
readonly detailsError = this.xtreamStore.detailsError;
private lastSaveTime = 0;
readonly hasPlaybackPosition = computed(() => {
const vodId = this.route.snapshot.params.vodId;
@@ -111,6 +124,7 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
}
ngOnDestroy() {
this.inlinePlayback.set(null);
this.xtreamStore.setSelectedItem(null);
}
@@ -128,19 +142,19 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
this.logger.debug('playVod resolved ID', { id, vodItem });
const contentInfo = {
const contentInfo: PlayerContentInfo = {
playlistId: this.xtreamStore.currentPlaylist().id,
contentXtreamId: id,
contentType: 'vod',
};
this.xtreamStore.openPlayer(
const playback: ResolvedPortalPlayback = {
streamUrl,
vodItem.info.name ?? vodItem?.movie_data?.name,
vodItem.info.movie_image,
undefined,
contentInfo
);
title: vodItem.info.name ?? vodItem?.movie_data?.name,
thumbnail: vodItem.info.movie_image,
contentInfo,
};
this.startPlayback(playback);
}
resumeVod(vodItem: XtreamVodDetails) {
@@ -152,19 +166,20 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
const streamUrl = this.xtreamStore.constructVodStreamUrl(vodItem);
// Use vodId from route (same as above)
const contentInfo = {
const contentInfo: PlayerContentInfo = {
playlistId: this.xtreamStore.currentPlaylist().id,
contentXtreamId: vodId,
contentType: 'vod',
};
this.xtreamStore.openPlayer(
const playback: ResolvedPortalPlayback = {
streamUrl,
vodItem.info.name ?? vodItem?.movie_data?.name,
vodItem.info.movie_image,
position?.positionSeconds,
contentInfo
);
title: vodItem.info.name ?? vodItem?.movie_data?.name,
thumbnail: vodItem.info.movie_image,
startTime: position?.positionSeconds,
contentInfo,
};
this.startPlayback(playback);
}
formatPosition(): string {
@@ -200,9 +215,54 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
}
goBack() {
this.closeInlinePlayer();
this.location.back();
}
closeInlinePlayer(): void {
this.inlinePlayback.set(null);
this.lastSaveTime = 0;
}
handleInlineTimeUpdate(event: {
currentTime: number;
duration: number;
}): void {
const playback = this.inlinePlayback();
if (!playback?.contentInfo) return;
const now = Date.now();
if (now - this.lastSaveTime <= 15000) return;
this.lastSaveTime = now;
void this.xtreamStore.savePosition(playback.contentInfo.playlistId, {
...playback.contentInfo,
positionSeconds: Math.floor(event.currentTime),
durationSeconds: Math.floor(event.duration),
});
}
showCopyNotification(): void {
this.snackBar.open(
this.translateService.instant('PORTALS.STREAM_URL_COPIED'),
null,
{
duration: 2000,
}
);
}
private startPlayback(playback: ResolvedPortalPlayback): void {
this.lastSaveTime = 0;
if (this.playerService.isEmbeddedPlayer()) {
this.inlinePlayback.set(playback);
return;
}
this.closeInlinePlayer();
this.playerService.openResolvedPlayback(playback, true);
}
async downloadVod(vodItem: XtreamVodDetails) {
const streamUrl = this.xtreamStore.constructVodStreamUrl(vodItem);
const routeVodId = this.route.snapshot.params.vodId;
@@ -107,6 +107,15 @@
}
</ng-container>
@if (inlinePlayback(); as playback) {
<app-portal-inline-player
[playback]="playback"
(timeUpdate)="onInlineTimeUpdate($event)"
(closed)="closeInlinePlayback()"
(streamUrlCopied)="onStreamUrlCopied()"
/>
}
<!-- YouTube Trailer (Xtream only) -->
@if (meta.youtubeTrailer) {
<div class="youtube-trailer">
@@ -11,6 +11,7 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { TranslatePipe } from '@ngx-translate/core';
import { ContentHeroComponent } from 'components';
import {
ResolvedPortalPlayback,
VodDetailsItem,
normalizeVodDetails,
getVodNumericId,
@@ -18,6 +19,7 @@ import {
import { DownloadsService } from '../../services/downloads.service';
import { SafePipe } from '@iptvnator/pipes';
import { StalkerStore } from '../../stalker/stalker.store';
import { PortalInlinePlayerComponent } from '../../shared/components/portal-inline-player/portal-inline-player.component';
/**
* Unified VOD details component for both Xtream and Stalker portals.
@@ -46,6 +48,7 @@ import { StalkerStore } from '../../stalker/stalker.store';
ContentHeroComponent,
MatIcon,
MatProgressSpinnerModule,
PortalInlinePlayerComponent,
SafePipe,
TranslatePipe,
],
@@ -62,6 +65,9 @@ export class VodDetailsComponent implements OnDestroy {
/** Playback position in seconds for resume feature (managed by parent) */
readonly playbackPosition = input<number | null>(null);
/** Inline playback payload for embedded players (managed by parent) */
readonly inlinePlayback = input<ResolvedPortalPlayback | null>(null);
// ============ Outputs ============
/** Emitted when play button is clicked */
@@ -79,6 +85,18 @@ export class VodDetailsComponent implements OnDestroy {
/** Emitted when download is requested (parent handles URL construction) */
readonly downloadRequested = output<VodDetailsItem>();
/** Emitted when inline playback position changes */
readonly inlineTimeUpdated = output<{
currentTime: number;
duration: number;
}>();
/** Emitted when the inline player should be closed */
readonly inlinePlaybackClosed = output<void>();
/** Emitted when the stream url is copied */
readonly streamUrlCopied = output<void>();
// ============ Services ============
private readonly downloadsService = inject(DownloadsService);
@@ -181,6 +199,21 @@ export class VodDetailsComponent implements OnDestroy {
this.downloadRequested.emit(this.item());
}
onInlineTimeUpdate(event: {
currentTime: number;
duration: number;
}): void {
this.inlineTimeUpdated.emit(event);
}
closeInlinePlayback(): void {
this.inlinePlaybackClosed.emit();
}
onStreamUrlCopied(): void {
this.streamUrlCopied.emit();
}
/** Play from local downloaded file */
async playFromLocal(): Promise<void> {
const item = this.item();
+1
View File
@@ -10,6 +10,7 @@ export * from './lib/ipc-commands';
export * from './lib/language.enum';
export * from './lib/parsed-playlist.interface';
export * from './lib/playback-position.interface';
export * from './lib/portal-playback.interface';
export * from './lib/playlist-meta.type';
export * from './lib/playlist.interface';
export * from './lib/settings.interface';
@@ -0,0 +1,21 @@
import { PlaybackPositionData } from './playback-position.interface';
export interface PlayerContentInfo
extends Omit<
PlaybackPositionData,
'positionSeconds' | 'durationSeconds' | 'updatedAt'
> {
playlistId: string;
}
export interface ResolvedPortalPlayback {
streamUrl: string;
title: string;
thumbnail?: string | null;
startTime?: number;
contentInfo?: PlayerContentInfo;
headers?: Record<string, string>;
userAgent?: string;
referer?: string;
origin?: string;
}