From e2cdcd5f223af779ba95135ce151dde2252cbed4 Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 6 Mar 2026 23:30:16 +0100 Subject: [PATCH] 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. --- .../src/app/services/player.service.spec.ts | 63 ++++++ apps/web/src/app/services/player.service.ts | 45 +++- .../portal-inline-player.component.html | 40 ++++ .../portal-inline-player.component.scss | 106 ++++++++++ .../portal-inline-player.component.ts | 63 ++++++ .../stalker-series-view.component.html | 9 + .../stalker-series-view.component.ts | 103 +++++++++- .../app/stalker/stalker.store.compat.spec.ts | 2 + .../features/with-stalker-player.feature.ts | 193 +++++++++++------- .../category-content-view.component.html | 4 + .../category-content-view.component.ts | 109 +++++++++- .../player-dialog/player-dialog.component.ts | 3 +- .../serial-details.component.html | 10 +- .../serial-details.component.ts | 81 +++++++- .../vod-details-route.component.html | 10 +- .../vod-details-route.component.ts | 96 +++++++-- .../vod-details/vod-details.component.html | 9 + .../vod-details/vod-details.component.ts | 33 +++ libs/shared/interfaces/src/index.ts | 1 + .../src/lib/portal-playback.interface.ts | 21 ++ 20 files changed, 874 insertions(+), 127 deletions(-) create mode 100644 apps/web/src/app/services/player.service.spec.ts create mode 100644 apps/web/src/app/shared/components/portal-inline-player/portal-inline-player.component.html create mode 100644 apps/web/src/app/shared/components/portal-inline-player/portal-inline-player.component.scss create mode 100644 apps/web/src/app/shared/components/portal-inline-player/portal-inline-player.component.ts create mode 100644 libs/shared/interfaces/src/lib/portal-playback.interface.ts diff --git a/apps/web/src/app/services/player.service.spec.ts b/apps/web/src/app/services/player.service.spec.ts new file mode 100644 index 000000000..44b876575 --- /dev/null +++ b/apps/web/src/app/services/player.service.spec.ts @@ -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(); + }); +}); diff --git a/apps/web/src/app/services/player.service.ts b/apps/web/src/app/services/player.service.ts index 5291abd25..12eb61aba 100644 --- a/apps/web/src/app/services/player.service.ts +++ b/apps/web/src/app/services/player.service.ts @@ -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 + ) { + 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) { diff --git a/apps/web/src/app/shared/components/portal-inline-player/portal-inline-player.component.html b/apps/web/src/app/shared/components/portal-inline-player/portal-inline-player.component.html new file mode 100644 index 000000000..07a279c2a --- /dev/null +++ b/apps/web/src/app/shared/components/portal-inline-player/portal-inline-player.component.html @@ -0,0 +1,40 @@ +@if (hasPlayback()) { +
+
+
Now Playing
+
+

+ {{ title() }} +

+
+ + +
+
+
+ +
+ +
+
+} diff --git a/apps/web/src/app/shared/components/portal-inline-player/portal-inline-player.component.scss b/apps/web/src/app/shared/components/portal-inline-player/portal-inline-player.component.scss new file mode 100644 index 000000000..c6280890b --- /dev/null +++ b/apps/web/src/app/shared/components/portal-inline-player/portal-inline-player.component.scss @@ -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; + } +} diff --git a/apps/web/src/app/shared/components/portal-inline-player/portal-inline-player.component.ts b/apps/web/src/app/shared/components/portal-inline-player/portal-inline-player.component.ts new file mode 100644 index 000000000..2c91bca87 --- /dev/null +++ b/apps/web/src/app/shared/components/portal-inline-player/portal-inline-player.component.ts @@ -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(null); + readonly title = computed(() => this.playback()?.title ?? ''); + readonly streamUrl = computed(() => this.playback()?.streamUrl ?? ''); + readonly startTime = computed(() => this.playback()?.startTime ?? 0); + readonly contentInfo = computed( + () => this.playback()?.contentInfo + ); + readonly hasPlayback = computed(() => !!this.playback()?.streamUrl); + + readonly closed = output(); + readonly timeUpdate = output<{ + currentTime: number; + duration: number; + }>(); + readonly streamUrlCopied = output(); + + onClose(): void { + this.closed.emit(); + } + + onTimeUpdate(event: { currentTime: number; duration: number }): void { + this.timeUpdate.emit(event); + } + + onCopied(): void { + this.streamUrlCopied.emit(); + } +} diff --git a/apps/web/src/app/stalker/stalker-series-view/stalker-series-view.component.html b/apps/web/src/app/stalker/stalker-series-view/stalker-series-view.component.html index 1741709cf..f7983904c 100644 --- a/apps/web/src/app/stalker/stalker-series-view/stalker-series-view.component.html +++ b/apps/web/src/app/stalker/stalker-series-view/stalker-series-view.component.html @@ -57,6 +57,15 @@ /> + @if (inlinePlayback(); as playback) { + + } + @if (mappedSeasons()) { (); private readonly logger = createLogger('StalkerSeriesView'); + readonly inlinePlayback = signal(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 { + 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, + }); + } + } } diff --git a/apps/web/src/app/stalker/stalker.store.compat.spec.ts b/apps/web/src/app/stalker/stalker.store.compat.spec.ts index 7f1ad1d2f..6d251025e 100644 --- a/apps/web/src/app/stalker/stalker.store.compat.spec.ts +++ b/apps/web/src/app/stalker/stalker.store.compat.spec.ts @@ -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', diff --git a/apps/web/src/app/stalker/stores/features/with-stalker-player.feature.ts b/apps/web/src/app/stalker/stores/features/with-stalker-player.feature.ts index f6051f9f5..c2848d1d2 100644 --- a/apps/web/src/app/stalker/stores/features/with-stalker-player.feature.ts +++ b/apps/web/src/app/stalker/stores/features/with-stalker-player.feature.ts @@ -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 => { + 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 { return fetchMovieFileIdInternal(movieId); }, + async resolveVodPlayback( + cmd?: string, + title?: string, + thumbnail?: string, + episodeNum?: number, + episodeId?: number, + startTime?: number + ): Promise { + 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); diff --git a/apps/web/src/app/xtream-electron/category-content-view/category-content-view.component.html b/apps/web/src/app/xtream-electron/category-content-view/category-content-view.component.html index 2d93781c1..991d92f42 100644 --- a/apps/web/src/app/xtream-electron/category-content-view/category-content-view.component.html +++ b/apps/web/src/app/xtream-electron/category-content-view/category-content-view.component.html @@ -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()" /> } } diff --git a/apps/web/src/app/xtream-electron/category-content-view/category-content-view.component.ts b/apps/web/src/app/xtream-electron/category-content-view/category-content-view.component.ts index 3d827d189..4507fcc98 100644 --- a/apps/web/src/app/xtream-electron/category-content-view/category-content-view.component.ts +++ b/apps/web/src/app/xtream-electron/category-content-view/category-content-view.component.ts @@ -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(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 { 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 { + try { + const stalkerStore = this.store as InstanceType; + 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, + }); + } + } } diff --git a/apps/web/src/app/xtream-electron/player-dialog/player-dialog.component.ts b/apps/web/src/app/xtream-electron/player-dialog/player-dialog.component.ts index d7bd788e8..2efc1fd40 100644 --- a/apps/web/src/app/xtream-electron/player-dialog/player-dialog.component.ts +++ b/apps/web/src/app/xtream-electron/player-dialog/player-dialog.component.ts @@ -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; } diff --git a/apps/web/src/app/xtream-electron/serial-details/serial-details.component.html b/apps/web/src/app/xtream-electron/serial-details/serial-details.component.html index 3ab6e4de6..1112ed2e0 100644 --- a/apps/web/src/app/xtream-electron/serial-details/serial-details.component.html +++ b/apps/web/src/app/xtream-electron/serial-details/serial-details.component.html @@ -66,6 +66,15 @@ } + @if (inlinePlayback(); as playback) { + + } + } - diff --git a/apps/web/src/app/xtream-electron/serial-details/serial-details.component.ts b/apps/web/src/app/xtream-electron/serial-details/serial-details.component.ts index c38ca3ef9..024b1eed0 100644 --- a/apps/web/src/app/xtream-electron/serial-details/serial-details.component.ts +++ b/apps/web/src/app/xtream-electron/serial-details/serial-details.component.ts @@ -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(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); + } } diff --git a/apps/web/src/app/xtream-electron/vod-details/vod-details-route.component.html b/apps/web/src/app/xtream-electron/vod-details/vod-details-route.component.html index 385a3e29a..296630586 100644 --- a/apps/web/src/app/xtream-electron/vod-details/vod-details-route.component.html +++ b/apps/web/src/app/xtream-electron/vod-details/vod-details-route.component.html @@ -116,6 +116,15 @@ } + @if (inlinePlayback(); as playback) { + + } + @if (item.info?.youtube_trailer) {
@@ -143,4 +152,3 @@ } @else { } - diff --git a/apps/web/src/app/xtream-electron/vod-details/vod-details-route.component.ts b/apps/web/src/app/xtream-electron/vod-details/vod-details-route.component.ts index 550f6e43f..5421b71a4 100644 --- a/apps/web/src/app/xtream-electron/vod-details/vod-details-route.component.ts +++ b/apps/web/src/app/xtream-electron/vod-details/vod-details-route.component.ts @@ -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(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; diff --git a/apps/web/src/app/xtream-electron/vod-details/vod-details.component.html b/apps/web/src/app/xtream-electron/vod-details/vod-details.component.html index b77ad7283..e2923dd40 100644 --- a/apps/web/src/app/xtream-electron/vod-details/vod-details.component.html +++ b/apps/web/src/app/xtream-electron/vod-details/vod-details.component.html @@ -107,6 +107,15 @@ } + @if (inlinePlayback(); as playback) { + + } + @if (meta.youtubeTrailer) {
diff --git a/apps/web/src/app/xtream-electron/vod-details/vod-details.component.ts b/apps/web/src/app/xtream-electron/vod-details/vod-details.component.ts index 75589c0f5..a00423aef 100644 --- a/apps/web/src/app/xtream-electron/vod-details/vod-details.component.ts +++ b/apps/web/src/app/xtream-electron/vod-details/vod-details.component.ts @@ -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(null); + /** Inline playback payload for embedded players (managed by parent) */ + readonly inlinePlayback = input(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(); + /** Emitted when inline playback position changes */ + readonly inlineTimeUpdated = output<{ + currentTime: number; + duration: number; + }>(); + + /** Emitted when the inline player should be closed */ + readonly inlinePlaybackClosed = output(); + + /** Emitted when the stream url is copied */ + readonly streamUrlCopied = output(); + // ============ 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 { const item = this.item(); diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index b586a0b55..8e6fbb498 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -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'; diff --git a/libs/shared/interfaces/src/lib/portal-playback.interface.ts b/libs/shared/interfaces/src/lib/portal-playback.interface.ts new file mode 100644 index 000000000..1a4d3c7f1 --- /dev/null +++ b/libs/shared/interfaces/src/lib/portal-playback.interface.ts @@ -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; + userAgent?: string; + referer?: string; + origin?: string; +}