mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 01:16:15 -08:00
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:
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();
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
+40
@@ -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>
|
||||
}
|
||||
+106
@@ -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;
|
||||
}
|
||||
}
|
||||
+63
@@ -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);
|
||||
|
||||
+4
@@ -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()"
|
||||
/>
|
||||
}
|
||||
}
|
||||
|
||||
+101
-8
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user