diff --git a/apps/electron-backend-e2e/src/favorites.e2e.ts b/apps/electron-backend-e2e/src/favorites.e2e.ts index 74a887a61..ddd37a443 100644 --- a/apps/electron-backend-e2e/src/favorites.e2e.ts +++ b/apps/electron-backend-e2e/src/favorites.e2e.ts @@ -541,6 +541,8 @@ test.describe('Electron Favorites', () => { title: movieTitle, playlistTitle: portalTitle, }); + await playCurrentDetail(app.mainWindow); + await expectInlinePlayerWithoutDialog(app.mainWindow); await goBackFromDetail(app.mainWindow); await expectPathname(app.mainWindow, /\/workspace\/global-favorites$/); @@ -556,6 +558,8 @@ test.describe('Electron Favorites', () => { title: seriesTitle, playlistTitle: portalTitle, }); + await playFirstSeriesEpisode(app.mainWindow); + await expectInlinePlayerWithoutDialog(app.mainWindow); await goBackFromDetail(app.mainWindow); await expectPathname(app.mainWindow, /\/workspace\/global-favorites$/); @@ -618,6 +622,47 @@ async function expectInlineCollectionDetail( ).toBeVisible({ timeout: 20000 }); } +async function expectInlinePlayerWithoutDialog(page: Page): Promise { + await expect( + page.locator('app-portal-inline-player app-web-player-view').first() + ).toBeVisible({ timeout: 20000 }); + await expect( + page.locator('mat-dialog-container app-web-player-view') + ).toHaveCount(0); +} + +async function playCurrentDetail(page: Page): Promise { + const playButton = page.locator('button.play-btn').first(); + + await expect(playButton).toBeVisible({ timeout: 20000 }); + await playButton.click(); +} + +async function playFirstSeriesEpisode(page: Page): Promise { + const seasonCard = page.locator('.season-card').first(); + const episodeCard = page + .locator('.episode-card, .episode-list-item') + .first(); + + await expect + .poll( + async () => + (await seasonCard.count()) + (await episodeCard.count()), + { timeout: 20000 } + ) + .toBeGreaterThan(0); + + if ((await seasonCard.count()) > 0) { + await seasonCard.scrollIntoViewIfNeeded(); + await expect(seasonCard).toBeVisible({ timeout: 20000 }); + await seasonCard.click(); + } + + await episodeCard.scrollIntoViewIfNeeded(); + await expect(episodeCard).toBeVisible({ timeout: 20000 }); + await episodeCard.click(); +} + async function toggleFavoriteForChannel( page: Page, title: string diff --git a/apps/electron-backend-e2e/src/recent.e2e.ts b/apps/electron-backend-e2e/src/recent.e2e.ts index c58ca5894..37a1329d0 100644 --- a/apps/electron-backend-e2e/src/recent.e2e.ts +++ b/apps/electron-backend-e2e/src/recent.e2e.ts @@ -591,6 +591,8 @@ test.describe('Electron Recently Viewed', () => { title: movieTitle, playlistTitle: portalTitle, }); + await playCurrentDetail(app.mainWindow); + await expectInlinePlayerWithoutDialog(app.mainWindow); await goBackFromDetail(app.mainWindow); await expectPathname(app.mainWindow, /\/workspace\/global-recent$/); @@ -606,6 +608,8 @@ test.describe('Electron Recently Viewed', () => { title: seriesTitle, playlistTitle: portalTitle, }); + await playFirstSeriesEpisode(app.mainWindow); + await expectInlinePlayerWithoutDialog(app.mainWindow); await goBackFromDetail(app.mainWindow); await expectPathname(app.mainWindow, /\/workspace\/global-recent$/); @@ -677,6 +681,15 @@ async function expectInlineCollectionDetail( ).toBeVisible({ timeout: 20000 }); } +async function expectInlinePlayerWithoutDialog(page: Page): Promise { + await expect( + page.locator('app-portal-inline-player app-web-player-view').first() + ).toBeVisible({ timeout: 20000 }); + await expect( + page.locator('mat-dialog-container app-web-player-view') + ).toHaveCount(0); +} + async function playCurrentDetail(page: Page): Promise { const playButton = page.locator('button.play-btn').first(); diff --git a/apps/electron-backend-e2e/src/search.e2e.ts b/apps/electron-backend-e2e/src/search.e2e.ts index 14ebc2914..08a6b2ee4 100644 --- a/apps/electron-backend-e2e/src/search.e2e.ts +++ b/apps/electron-backend-e2e/src/search.e2e.ts @@ -905,6 +905,16 @@ test.describe('Electron Workspace Search', () => { await expect( contentCardByTitle(app.mainWindow, sample.targetTitle).first() ).toBeVisible({ timeout: 20000 }); + await contentCardByTitle(app.mainWindow, sample.targetTitle) + .first() + .click(); + await expect( + app.mainWindow.locator('app-content-hero') + ).toContainText(flexibleTextPattern(sample.targetTitle), { + timeout: 20000, + }); + await playCurrentDetail(app.mainWindow); + await expectInlinePlayerWithoutDialog(app.mainWindow); } finally { await closeElectronApp(app); } @@ -1535,6 +1545,22 @@ async function goBackFromDetail(page: Page): Promise { await backButton.click(); } +async function playCurrentDetail(page: Page): Promise { + const playButton = page.locator('button.play-btn').first(); + + await expect(playButton).toBeVisible({ timeout: 20000 }); + await playButton.click(); +} + +async function expectInlinePlayerWithoutDialog(page: Page): Promise { + await expect( + page.locator('app-portal-inline-player app-web-player-view').first() + ).toBeVisible({ timeout: 20000 }); + await expect( + page.locator('mat-dialog-container app-web-player-view') + ).toHaveCount(0); +} + async function pickPreferredCategory(categories: Locator): Promise { const count = await categories.count(); let fallback = categories.first(); diff --git a/apps/web/jest.config.ts b/apps/web/jest.config.ts index af4800355..5cfd40cac 100644 --- a/apps/web/jest.config.ts +++ b/apps/web/jest.config.ts @@ -24,8 +24,6 @@ export default { snapshotSerializers: angularEsmPreset.snapshotSerializers, moduleNameMapper: { ...(angularEsmPreset.moduleNameMapper ?? {}), - '^@iptvnator/portal/xtream/feature$': - '/src/test-stubs/xtream-feature.stub.ts', tslib: 'tslib/tslib.es6.js', '^iptv-playlist-parser$': '/src/test-stubs/iptv-playlist-parser.mjs', diff --git a/apps/web/src/app/services/player.service.spec.ts b/apps/web/src/app/services/player.service.spec.ts index d731743a6..8fa7de9db 100644 --- a/apps/web/src/app/services/player.service.spec.ts +++ b/apps/web/src/app/services/player.service.spec.ts @@ -52,25 +52,14 @@ describe('PlayerService', () => { expect(service.isEmbeddedPlayer(VideoPlayer.VLC)).toBe(false); }); - it('uses the dialog fallback for embedded players', async () => { - await service.openResolvedPlayback({ + it('does not open a dialog or IPC for embedded players', async () => { + const result = await service.openResolvedPlayback({ streamUrl: 'https://example.com/video.mp4', title: 'Example Video', }); - expect(dialog.open).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - data: expect.objectContaining({ - streamUrl: 'https://example.com/video.mp4', - title: 'Example Video', - playback: expect.objectContaining({ - streamUrl: 'https://example.com/video.mp4', - title: 'Example Video', - }), - }), - }) - ); + expect(result).toBeUndefined(); + expect(dialog.open).not.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 71734fb0b..25cd161db 100644 --- a/apps/web/src/app/services/player.service.ts +++ b/apps/web/src/app/services/player.service.ts @@ -67,7 +67,6 @@ export class PlayerService { hideExternalInfoDialog = true ): Promise { const player = this.settingsStore.player() ?? VideoPlayer.VideoJs; - const { streamUrl, title, contentInfo, startTime } = playback; if (player === VideoPlayer.MPV) { if (!hideExternalInfoDialog) { @@ -81,22 +80,7 @@ export class PlayerService { return await this.openExternalPlayback(playback, 'vlc'); } - return import('@iptvnator/portal/xtream/feature').then( - ({ PlayerDialogComponent }) => { - this.dialog.open(PlayerDialogComponent, { - data: { - streamUrl, - title, - contentInfo, - startTime, - playback, - }, - width: '80%', - maxWidth: '1200px', - maxHeight: '90vh', - }); - } - ); + return; } async openExternalPlayback( diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 296463bcc..bc3e4e2a3 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -575,6 +575,10 @@ "RESUME": "استئناف", "RESTART": "البدء من جديد", "CONTINUE_WATCHING": "متابعة المشاهدة", + "PLAY_FIRST_EPISODE": "تشغيل الحلقة الأولى", + "RESUME_EPISODE": "استئناف الحلقة", + "PLAY_NEXT_EPISODE": "تشغيل الحلقة التالية", + "SERIES_WATCHED": "تمت مشاهدة المسلسل", "WATCHED": "تمت المشاهدة", "IN_PROGRESS": "قيد التقدم", "MARK_WATCHED": "وضع علامة كمشاهد", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 15e69f71c..d610e18e8 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -575,6 +575,10 @@ "RESUME": "كمل", "RESTART": "بدا من الأول", "CONTINUE_WATCHING": "كمل المشاهدة", + "PLAY_FIRST_EPISODE": "شغل الحلقة الأولى", + "RESUME_EPISODE": "كمل الحلقة", + "PLAY_NEXT_EPISODE": "شغل الحلقة الجاية", + "SERIES_WATCHED": "السلسلة متشافة", "WATCHED": "متشاف", "IN_PROGRESS": "جاري", "MARK_WATCHED": "علم كمتشاف", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 5b4bd1a26..1326d4742 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -575,6 +575,10 @@ "RESUME": "Працягнуць", "RESTART": "Спачатку", "CONTINUE_WATCHING": "Працягнуць прагляд", + "PLAY_FIRST_EPISODE": "Прайграць першы эпізод", + "RESUME_EPISODE": "Працягнуць эпізод", + "PLAY_NEXT_EPISODE": "Прайграць наступны эпізод", + "SERIES_WATCHED": "Серыял прагледжаны", "WATCHED": "Прагледжана", "IN_PROGRESS": "У працэсе", "MARK_WATCHED": "Адзначыць як прагледжанае", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index c974a6b16..12134643c 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -575,6 +575,10 @@ "RESUME": "Fortsetzen", "RESTART": "Neu starten", "CONTINUE_WATCHING": "Weiter schauen", + "PLAY_FIRST_EPISODE": "Erste Folge abspielen", + "RESUME_EPISODE": "Folge fortsetzen", + "PLAY_NEXT_EPISODE": "Nächste Folge abspielen", + "SERIES_WATCHED": "Serie angesehen", "WATCHED": "Angesehen", "IN_PROGRESS": "In Wiedergabe", "MARK_WATCHED": "Als angesehen markieren", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 3018d0f98..e98dbcb92 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -575,6 +575,10 @@ "RESUME": "Συνέχιση", "RESTART": "Από την αρχή", "CONTINUE_WATCHING": "Συνέχιση παρακολούθησης", + "PLAY_FIRST_EPISODE": "Αναπαραγωγή πρώτου επεισοδίου", + "RESUME_EPISODE": "Συνέχιση επεισοδίου", + "PLAY_NEXT_EPISODE": "Αναπαραγωγή επόμενου επεισοδίου", + "SERIES_WATCHED": "Η σειρά προβλήθηκε", "WATCHED": "Έχει προβληθεί", "IN_PROGRESS": "Σε εξέλιξη", "MARK_WATCHED": "Σήμανση ως προβληθέν", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index d280c81e3..9f6ace9f0 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -581,6 +581,10 @@ "RESUME": "Resume", "RESTART": "Start Over", "CONTINUE_WATCHING": "Continue Watching", + "PLAY_FIRST_EPISODE": "Play first episode", + "RESUME_EPISODE": "Resume episode", + "PLAY_NEXT_EPISODE": "Play next episode", + "SERIES_WATCHED": "Series watched", "WATCHED": "Watched", "IN_PROGRESS": "In Progress", "MARK_WATCHED": "Mark as Watched", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 71251fb1d..76b8a66ea 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -575,6 +575,10 @@ "RESUME": "Continuar", "RESTART": "Reiniciar", "CONTINUE_WATCHING": "Continuar viendo", + "PLAY_FIRST_EPISODE": "Reproducir primer episodio", + "RESUME_EPISODE": "Continuar episodio", + "PLAY_NEXT_EPISODE": "Reproducir siguiente episodio", + "SERIES_WATCHED": "Serie vista", "WATCHED": "Visto", "IN_PROGRESS": "En progreso", "MARK_WATCHED": "Marcar como visto", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 4e3072e3d..db4fc35a6 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -575,6 +575,10 @@ "RESUME": "Reprendre", "RESTART": "Recommencer", "CONTINUE_WATCHING": "Reprendre la lecture", + "PLAY_FIRST_EPISODE": "Lire le premier épisode", + "RESUME_EPISODE": "Reprendre l'épisode", + "PLAY_NEXT_EPISODE": "Lire l'épisode suivant", + "SERIES_WATCHED": "Série vue", "WATCHED": "Vu", "IN_PROGRESS": "En cours", "MARK_WATCHED": "Marquer comme vu", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 5030c8afe..91b11a273 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -575,6 +575,10 @@ "RESUME": "Riprendi", "RESTART": "Ricomincia", "CONTINUE_WATCHING": "Continua a guardare", + "PLAY_FIRST_EPISODE": "Riproduci primo episodio", + "RESUME_EPISODE": "Riprendi episodio", + "PLAY_NEXT_EPISODE": "Riproduci episodio successivo", + "SERIES_WATCHED": "Serie vista", "WATCHED": "Visto", "IN_PROGRESS": "In corso", "MARK_WATCHED": "Segna come visto", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index ab979fc7f..b22801e33 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -575,6 +575,10 @@ "RESUME": "続きから再生", "RESTART": "最初から再生", "CONTINUE_WATCHING": "視聴を再開", + "PLAY_FIRST_EPISODE": "第1話を再生", + "RESUME_EPISODE": "エピソードを再開", + "PLAY_NEXT_EPISODE": "次のエピソードを再生", + "SERIES_WATCHED": "シリーズ視聴済み", "WATCHED": "視聴済み", "IN_PROGRESS": "視聴中", "MARK_WATCHED": "視聴済みにする", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 7184acdff..dcf07fe03 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -575,6 +575,10 @@ "RESUME": "이어 보기", "RESTART": "처음부터 보기", "CONTINUE_WATCHING": "이어서 시청", + "PLAY_FIRST_EPISODE": "첫 에피소드 재생", + "RESUME_EPISODE": "에피소드 이어 보기", + "PLAY_NEXT_EPISODE": "다음 에피소드 재생", + "SERIES_WATCHED": "시리즈 시청 완료", "WATCHED": "시청 완료", "IN_PROGRESS": "시청 중", "MARK_WATCHED": "시청한 것으로 표시", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 5037188d2..6e96e2741 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -575,6 +575,10 @@ "RESUME": "Hervatten", "RESTART": "Opnieuw beginnen", "CONTINUE_WATCHING": "Verder kijken", + "PLAY_FIRST_EPISODE": "Eerste aflevering afspelen", + "RESUME_EPISODE": "Aflevering hervatten", + "PLAY_NEXT_EPISODE": "Volgende aflevering afspelen", + "SERIES_WATCHED": "Serie bekeken", "WATCHED": "Bekeken", "IN_PROGRESS": "Bezig", "MARK_WATCHED": "Markeren als bekeken", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 03cb1f9d0..a16a0b5dd 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -575,6 +575,10 @@ "RESUME": "Wznów", "RESTART": "Zacznij od nowa", "CONTINUE_WATCHING": "Kontynuuj oglądanie", + "PLAY_FIRST_EPISODE": "Odtwórz pierwszy odcinek", + "RESUME_EPISODE": "Wznów odcinek", + "PLAY_NEXT_EPISODE": "Odtwórz następny odcinek", + "SERIES_WATCHED": "Serial obejrzany", "WATCHED": "Obejrzane", "IN_PROGRESS": "W trakcie", "MARK_WATCHED": "Oznacz jako obejrzane", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 4f4f38509..6e9f9c098 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -575,6 +575,10 @@ "RESUME": "Continuar", "RESTART": "Recomeçar", "CONTINUE_WATCHING": "Continuar assistindo", + "PLAY_FIRST_EPISODE": "Reproduzir primeiro episódio", + "RESUME_EPISODE": "Continuar episódio", + "PLAY_NEXT_EPISODE": "Reproduzir próximo episódio", + "SERIES_WATCHED": "Série assistida", "WATCHED": "Assistido", "IN_PROGRESS": "Em andamento", "MARK_WATCHED": "Marcar como assistido", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 9c2e41b81..0b6b0a169 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -579,6 +579,10 @@ "RESUME": "Продолжить", "RESTART": "С начала", "CONTINUE_WATCHING": "Продолжить просмотр", + "PLAY_FIRST_EPISODE": "Воспроизвести первую серию", + "RESUME_EPISODE": "Продолжить серию", + "PLAY_NEXT_EPISODE": "Следующая серия", + "SERIES_WATCHED": "Сериал просмотрен", "WATCHED": "Просмотрено", "IN_PROGRESS": "В процессе", "MARK_WATCHED": "Отметить как просмотренное", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 4d89ad146..ce0f0e818 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -575,6 +575,10 @@ "RESUME": "Devam Et", "RESTART": "Baştan Başla", "CONTINUE_WATCHING": "İzlemeye Devam Et", + "PLAY_FIRST_EPISODE": "İlk bölümü oynat", + "RESUME_EPISODE": "Bölüme devam et", + "PLAY_NEXT_EPISODE": "Sonraki bölümü oynat", + "SERIES_WATCHED": "Dizi izlendi", "WATCHED": "İzlendi", "IN_PROGRESS": "Devam Ediyor", "MARK_WATCHED": "İzlendi Olarak İşaretle", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 4defd7261..24e1e0643 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -575,6 +575,10 @@ "RESUME": "继续观看", "RESTART": "从头开始", "CONTINUE_WATCHING": "继续观看", + "PLAY_FIRST_EPISODE": "播放第一集", + "RESUME_EPISODE": "继续播放本集", + "PLAY_NEXT_EPISODE": "播放下一集", + "SERIES_WATCHED": "剧集已看完", "WATCHED": "已观看", "IN_PROGRESS": "观看中", "MARK_WATCHED": "标记为已观看", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 2942169a1..fb89c9b4d 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -575,6 +575,10 @@ "RESUME": "繼續", "RESTART": "從頭開始", "CONTINUE_WATCHING": "繼續觀看", + "PLAY_FIRST_EPISODE": "播放第一集", + "RESUME_EPISODE": "繼續播放本集", + "PLAY_NEXT_EPISODE": "播放下一集", + "SERIES_WATCHED": "劇集已看完", "WATCHED": "已觀看", "IN_PROGRESS": "觀看中", "MARK_WATCHED": "標記為已觀看", diff --git a/apps/web/src/test-stubs/xtream-feature.stub.ts b/apps/web/src/test-stubs/xtream-feature.stub.ts deleted file mode 100644 index 0dd1ce38a..000000000 --- a/apps/web/src/test-stubs/xtream-feature.stub.ts +++ /dev/null @@ -1 +0,0 @@ -export class PlayerDialogComponent {} diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md index f75a3ed84..e80715b8e 100644 --- a/docs/architecture/embedded-inline-playback.md +++ b/docs/architecture/embedded-inline-playback.md @@ -9,23 +9,30 @@ This document records the current contract for embedded playback in portal detai - External players are `mpv` and `vlc`. - Flatpak launches external players on the host via `flatpak-spawn --host`. - Live playback stays inline in dedicated live layouts. -- VOD and series detail playback now also stays inline on canonical detail surfaces. -- Material dialog playback remains only as a fallback for older non-detail callers. +- VOD and series detail playback stays inline on canonical detail, collection, + favorites, recent, and search surfaces. +- Xtream and Stalker series detail heroes expose a quick-start CTA driven by + saved episode playback positions. +- Embedded playback UI is always hosted by the current view. `PlayerService` + launches MPV/VLC only and does not open an embedded-player dialog. - Browser-player failures are diagnosed client-side and can offer explicit MPV/VLC fallback actions without changing the saved player setting. ## Scope -The first pass is intentionally limited: +Inline embedded playback is required for these VOD/series entry points: - Xtream VOD detail route - Xtream series detail route - Stalker VOD detail view - Stalker series detail view +- unified favorites collection details +- unified recently viewed collection details +- Stalker advanced search result details -Not migrated in this pass: - -- Generic non-detail playback entry points that still call `PlayerService.openPlayer(...)` -- Any collection/search surface that does not host a canonical detail surface of its own +Collection/search VOD surfaces that expose embedded playback must host +`ResolvedPortalPlayback` inline state locally. They must not call +`PlayerService.openPlayer(...)` or `PlayerService.openResolvedPlayback(...)` to +create embedded UI. ## Embedded MPV Harness @@ -68,7 +75,7 @@ Current limitation: Shared inline player shell: -- `/Users/4gray/Code/iptvnator/libs/ui/components/src/lib/portal-inline-player/portal-inline-player.component.ts` +- `/Users/4gray/Code/iptvnator/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts` Xtream detail hosts: @@ -77,13 +84,14 @@ Xtream detail hosts: Stalker detail hosts: -- `/Users/4gray/Code/iptvnator/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts` -- `/Users/4gray/Code/iptvnator/libs/ui/components/src/lib/stalker-series-view/stalker-series-view.component.ts` +- `/Users/4gray/Code/iptvnator/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts` +- `/Users/4gray/Code/iptvnator/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts` +- `/Users/4gray/Code/iptvnator/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts` +- `/Users/4gray/Code/iptvnator/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts` -Fallback dialog path: - -- `/Users/4gray/Code/iptvnator/apps/web/src/app/services/player.service.ts` -- `/Users/4gray/Code/iptvnator/libs/portal/xtream/feature/src/lib/player-dialog/player-dialog.component.ts` +Embedded playback does not have a fallback dialog path. +`PlayerService.openResolvedPlayback(...)` remains the MPV/VLC external launch +entry point; for embedded players it returns without creating UI. Diagnostics and fallback UI: @@ -99,7 +107,30 @@ When a detail view starts playback: 3. If the player is embedded, render the inline player inside the current detail view. 4. If the player is external, hand the same payload to `PlayerService` for MPV/VLC playback. -The detail host owns inline state. `PlayerService` is no longer the primary owner of UI playback state for canonical VOD/series detail screens. +The detail or collection/search host owns inline state. `PlayerService` is not +an owner of embedded UI playback state. + +## Series Quick Start CTA + +Xtream and Stalker series detail views share the quick-start decision helper in +`/Users/4gray/Code/iptvnator/libs/portal/shared/util/src/lib/series-quick-start.ts`. +The helper flattens the loaded season/episode map, sorts seasons and episodes in +natural order, and returns the hero CTA state. + +Current contract: + +- the CTA shows the action label plus a compact episode target such as + `S01E02 · Episode title` +- if an episode is in progress, resume the latest updated in-progress episode + with its saved offset +- if no episode is in progress, play the first unwatched episode in season order +- if watched episodes end at a season boundary, play the first episode of the + next loaded season +- if every loaded episode is watched, render a disabled completed state + +The click path must continue through each detail host's normal episode playback +method so recent-item updates, inline/external player selection, resume offsets, +and playback-position saving keep the same behavior as manual episode clicks. ## Codec And Container Diagnostics @@ -175,18 +206,20 @@ Stalker previously resolved playback and opened UI in the same method. Current contract: - `resolveVodPlayback(...)` returns a `ResolvedPortalPlayback` -- `createLinkToPlayVod(...)` remains as a compatibility wrapper for untouched callers -- canonical Stalker detail views use the resolver directly and decide inline vs external locally +- `createLinkToPlayVod(...)` remains as a compatibility API but collection, + search, and canonical detail views use the resolver directly +- Stalker detail, collection, and search views decide inline vs external locally This keeps: - inline/store-state detail navigation intact - series and VOD-as-series support intact -- non-detail callers working until they are migrated +- external MPV/VLC launches unchanged ## Playback Position Saving -The old dialog path saved playback positions from inside `PlayerDialogComponent`. +The old dialog path saved playback positions from inside the removed Xtream +player dialog. The new contract is: @@ -198,7 +231,7 @@ This avoids coupling inline UI state to a global dialog. ## Future Migration Rule -If a non-detail surface is converted away from dialog playback: +If a non-detail surface needs embedded playback: - give that surface a canonical inline host - switch it to `ResolvedPortalPlayback` diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index b45f909fc..4a75c2aac 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -141,6 +141,12 @@ Stalker has multiple real-world data shapes. The current implementation supports - Treated as series flow from VOD context. - Seasons are fetched lazily. - Episodes are fetched on season select. +- The series quick-start CTA can load the first unloaded VOD-series season + before playback, then starts its first episode. If all currently loaded + episodes are watched and more season metadata exists, quick start loads the + next unloaded season instead of showing the completed state. +- For unloaded VOD-series seasons, the CTA target label is derived from season + metadata and rendered as `SxxE01` until episode details are loaded. - Uses unique generated tracking IDs for episode playback position compatibility. Core decision logic and normalization are centralized in: diff --git a/libs/portal/shared/util/src/index.ts b/libs/portal/shared/util/src/index.ts index 7b8455579..fa636d2e8 100644 --- a/libs/portal/shared/util/src/index.ts +++ b/libs/portal/shared/util/src/index.ts @@ -6,6 +6,7 @@ export * from './lib/portal-catalog-facade'; export * from './lib/portal-external-playback'; export * from './lib/portal-player'; export * from './lib/portal-playback-positions'; +export * from './lib/series-quick-start'; export * from './lib/portal-navigation-actions'; export * from './lib/portal-shell-actions'; export * from './lib/portal-collection-context'; diff --git a/libs/portal/shared/util/src/lib/series-quick-start.spec.ts b/libs/portal/shared/util/src/lib/series-quick-start.spec.ts new file mode 100644 index 000000000..4fa794a61 --- /dev/null +++ b/libs/portal/shared/util/src/lib/series-quick-start.spec.ts @@ -0,0 +1,261 @@ +import { PlaybackPositionData, XtreamSerieEpisode } from 'shared-interfaces'; +import { + SERIES_QUICK_START_ACTION_KIND, + getSeriesQuickStartAction, + getPositiveInteger, + padEpisodePart, +} from './series-quick-start'; + +function episode( + id: number, + season: number, + episodeNum: number, + title = `Episode ${episodeNum}` +): XtreamSerieEpisode { + return { + id: String(id), + episode_num: episodeNum, + title, + container_extension: 'mp4', + info: [], + custom_sid: '', + added: '', + season, + direct_source: '', + }; +} + +function position( + contentXtreamId: number, + overrides: Partial = {} +): PlaybackPositionData { + return { + contentXtreamId, + contentType: 'episode', + seriesXtreamId: 10, + positionSeconds: 0, + durationSeconds: 100, + ...overrides, + }; +} + +describe('getSeriesQuickStartAction', () => { + it('returns the first episode when there are no playback positions', () => { + const firstEpisode = episode(101, 1, 1); + + const action = getSeriesQuickStartAction({ + seasons: { + '1': [firstEpisode, episode(102, 1, 2)], + }, + playbackPositions: new Map(), + }); + + expect(action).toEqual({ + kind: SERIES_QUICK_START_ACTION_KIND.PlayFirst, + labelKey: 'XTREAM.PLAY_FIRST_EPISODE', + episodeLabel: 'S01E01 · Episode 1', + icon: 'play_arrow', + episode: firstEpisode, + position: null, + startTime: undefined, + disabled: false, + }); + }); + + it('resumes the latest in-progress episode by updatedAt', () => { + const olderEpisode = episode(101, 1, 1); + const latestEpisode = episode(102, 1, 2); + + const action = getSeriesQuickStartAction({ + seasons: { + '1': [olderEpisode, latestEpisode], + }, + playbackPositions: new Map([ + [ + 101, + position(101, { + positionSeconds: 20, + updatedAt: '2026-05-10T10:00:00.000Z', + }), + ], + [ + 102, + position(102, { + positionSeconds: 30, + updatedAt: '2026-05-10T11:00:00.000Z', + }), + ], + ]), + }); + + expect(action?.kind).toBe(SERIES_QUICK_START_ACTION_KIND.Resume); + expect(action?.labelKey).toBe('XTREAM.RESUME_EPISODE'); + expect(action?.episodeLabel).toBe('S01E02 · Episode 2'); + expect(action?.episode).toBe(latestEpisode); + expect(action?.startTime).toBe(30); + }); + + it('plays the next unwatched episode after watched episodes', () => { + const nextEpisode = episode(102, 1, 2); + + const action = getSeriesQuickStartAction({ + seasons: { + '1': [episode(101, 1, 1), nextEpisode], + }, + playbackPositions: new Map([ + [ + 101, + position(101, { + positionSeconds: 95, + durationSeconds: 100, + }), + ], + ]), + }); + + expect(action?.kind).toBe(SERIES_QUICK_START_ACTION_KIND.PlayNext); + expect(action?.labelKey).toBe('XTREAM.PLAY_NEXT_EPISODE'); + expect(action?.episodeLabel).toBe('S01E02 · Episode 2'); + expect(action?.episode).toBe(nextEpisode); + }); + + it('plays the first episode of the next season when the current season is watched', () => { + const nextSeasonEpisode = episode(201, 2, 1); + + const action = getSeriesQuickStartAction({ + seasons: { + '1': [episode(101, 1, 1)], + '2': [nextSeasonEpisode], + }, + playbackPositions: new Map([ + [ + 101, + position(101, { + positionSeconds: 100, + durationSeconds: 100, + }), + ], + ]), + }); + + expect(action?.kind).toBe(SERIES_QUICK_START_ACTION_KIND.PlayNext); + expect(action?.episodeLabel).toBe('S02E01 · Episode 1'); + expect(action?.episode).toBe(nextSeasonEpisode); + }); + + it('returns a disabled completed action when every episode is watched', () => { + const finalEpisode = episode(102, 1, 2); + + const action = getSeriesQuickStartAction({ + seasons: { + '1': [episode(101, 1, 1), finalEpisode], + }, + playbackPositions: new Map([ + [ + 101, + position(101, { + positionSeconds: 100, + durationSeconds: 100, + }), + ], + [ + 102, + position(102, { + positionSeconds: 90, + durationSeconds: 100, + }), + ], + ]), + }); + + expect(action).toEqual({ + kind: SERIES_QUICK_START_ACTION_KIND.Completed, + labelKey: 'XTREAM.SERIES_WATCHED', + episodeLabel: 'S01E02 · Episode 2', + icon: 'check_circle', + episode: finalEpisode, + position: expect.any(Object), + startTime: undefined, + disabled: true, + }); + }); + + it('sorts nonnumeric season keys naturally', () => { + const seasonTwoEpisode = episode(201, 2, 1); + + const action = getSeriesQuickStartAction({ + seasons: { + 'Season 10': [episode(1001, 10, 1)], + 'Season 2': [seasonTwoEpisode], + }, + playbackPositions: new Map(), + }); + + expect(action?.episode).toBe(seasonTwoEpisode); + }); + + it('falls back to series order when in-progress positions have no updatedAt', () => { + const laterEpisode = episode(102, 1, 2); + + const action = getSeriesQuickStartAction({ + seasons: { + '1': [episode(101, 1, 1), laterEpisode], + }, + playbackPositions: new Map([ + [ + 101, + position(101, { + positionSeconds: 20, + }), + ], + [ + 102, + position(102, { + positionSeconds: 30, + }), + ], + ]), + }); + + expect(action?.kind).toBe(SERIES_QUICK_START_ACTION_KIND.Resume); + expect(action?.episode).toBe(laterEpisode); + }); + + it('omits the title separator when an episode title is empty', () => { + const firstEpisode = episode(101, 1, 1, ''); + + const action = getSeriesQuickStartAction({ + seasons: { + '1': [firstEpisode], + }, + playbackPositions: new Map(), + }); + + expect(action?.episodeLabel).toBe('S01E01'); + }); + + it('returns null when no playable episodes exist', () => { + const action = getSeriesQuickStartAction({ + seasons: { + '1': [], + }, + playbackPositions: new Map(), + }); + + expect(action).toBeNull(); + }); +}); + +describe('episode label helpers', () => { + it('normalizes only positive integers', () => { + expect(getPositiveInteger(1)).toBe(1); + expect(getPositiveInteger(0)).toBeNull(); + expect(getPositiveInteger(1.5)).toBeNull(); + expect(getPositiveInteger(Number.NaN)).toBeNull(); + }); + + it('pads single digit episode label parts', () => { + expect(padEpisodePart(1)).toBe('01'); + expect(padEpisodePart(10)).toBe('10'); + }); +}); diff --git a/libs/portal/shared/util/src/lib/series-quick-start.ts b/libs/portal/shared/util/src/lib/series-quick-start.ts new file mode 100644 index 000000000..50998f5d6 --- /dev/null +++ b/libs/portal/shared/util/src/lib/series-quick-start.ts @@ -0,0 +1,197 @@ +import { PlaybackPositionData, XtreamSerieEpisode } from 'shared-interfaces'; +import { + isPortalPlaybackInProgress, + isPortalPlaybackWatched, +} from './portal-playback-positions'; + +export const SERIES_QUICK_START_ACTION_KIND = { + PlayFirst: 'play-first', + Resume: 'resume', + PlayNext: 'play-next', + Completed: 'completed', +} as const; + +export type SeriesQuickStartActionKind = + (typeof SERIES_QUICK_START_ACTION_KIND)[keyof typeof SERIES_QUICK_START_ACTION_KIND]; + +export interface SeriesQuickStartAction { + kind: SeriesQuickStartActionKind; + labelKey: string; + episodeLabel: string; + icon: string; + episode: XtreamSerieEpisode; + position: PlaybackPositionData | null; + startTime?: number; + disabled: boolean; +} + +interface SeriesQuickStartRequest { + seasons: Record; + playbackPositions: Map; +} + +interface OrderedEpisode { + episode: XtreamSerieEpisode; + position: PlaybackPositionData | null; + order: number; +} + +const naturalCollator = new Intl.Collator(undefined, { + numeric: true, + sensitivity: 'base', +}); + +export function getSeriesQuickStartAction( + request: SeriesQuickStartRequest +): SeriesQuickStartAction | null { + const orderedEpisodes = getOrderedEpisodes(request); + + if (orderedEpisodes.length === 0) { + return null; + } + + const inProgress = orderedEpisodes + .filter(({ position }) => isPortalPlaybackInProgress(position)) + .sort(compareInProgressPositions); + + const resumeEpisode = inProgress[inProgress.length - 1]; + if (resumeEpisode?.position) { + return createQuickStartAction({ + kind: SERIES_QUICK_START_ACTION_KIND.Resume, + labelKey: 'XTREAM.RESUME_EPISODE', + icon: 'play_arrow', + episode: resumeEpisode.episode, + position: resumeEpisode.position, + startTime: resumeEpisode.position.positionSeconds, + disabled: false, + }); + } + + const firstUnwatchedEpisode = orderedEpisodes.find( + ({ position }) => !isPortalPlaybackWatched(position) + ); + + if (firstUnwatchedEpisode) { + const hasWatchedEpisode = orderedEpisodes.some(({ position }) => + isPortalPlaybackWatched(position) + ); + + return createQuickStartAction({ + kind: hasWatchedEpisode + ? SERIES_QUICK_START_ACTION_KIND.PlayNext + : SERIES_QUICK_START_ACTION_KIND.PlayFirst, + labelKey: hasWatchedEpisode + ? 'XTREAM.PLAY_NEXT_EPISODE' + : 'XTREAM.PLAY_FIRST_EPISODE', + icon: 'play_arrow', + episode: firstUnwatchedEpisode.episode, + position: firstUnwatchedEpisode.position, + startTime: undefined, + disabled: false, + }); + } + + const finalEpisode = orderedEpisodes[orderedEpisodes.length - 1]; + if (!finalEpisode) { + return null; + } + + return createQuickStartAction({ + kind: SERIES_QUICK_START_ACTION_KIND.Completed, + labelKey: 'XTREAM.SERIES_WATCHED', + icon: 'check_circle', + episode: finalEpisode.episode, + position: finalEpisode.position, + startTime: undefined, + disabled: true, + }); +} + +function createQuickStartAction( + action: Omit +): SeriesQuickStartAction { + return { + ...action, + episodeLabel: getEpisodeLabel(action.episode), + }; +} + +function getOrderedEpisodes( + request: SeriesQuickStartRequest +): OrderedEpisode[] { + const orderedEpisodes: OrderedEpisode[] = []; + + Object.entries(request.seasons) + .sort(([seasonA], [seasonB]) => + naturalCollator.compare(seasonA, seasonB) + ) + .forEach(([, episodes]) => { + [...episodes].sort(compareEpisodes).forEach((episode) => { + orderedEpisodes.push({ + episode, + position: + request.playbackPositions.get(Number(episode.id)) ?? + null, + order: orderedEpisodes.length, + }); + }); + }); + + return orderedEpisodes; +} + +function compareEpisodes( + episodeA: XtreamSerieEpisode, + episodeB: XtreamSerieEpisode +): number { + const episodeNumberDelta = + Number(episodeA.episode_num) - Number(episodeB.episode_num); + + if (Number.isFinite(episodeNumberDelta) && episodeNumberDelta !== 0) { + return episodeNumberDelta; + } + + return naturalCollator.compare(episodeA.title ?? '', episodeB.title ?? ''); +} + +function compareInProgressPositions( + episodeA: OrderedEpisode, + episodeB: OrderedEpisode +): number { + const timeA = getTimestamp(episodeA.position?.updatedAt); + const timeB = getTimestamp(episodeB.position?.updatedAt); + + if (timeA !== timeB) { + return timeA - timeB; + } + + return episodeA.order - episodeB.order; +} + +function getTimestamp(value: string | undefined): number { + if (!value) { + return 0; + } + + const timestamp = Date.parse(value); + return Number.isFinite(timestamp) ? timestamp : 0; +} + +function getEpisodeLabel(episode: XtreamSerieEpisode): string { + const seasonNumber = getPositiveInteger(episode.season) ?? 1; + const episodeNumber = getPositiveInteger(episode.episode_num) ?? 1; + const episodeCode = `S${padEpisodePart(seasonNumber)}E${padEpisodePart( + episodeNumber + )}`; + const title = episode.title.trim(); + + return title ? `${episodeCode} · ${title}` : episodeCode; +} + +export function getPositiveInteger(value: number): number | null { + return Number.isInteger(value) && value > 0 ? value : null; +} + +export function padEpisodePart(value: number): string { + return value < 10 ? `0${value}` : String(value); +} diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.spec.ts index 79587af21..2e5794fbd 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.spec.ts @@ -202,6 +202,49 @@ describe('withStalkerPlayer', () => { }); }); + it('persists regular series recent metadata without marking it as a VOD series', async () => { + store.setSelectedContentType('series'); + store.setSelectedItem({ + id: '30000', + cmd: '', + title: 'Regular Series', + category_id: 'series', + }); + dataService.sendIpcEvent.mockResolvedValueOnce({ + js: { + cmd: 'ffmpeg http://cdn.example/episode_30000_1.mpg', + }, + }); + + const playback = await store.resolveVodPlayback( + 'ffrt4://series/30000/season/1', + 'Episode One', + 'series.jpg', + 1, + 3000001 + ); + + expect(playlistService.addPortalRecentlyViewed).toHaveBeenCalledWith( + PLAYLIST._id, + expect.objectContaining({ + id: '30000', + title: 'Episode One', + category_id: 'series', + cover: 'series.jpg', + added_at: expect.any(Number), + }) + ); + expect( + playlistService.addPortalRecentlyViewed.mock.calls[0][1] + ).not.toHaveProperty('is_series'); + expect(playback.contentInfo).toEqual({ + playlistId: PLAYLIST._id, + contentXtreamId: 3000001, + contentType: 'episode', + seriesXtreamId: 30000, + }); + }); + it('resolves direct radio stream commands without external player side effects', async () => { store.setSelectedContentType('radio'); store.setSelectedItem({ diff --git a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-collection-persistence.utils.ts b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-collection-persistence.utils.ts index 959a77d0e..66a69e2a1 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-collection-persistence.utils.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/utils/stalker-collection-persistence.utils.ts @@ -27,7 +27,6 @@ export function getStalkerSeriesRecentMetadata( selectedContentType: StalkerContentType ): { category_id?: 'series'; - is_series?: true; } { if (selectedContentType !== 'series') { return {}; @@ -35,7 +34,6 @@ export function getStalkerSeriesRecentMetadata( return { category_id: 'series', - is_series: true, }; } diff --git a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.spec.ts index c1f9f983a..da8218ae5 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.spec.ts @@ -1,10 +1,22 @@ import { Component, input, output, signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { TranslateService } from '@ngx-translate/core'; import { ContentHeroComponent } from 'components'; -import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util'; +import { + PORTAL_EXTERNAL_PLAYBACK, + PORTAL_PLAYBACK_POSITIONS, + PORTAL_PLAYER, + UnifiedCollectionItem, +} from '@iptvnator/portal/shared/util'; import { StalkerStore } from '@iptvnator/portal/stalker/data-access'; import { PlaylistsService } from 'services'; -import { Playlist } from 'shared-interfaces'; +import { + Playlist, + ResolvedPortalPlayback, + VodDetailsItem, + createStalkerVodItem, +} from 'shared-interfaces'; import { of } from 'rxjs'; import { StalkerCollectionDetailComponent } from './stalker-collection-detail.component'; import { StalkerInlineDetailComponent } from './stalker-inline-detail/stalker-inline-detail.component'; @@ -27,11 +39,19 @@ class StubStalkerInlineDetailComponent { readonly categoryId = input<'vod' | 'series' | null>(null); readonly seriesItem = input(null); readonly isSeries = input(false); - readonly vodDetailsItem = input(null); + readonly vodDetailsItem = input(null); readonly isFavorite = input(false); + readonly playbackPosition = input(null); + readonly inlinePlayback = input(null); + readonly externalPlayback = input(null); readonly backClicked = output(); - readonly playClicked = output(); + readonly playClicked = output(); + readonly resumeClicked = output(); readonly favoriteToggled = output(); + readonly inlineTimeUpdated = output(); + readonly inlinePlaybackClosed = output(); + readonly streamUrlCopied = output(); + readonly inlineExternalFallbackRequested = output(); } describe('StalkerCollectionDetailComponent', () => { @@ -54,6 +74,19 @@ describe('StalkerCollectionDetailComponent', () => { addToFavorites: jest.Mock; removeFromFavorites: jest.Mock; createLinkToPlayVod: jest.Mock; + resolveVodPlayback: jest.Mock; + }; + let portalPlayer: { + isEmbeddedPlayer: jest.Mock; + openResolvedPlayback: jest.Mock; + openExternalPlayback: jest.Mock; + }; + let playbackPositions: { + savePlaybackPosition: jest.Mock; + getPlaybackPosition: jest.Mock; + getSeriesPlaybackPositions: jest.Mock; + getAllPlaybackPositions: jest.Mock; + clearPlaybackPosition: jest.Mock; }; const playlist = { @@ -91,6 +124,19 @@ describe('StalkerCollectionDetailComponent', () => { addToFavorites: jest.fn(), removeFromFavorites: jest.fn(), createLinkToPlayVod: jest.fn(), + resolveVodPlayback: jest.fn(), + }; + portalPlayer = { + isEmbeddedPlayer: jest.fn(() => true), + openResolvedPlayback: jest.fn(), + openExternalPlayback: jest.fn(), + }; + playbackPositions = { + savePlaybackPosition: jest.fn(), + getPlaybackPosition: jest.fn(async () => null), + getSeriesPlaybackPositions: jest.fn(), + getAllPlaybackPositions: jest.fn(), + clearPlaybackPosition: jest.fn(), }; await TestBed.configureTestingModule({ @@ -100,6 +146,40 @@ describe('StalkerCollectionDetailComponent', () => { provide: StalkerStore, useValue: stalkerStore, }, + { + provide: PORTAL_PLAYER, + useValue: portalPlayer, + }, + { + provide: PORTAL_PLAYBACK_POSITIONS, + useValue: playbackPositions, + }, + { + provide: PORTAL_EXTERNAL_PLAYBACK, + useValue: { + activeSession: signal(null), + visibleSession: signal(null), + dismissActiveSession: jest.fn(), + closeSession: jest.fn(), + }, + }, + { + provide: MatSnackBar, + useValue: { + open: jest.fn(), + }, + }, + { + provide: TranslateService, + useValue: { + instant: (key: string) => key, + get: (key: string) => of(key), + stream: (key: string) => of(key), + onLangChange: of(null), + onTranslationChange: of(null), + onDefaultLangChange: of(null), + }, + }, { provide: PlaylistsService, useValue: { @@ -111,7 +191,10 @@ describe('StalkerCollectionDetailComponent', () => { }) .overrideComponent(StalkerCollectionDetailComponent, { remove: { - imports: [ContentHeroComponent, StalkerInlineDetailComponent], + imports: [ + ContentHeroComponent, + StalkerInlineDetailComponent, + ], }, add: { imports: [ @@ -150,7 +233,9 @@ describe('StalkerCollectionDetailComponent', () => { expect(stalkerStore.setSelectedContentType).toHaveBeenLastCalledWith( 'vod' ); - expect(stalkerStore.setSelectedCategory).toHaveBeenLastCalledWith('vod'); + expect(stalkerStore.setSelectedCategory).toHaveBeenLastCalledWith( + 'vod' + ); expect(stalkerStore.setSelectedItem).toHaveBeenLastCalledWith( expect.objectContaining({ id: '1507', @@ -227,6 +312,83 @@ describe('StalkerCollectionDetailComponent', () => { fixture.componentInstance.inlineDetail().seriesItem?.series ).toEqual([1, 2]); }); + + it('plays regular VOD collection details inline for embedded players instead of using the legacy play wrapper', async () => { + const sourceItem = { + id: '1701', + title: 'Collection Movie', + category_id: 'vod', + cmd: '/media/file_1701.mpg', + info: { + name: 'Collection Movie', + movie_image: 'movie.jpg', + }, + }; + const playback: ResolvedPortalPlayback = { + streamUrl: 'https://streams.example.test/movie.mp4', + title: 'Collection Movie', + thumbnail: 'movie.jpg', + }; + stalkerStore.resolveVodPlayback.mockResolvedValue(playback); + + fixture.componentRef.setInput( + 'item', + buildCollectionItem({ + contentType: 'movie', + categoryId: 'vod', + stalkerItem: sourceItem, + }) + ); + + await settleDetail(fixture); + + fixture.componentInstance.onVodPlay( + createStalkerVodItem(sourceItem, playlist._id) + ); + await settleDetail(fixture); + + expect(stalkerStore.resolveVodPlayback).toHaveBeenCalledWith( + '/media/file_1701.mpg', + 'Collection Movie', + 'movie.jpg' + ); + expect(stalkerStore.createLinkToPlayVod).not.toHaveBeenCalled(); + expect(portalPlayer.openResolvedPlayback).not.toHaveBeenCalled(); + expect(fixture.componentInstance.inlinePlayback()).toEqual(playback); + }); + + it('does not load VOD playback position when the playlist id is missing', async () => { + const playlistsService = TestBed.inject(PlaylistsService) as { + getPlaylistById: jest.Mock; + }; + playlistsService.getPlaylistById.mockReturnValue( + of({ + ...playlist, + _id: '', + }) + ); + + fixture.componentRef.setInput( + 'item', + buildCollectionItem({ + contentType: 'movie', + categoryId: 'vod', + stalkerItem: { + id: '1702', + title: 'Collection Movie Without Playlist', + category_id: 'vod', + cmd: '/media/file_1702.mpg', + }, + }) + ); + + await settleDetail(fixture); + + expect(playbackPositions.getPlaybackPosition).not.toHaveBeenCalled(); + expect(fixture.componentInstance.selectedVodPlaybackPosition()).toBe( + null + ); + }); }); function buildCollectionItem( diff --git a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts index 55d2f5654..a38f26a68 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts @@ -9,9 +9,15 @@ import { signal, untracked, } from '@angular/core'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { TranslateService } from '@ngx-translate/core'; import { ContentHeroComponent } from 'components'; import { buildStalkerStateItem, + createLogger, + PORTAL_EXTERNAL_PLAYBACK, + PORTAL_PLAYBACK_POSITIONS, + PORTAL_PLAYER, toStalkerCategoryId, UnifiedCollectionItem, } from '@iptvnator/portal/shared/util'; @@ -29,10 +35,18 @@ import { StalkerStore, toggleStalkerVodFavorite, } from '@iptvnator/portal/stalker/data-access'; +import type { PlaybackFallbackRequest } from '@iptvnator/ui/playback'; import { PlaylistsService } from 'services'; -import { Playlist, StalkerPortalItem, VodDetailsItem } from 'shared-interfaces'; +import { + PlaybackPositionData, + Playlist, + ResolvedPortalPlayback, + StalkerPortalItem, + VodDetailsItem, +} from 'shared-interfaces'; import { firstValueFrom } from 'rxjs'; import { StalkerInlineDetailComponent } from './stalker-inline-detail/stalker-inline-detail.component'; +import { StalkerVodPlaybackController } from './stalker-vod-playback-controller'; interface StalkerCollectionStateSnapshot { currentPlaylist: Playlist | undefined; @@ -61,9 +75,19 @@ interface StalkerCollectionDetailMode { [isSeries]="inlineDetail().isSeries" [vodDetailsItem]="inlineDetail().vodDetailsItem" [isFavorite]="isSelectedVodFavorite()" + [playbackPosition]="selectedVodPlaybackPosition()" + [inlinePlayback]="inlinePlayback()" + [externalPlayback]="externalPlayback.activeSession()" (backClicked)="closeRequested.emit()" (playClicked)="onVodPlay($event)" + (resumeClicked)="onVodResume($event)" (favoriteToggled)="onVodFavoriteToggled($event)" + (inlineTimeUpdated)="handleInlineTimeUpdate($event)" + (inlinePlaybackClosed)="closeInlinePlayer()" + (streamUrlCopied)="showCopyNotification()" + (inlineExternalFallbackRequested)=" + handleExternalFallbackRequest($event) + " /> } @else { @@ -87,11 +111,21 @@ export class StalkerCollectionDetailComponent { private readonly playlistsService = inject(PlaylistsService); private readonly stalkerStore = inject(StalkerStore); + readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK); + private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS); + private readonly portalPlayer = inject(PORTAL_PLAYER); + private readonly snackBar = inject(MatSnackBar); + private readonly translateService = inject(TranslateService); + private readonly logger = createLogger('StalkerCollectionDetail'); private readonly originalState = this.captureStoreState(); private readonly favoritesRefresh = createRefreshTrigger(); readonly itemDetails = signal(null); readonly vodDetailsItem = signal(null); + readonly inlinePlayback = signal(null); + private readonly selectedVodPosition = signal( + null + ); readonly isSelectedVodFavorite = signal(false); readonly detailCategoryOverride = signal( null @@ -109,8 +143,21 @@ export class StalkerCollectionDetailComponent { () => this.stalkerStore.currentPlaylist()?._id, () => this.favoritesRefresh.refreshVersion() ); + readonly selectedVodPlaybackPosition = computed( + () => this.selectedVodPosition()?.positionSeconds ?? null + ); private initRequestId = 0; + private readonly vodPlayback = new StalkerVodPlaybackController({ + inlinePlayback: this.inlinePlayback, + selectedVodPosition: this.selectedVodPosition, + playbackPositions: this.playbackPositions, + portalPlayer: this.portalPlayer, + snackBar: this.snackBar, + translateService: this.translateService, + logger: this.logger, + playbackErrorLogMessage: 'Failed to start collection VOD playback', + }); constructor() { effect(() => { @@ -139,11 +186,12 @@ export class StalkerCollectionDetailComponent { this.stalkerStore.setSelectedItem( this.originalState.selectedItem as never ); + this.closeInlinePlayer(); } onVodPlay(item: VodDetailsItem): void { if (item.type === 'stalker') { - this.stalkerStore.createLinkToPlayVod( + void this.startStalkerVodPlayback( item.cmd, item.data.info?.name, item.data.info?.movie_image @@ -151,6 +199,20 @@ export class StalkerCollectionDetailComponent { } } + onVodResume(event: { + item: VodDetailsItem; + positionSeconds: number; + }): void { + if (event.item.type === 'stalker') { + void this.startStalkerVodPlayback( + event.item.cmd, + event.item.data.info?.name, + event.item.data.info?.movie_image, + event.positionSeconds + ); + } + } + onVodFavoriteToggled(event: { item: VodDetailsItem; isFavorite: boolean; @@ -170,6 +232,25 @@ export class StalkerCollectionDetailComponent { }); } + handleInlineTimeUpdate(event: { + currentTime: number; + duration: number; + }): void { + this.vodPlayback.handleInlineTimeUpdate(event); + } + + closeInlinePlayer(): void { + this.vodPlayback.closeInlinePlayer(); + } + + showCopyNotification(): void { + this.vodPlayback.showCopyNotification(); + } + + handleExternalFallbackRequest(request: PlaybackFallbackRequest): void { + this.vodPlayback.handleExternalFallbackRequest(request); + } + private async prepareDetail( item: UnifiedCollectionItem | null ): Promise { @@ -177,6 +258,7 @@ export class StalkerCollectionDetailComponent { if (!item) { this.clearLocalDetailState(); + this.closeInlinePlayer(); return; } @@ -203,7 +285,9 @@ export class StalkerCollectionDetailComponent { ); this.detailCategoryOverride.set(detailMode.category); - this.stalkerStore.setSelectedContentType(detailMode.selectedContentType); + this.stalkerStore.setSelectedContentType( + detailMode.selectedContentType + ); this.stalkerStore.setSelectedCategory( this.resolveSelectedCategory(item, stalkerItem, detailMode) ); @@ -222,12 +306,17 @@ export class StalkerCollectionDetailComponent { this.itemDetails.set(detailViewState.itemDetails); this.vodDetailsItem.set(detailViewState.vodDetailsItem); this.syncSelectedVodFavorite(); + void this.loadSelectedVodPosition( + playlist._id, + Number(detailViewState.itemDetails?.id) + ); return; } const cleared = clearStalkerDetailViewState(); this.vodDetailsItem.set(cleared.vodDetailsItem); this.isSelectedVodFavorite.set(false); + this.selectedVodPosition.set(null); } private captureStoreState(): StalkerCollectionStateSnapshot { @@ -273,11 +362,8 @@ export class StalkerCollectionDetailComponent { item: UnifiedCollectionItem, stalkerItem: StalkerPortalItem ): StalkerCollectionDetailMode { - const hasEmbeddedSeries = Array.isArray( - (stalkerItem as { series?: unknown[] }).series - ) - ? (stalkerItem as { series?: unknown[] }).series!.length > 0 - : false; + const series = (stalkerItem as { series?: unknown[] }).series; + const hasEmbeddedSeries = Array.isArray(series) && series.length > 0; const isVodSeries = isStalkerSeriesFlag( (stalkerItem as { is_series?: unknown }).is_series ); @@ -314,7 +400,9 @@ export class StalkerCollectionDetailComponent { return 'vod'; } - return categoryId ?? toStalkerCategoryId(detailMode.selectedContentType); + return ( + categoryId ?? toStalkerCategoryId(detailMode.selectedContentType) + ); } private syncSelectedVodFavorite(): void { @@ -332,5 +420,33 @@ export class StalkerCollectionDetailComponent { this.vodDetailsItem.set(cleared.vodDetailsItem); this.detailCategoryOverride.set(null); this.isSelectedVodFavorite.set(false); + this.selectedVodPosition.set(null); + } + + private async startStalkerVodPlayback( + cmd?: string, + title?: string, + thumbnail?: string, + startTime?: number + ): Promise { + await this.vodPlayback.startVodPlayback(() => + startTime === undefined + ? this.stalkerStore.resolveVodPlayback(cmd, title, thumbnail) + : this.stalkerStore.resolveVodPlayback( + cmd, + title, + thumbnail, + undefined, + undefined, + startTime + ) + ); + } + + private async loadSelectedVodPosition( + playlistId: string, + vodId: number + ): Promise { + await this.vodPlayback.loadSelectedVodPosition(playlistId, vodId); } } diff --git a/libs/portal/stalker/feature/src/lib/stalker-favorites-button/stalker-favorites-button.component.scss b/libs/portal/stalker/feature/src/lib/stalker-favorites-button/stalker-favorites-button.component.scss index 679630b10..8a45e4141 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-favorites-button/stalker-favorites-button.component.scss +++ b/libs/portal/stalker/feature/src/lib/stalker-favorites-button/stalker-favorites-button.component.scss @@ -2,13 +2,14 @@ display: inline-flex; align-items: center; gap: 8px; - padding: 12px 20px; - font-size: 0.9375rem; - font-weight: 500; + min-height: 64px; + padding: 0 24px; + font-size: 1rem; + font-weight: 600; background: rgba(109, 109, 110, 0.7); color: #fff; border: none; - border-radius: 4px; + border-radius: 16px; cursor: pointer; transition: all 0.2s ease; @@ -25,8 +26,8 @@ } mat-icon { - font-size: 20px; - width: 20px; - height: 20px; + font-size: 24px; + width: 24px; + height: 24px; } } diff --git a/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.html b/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.html index c69bb55aa..fa933a512 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.html @@ -13,10 +13,19 @@ } } @else if (categoryId() === 'series') { diff --git a/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.ts b/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.ts index c7a18178e..51fde5c2b 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.ts @@ -1,14 +1,19 @@ import { ChangeDetectionStrategy, Component, - inject, input, output, } from '@angular/core'; -import { PORTAL_EXTERNAL_PLAYBACK } from '@iptvnator/portal/shared/util'; import { StalkerSelectedVodItem } from '@iptvnator/portal/stalker/data-access'; -import { VodDetailsComponent } from '@iptvnator/ui/playback'; -import { VodDetailsItem } from 'shared-interfaces'; +import { + type PlaybackFallbackRequest, + VodDetailsComponent, +} from '@iptvnator/ui/playback'; +import { + ExternalPlayerSession, + ResolvedPortalPlayback, + VodDetailsItem, +} from 'shared-interfaces'; import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component'; @Component({ @@ -27,19 +32,33 @@ import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-serie ], }) export class StalkerInlineDetailComponent { - readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK); readonly categoryId = input<'vod' | 'series' | null>(null); readonly seriesItem = input(null); readonly isSeries = input(false); readonly vodDetailsItem = input(null); readonly isFavorite = input(false); + readonly playbackPosition = input(null); + readonly inlinePlayback = input(null); + readonly externalPlayback = input(null); readonly backClicked = output(); readonly playClicked = output(); + readonly resumeClicked = output<{ + item: VodDetailsItem; + positionSeconds: number; + }>(); readonly favoriteToggled = output<{ item: VodDetailsItem; isFavorite: boolean; }>(); + readonly inlineTimeUpdated = output<{ + currentTime: number; + duration: number; + }>(); + readonly inlinePlaybackClosed = output(); + readonly streamUrlCopied = output(); + readonly inlineExternalFallbackRequested = + output(); onBackClicked() { this.backClicked.emit(); @@ -49,7 +68,30 @@ export class StalkerInlineDetailComponent { this.playClicked.emit(item); } + onResumeClicked(event: { + item: VodDetailsItem; + positionSeconds: number; + }) { + this.resumeClicked.emit(event); + } + onFavoriteToggled(event: { item: VodDetailsItem; isFavorite: boolean }) { this.favoriteToggled.emit(event); } + + onInlineTimeUpdated(event: { currentTime: number; duration: number }) { + this.inlineTimeUpdated.emit(event); + } + + onInlinePlaybackClosed() { + this.inlinePlaybackClosed.emit(); + } + + onStreamUrlCopied() { + this.streamUrlCopied.emit(); + } + + onInlineExternalFallbackRequested(request: PlaybackFallbackRequest) { + this.inlineExternalFallbackRequested.emit(request); + } } diff --git a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.html b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.html index 778dde0a9..985dbfb51 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.html @@ -15,7 +15,15 @@ [isSeries]="inlineDetail().isSeries" [vodDetailsItem]="inlineDetail().vodDetailsItem" [isFavorite]="isSelectedVodFavorite()" + [playbackPosition]="selectedVodPlaybackPosition()" + [inlinePlayback]="inlinePlayback()" + [externalPlayback]="externalPlayback.activeSession()" (playClicked)="onVodPlay($event)" + (resumeClicked)="onVodResume($event)" + (inlineTimeUpdated)="handleInlineTimeUpdate($event)" + (inlinePlaybackClosed)="closeInlinePlayer()" + (streamUrlCopied)="showCopyNotification()" + (inlineExternalFallbackRequested)="handleExternalFallbackRequest($event)" (favoriteToggled)="onVodFavoriteToggled($event)" (backClicked)="onVodBack()" /> diff --git a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts index a3ec875f7..d2b473625 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts @@ -9,16 +9,20 @@ import { } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatCheckboxModule } from '@angular/material/checkbox'; +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 { StalkerSessionService } from '@iptvnator/portal/stalker/data-access'; import { DataService, PlaylistsService } from 'services'; import { + PlaybackPositionData, Playlist, + ResolvedPortalPlayback, STALKER_REQUEST, StalkerPortalActions, VodDetailsItem, } from 'shared-interfaces'; +import type { PlaybackFallbackRequest } from '@iptvnator/ui/playback'; import { ContentCardComponent } from '@iptvnator/portal/shared/ui'; import { SearchLayoutComponent } from '@iptvnator/portal/shared/ui'; import { StalkerInlineDetailComponent } from '../stalker-inline-detail/stalker-inline-detail.component'; @@ -27,6 +31,9 @@ import { StalkerStore } from '@iptvnator/portal/stalker/data-access'; import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; import { isWorkspaceLayoutRoute, + PORTAL_EXTERNAL_PLAYBACK, + PORTAL_PLAYBACK_POSITIONS, + PORTAL_PLAYER, queryParamSignal, } from '@iptvnator/portal/shared/util'; import { createLogger } from '@iptvnator/portal/shared/util'; @@ -45,6 +52,7 @@ import { isStalkerSeriesFlag, toggleStalkerVodFavorite, } from '@iptvnator/portal/stalker/data-access'; +import { StalkerVodPlaybackController } from '../stalker-vod-playback-controller'; interface StalkerFilter { key: string; @@ -79,8 +87,13 @@ export class StalkerSearchComponent { private readonly dataService = inject(DataService); private readonly playlistContext = inject(PlaylistContextFacade); private readonly playlistService = inject(PlaylistsService); + readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK); + private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS); + private readonly portalPlayer = inject(PORTAL_PLAYER); private readonly stalkerStore = inject(StalkerStore); private readonly stalkerSession = inject(StalkerSessionService); + private readonly snackBar = inject(MatSnackBar); + private readonly translateService = inject(TranslateService); private readonly logger = createLogger('StalkerSearch'); readonly filters = signal({ @@ -119,6 +132,21 @@ export class StalkerSearchComponent { itemDetails: StalkerSelectedVodItem | null = null; vodDetailsItem: VodDetailsItem | null = null; + readonly inlinePlayback = signal(null); + readonly selectedVodPosition = signal(null); + readonly selectedVodPlaybackPosition = computed( + () => this.selectedVodPosition()?.positionSeconds ?? null + ); + private readonly vodPlayback = new StalkerVodPlaybackController({ + inlinePlayback: this.inlinePlayback, + selectedVodPosition: this.selectedVodPosition, + playbackPositions: this.playbackPositions, + portalPlayer: this.portalPlayer, + snackBar: this.snackBar, + translateService: this.translateService, + logger: this.logger, + playbackErrorLogMessage: 'Failed to start search VOD playback', + }); readonly portalFavorites = createPortalFavoritesResource( this.playlistService, @@ -211,10 +239,6 @@ export class StalkerSearchComponent { return this.searchResultsResource.value()?.length ?? 0; } - createLinkToPlayVodItv(cmd?: string, title?: string, thumbnail?: string) { - this.stalkerStore.createLinkToPlayVod(cmd, title, thumbnail); - } - updateSearchTerm(term: string) { this.searchTerm.set(term); } @@ -234,6 +258,7 @@ export class StalkerSearchComponent { } selectItem(item: StalkerVodSource) { + this.closeInlinePlayer(); const hasEmbeddedSeries = item.series?.length > 0; const needsSeriesFetch = this.selectedFilterType() === 'vod' && @@ -255,10 +280,15 @@ export class StalkerSearchComponent { this.itemDetails = detailViewState.itemDetails; this.vodDetailsItem = detailViewState.vodDetailsItem; this.syncSelectedVodFavorite(); + void this.loadSelectedVodPosition( + this.currentPlaylist()?._id ?? '', + Number(detailViewState.itemDetails?.id) + ); } else { const cleared = clearStalkerDetailViewState(); this.vodDetailsItem = cleared.vodDetailsItem; this.isSelectedVodFavorite.set(false); + this.selectedVodPosition.set(null); } break; case 'series': @@ -271,7 +301,7 @@ export class StalkerSearchComponent { onVodPlay(item: VodDetailsItem): void { if (item.type === 'stalker') { - this.createLinkToPlayVodItv( + void this.startStalkerVodPlayback( item.cmd, item.data.info?.name, item.data.info?.movie_image @@ -279,6 +309,20 @@ export class StalkerSearchComponent { } } + onVodResume(event: { + item: VodDetailsItem; + positionSeconds: number; + }): void { + if (event.item.type === 'stalker') { + void this.startStalkerVodPlayback( + event.item.cmd, + event.item.data.info?.name, + event.item.data.info?.movie_image, + event.positionSeconds + ); + } + } + onVodFavoriteToggled(event: { item: VodDetailsItem; isFavorite: boolean; @@ -299,6 +343,27 @@ export class StalkerSearchComponent { this.itemDetails = cleared.itemDetails; this.vodDetailsItem = cleared.vodDetailsItem; this.isSelectedVodFavorite.set(false); + this.selectedVodPosition.set(null); + this.closeInlinePlayer(); + } + + handleInlineTimeUpdate(event: { + currentTime: number; + duration: number; + }): void { + this.vodPlayback.handleInlineTimeUpdate(event); + } + + closeInlinePlayer(): void { + this.vodPlayback.closeInlinePlayer(); + } + + showCopyNotification(): void { + this.vodPlayback.showCopyNotification(); + } + + handleExternalFallbackRequest(request: PlaybackFallbackRequest): void { + this.vodPlayback.handleExternalFallbackRequest(request); } removeFromFavorites(favoriteId: string, onDone?: () => void) { @@ -361,4 +426,31 @@ export class StalkerSearchComponent { return relativePath; } } + + private async startStalkerVodPlayback( + cmd?: string, + title?: string, + thumbnail?: string, + startTime?: number + ): Promise { + await this.vodPlayback.startVodPlayback(() => + startTime === undefined + ? this.stalkerStore.resolveVodPlayback(cmd, title, thumbnail) + : this.stalkerStore.resolveVodPlayback( + cmd, + title, + thumbnail, + undefined, + undefined, + startTime + ) + ); + } + + private async loadSelectedVodPosition( + playlistId: string, + vodId: number + ): Promise { + await this.vodPlayback.loadSelectedVodPosition(playlistId, vodId); + } } diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html index 70ef5b16f..7bcb88687 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html @@ -51,6 +51,30 @@ + @if (quickStartAction(); as action) { + + } ', +}) +class StubContentHeroComponent { + readonly title = input(undefined); + readonly description = input(undefined); + readonly posterUrl = input(undefined); + readonly backClicked = output(); +} + +@Component({ + selector: 'app-season-container', + standalone: true, + template: '
', +}) +class StubSeasonContainerComponent { + readonly seasons = input(null); + readonly seriesId = input(null); + readonly playlistId = input(''); + readonly seriesTitle = input(undefined); + readonly playbackPositions = input(null); + readonly openingEpisodeId = input(null); + readonly activeEpisodeId = input(null); + readonly isLoading = input(false); + readonly seasonSelected = output(); + readonly episodeClicked = output(); + readonly episodeDownloadRequested = output(); + readonly playbackToggleRequested = output(); + selectedSeason: string | undefined; +} + +@Component({ + selector: 'app-portal-inline-player', + standalone: true, + template: '', +}) +class StubPortalInlinePlayerComponent { + readonly playback = input(null); + readonly timeUpdate = output(); + readonly closed = output(); + readonly streamUrlCopied = output(); + readonly externalFallbackRequested = output(); +} + +@Component({ + selector: 'app-favorites-button', + standalone: true, + template: '', +}) +class StubFavoritesButtonComponent { + readonly itemId = input(undefined); + readonly item = input(null); +} + +describe('StalkerSeriesViewComponent', () => { + let fixture: ComponentFixture; + const selectedContentType = signal<'series' | 'vod'>('series'); + const selectedItem = signal(null); + const serialSeasonsResource = signal([]); + const vodSeriesSeasonsResource = signal([]); + const fetchVodSeriesEpisodes = jest.fn(); + const resolveVodPlayback = jest.fn(); + const getSeriesPlaybackPositions = jest.fn().mockResolvedValue([]); + const openResolvedPlayback = jest.fn(); + + beforeEach(async () => { + selectedContentType.set('series'); + selectedItem.set({ + id: '30001', + cmd: '/media/file_30001.mpg', + info: { + name: 'Regular Series', + description: 'Series description', + movie_image: 'poster.jpg', + }, + }); + serialSeasonsResource.set([ + { + id: 'season-1', + name: 'Season 1', + cmd: '/media/file_30001.mpg', + series: [1, 2], + }, + ]); + vodSeriesSeasonsResource.set([]); + fetchVodSeriesEpisodes.mockReset(); + resolveVodPlayback.mockReset(); + resolveVodPlayback.mockResolvedValue({ + streamUrl: 'http://stalker.example/episode.mpg', + title: 'Regular Series', + thumbnail: 'poster.jpg', + }); + getSeriesPlaybackPositions.mockClear(); + getSeriesPlaybackPositions.mockResolvedValue([]); + openResolvedPlayback.mockClear(); + + await TestBed.configureTestingModule({ + imports: [StalkerSeriesViewComponent], + providers: [ + { + provide: StalkerStore, + useValue: { + selectedItem, + selectedContentType, + currentPlaylist: signal({ _id: 'stalker-1' }), + getSerialSeasonsResource: () => + serialSeasonsResource(), + getVodSeriesSeasonsResource: () => + vodSeriesSeasonsResource(), + isVodSeriesSeasonsLoading: signal(false), + isSerialSeasonsLoading: signal(false), + fetchVodSeriesEpisodes, + resolveVodPlayback, + fetchLinkToPlay: jest.fn(), + clearSelectedItem: jest.fn(), + }, + }, + { + provide: PORTAL_EXTERNAL_PLAYBACK, + useValue: { + activeSession: signal(null), + }, + }, + { + provide: PORTAL_PLAYBACK_POSITIONS, + useValue: { + getSeriesPlaybackPositions, + savePlaybackPosition: jest.fn(), + clearPlaybackPosition: jest.fn(), + }, + }, + { + provide: PORTAL_PLAYER, + useValue: { + isEmbeddedPlayer: jest.fn().mockReturnValue(false), + openResolvedPlayback, + }, + }, + { + provide: Router, + useValue: { + navigateByUrl: jest.fn(), + }, + }, + { + provide: DownloadsService, + useValue: { + startDownload: jest.fn(), + }, + }, + { + provide: MatSnackBar, + useValue: { + open: jest.fn(), + }, + }, + { + provide: TranslateService, + useValue: { + instant: (key: string) => key, + get: (key: string) => of(key), + stream: (key: string) => of(key), + onLangChange: of(null), + onTranslationChange: of(null), + onDefaultLangChange: of(null), + }, + }, + ], + }) + .overrideComponent(StalkerSeriesViewComponent, { + remove: { + imports: [ + ContentHeroComponent, + FavoritesButtonComponent, + PortalInlinePlayerComponent, + SeasonContainerComponent, + TranslatePipe, + ], + }, + add: { + imports: [ + StubContentHeroComponent, + StubFavoritesButtonComponent, + StubPortalInlinePlayerComponent, + StubSeasonContainerComponent, + MockPipe( + TranslatePipe, + (value: string | null | undefined) => value ?? '' + ), + ], + }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(StalkerSeriesViewComponent); + }); + + afterEach(() => { + fixture?.destroy(); + }); + + it('renders quick start for regular series and starts the first episode', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + const quickStartButton: HTMLButtonElement | null = + fixture.nativeElement.querySelector( + '[data-testid="series-quick-start"]' + ); + + expect(quickStartButton).not.toBeNull(); + expect(quickStartButton?.textContent).toContain( + 'XTREAM.PLAY_FIRST_EPISODE' + ); + expect(quickStartButton?.textContent).toContain( + 'S01E01 · Episode 1' + ); + + quickStartButton?.click(); + await fixture.whenStable(); + + expect(resolveVodPlayback).toHaveBeenCalledWith( + '/media/file_30001.mpg', + 'Regular Series', + 'poster.jpg', + 1, + expect.any(Number), + undefined + ); + expect(openResolvedPlayback).toHaveBeenCalledWith( + expect.objectContaining({ + streamUrl: 'http://stalker.example/episode.mpg', + }), + true + ); + }); + + it('loads the first VOD-series season and starts its first episode from quick start', async () => { + selectedContentType.set('vod'); + selectedItem.set({ + id: '50001', + is_series: true, + info: { + name: 'VOD Flagged Series', + description: 'Lazy seasons', + movie_image: 'vod-series.jpg', + }, + }); + serialSeasonsResource.set([]); + vodSeriesSeasonsResource.set([ + { + id: 'season-1', + video_id: '50001', + season_number: '1', + name: 'Season 1', + }, + ]); + fetchVodSeriesEpisodes.mockResolvedValue([ + { + id: 'episode-1', + series_number: 1, + name: 'Pilot', + }, + ]); + + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + const quickStartButton: HTMLButtonElement | null = + fixture.nativeElement.querySelector( + '[data-testid="series-quick-start"]' + ); + + expect(quickStartButton).not.toBeNull(); + expect(quickStartButton?.textContent).toContain('S01E01'); + + quickStartButton?.click(); + await fixture.whenStable(); + + expect(fetchVodSeriesEpisodes).toHaveBeenCalledWith( + '50001', + 'season-1' + ); + expect(resolveVodPlayback).toHaveBeenCalledWith( + '/media/file_episode-1.mpg', + 'VOD Flagged Series - Pilot', + 'vod-series.jpg', + 1, + expect.any(Number), + undefined + ); + }); + + it('loads the next unloaded VOD-series season after the loaded season is watched', async () => { + selectedContentType.set('vod'); + selectedItem.set({ + id: '50001', + is_series: true, + info: { + name: 'VOD Flagged Series', + description: 'Lazy seasons', + movie_image: 'vod-series.jpg', + }, + }); + serialSeasonsResource.set([]); + vodSeriesSeasonsResource.set([ + { + id: 'season-1', + video_id: '50001', + season_number: '1', + name: 'Season 1', + }, + { + id: 'season-2', + video_id: '50001', + season_number: '2', + name: 'Season 2', + }, + ]); + fetchVodSeriesEpisodes.mockResolvedValue([ + { + id: 'episode-2', + series_number: 1, + name: 'Second Season Pilot', + }, + ]); + + fixture.detectChanges(); + await fixture.whenStable(); + + fixture.componentInstance.vodSeriesSeasons.set([ + { + id: 'season-1', + video_id: '50001', + season_number: '1', + name: 'Season 1', + episodes: [ + { + id: 'episode-1', + series_number: 1, + name: 'Pilot', + }, + ], + isLoading: false, + isExpanded: false, + }, + { + id: 'season-2', + video_id: '50001', + season_number: '2', + name: 'Season 2', + episodes: [], + isLoading: false, + isExpanded: false, + }, + ]); + const watchedEpisode = fixture.componentInstance.mappedSeasons()['1'][0]; + const watchedPosition: PlaybackPositionData = { + contentXtreamId: Number(watchedEpisode.id), + contentType: 'episode', + seriesXtreamId: 50001, + positionSeconds: 95, + durationSeconds: 100, + }; + fixture.componentInstance.episodePlaybackPositions.set( + new Map([[Number(watchedEpisode.id), watchedPosition]]) + ); + fixture.detectChanges(); + + const quickStartButton: HTMLButtonElement | null = + fixture.nativeElement.querySelector( + '[data-testid="series-quick-start"]' + ); + + expect(quickStartButton).not.toBeNull(); + expect(quickStartButton?.textContent).toContain( + 'XTREAM.PLAY_NEXT_EPISODE' + ); + expect(quickStartButton?.textContent).toContain('S02E01'); + + quickStartButton?.click(); + await fixture.whenStable(); + + expect(fetchVodSeriesEpisodes).toHaveBeenCalledWith( + '50001', + 'season-2' + ); + expect(resolveVodPlayback).toHaveBeenCalledWith( + '/media/file_episode-2.mpg', + 'VOD Flagged Series - Second Season Pilot', + 'vod-series.jpg', + 1, + expect.any(Number), + undefined + ); + }); +}); diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts index b41ec3918..ca7ed2d88 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts @@ -8,6 +8,7 @@ import { output, signal, } from '@angular/core'; +import { MatIcon } from '@angular/material/icon'; import { MatSnackBar } from '@angular/material/snack-bar'; import { Router } from '@angular/router'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; @@ -26,8 +27,13 @@ import { PORTAL_EXTERNAL_PLAYBACK, PORTAL_PLAYBACK_POSITIONS, PORTAL_PLAYER, + SERIES_QUICK_START_ACTION_KIND, + SeriesQuickStartAction, createLogger, + getPositiveInteger, + getSeriesQuickStartAction, getStalkerReturnToState, + padEpisodePart, } from '@iptvnator/portal/shared/util'; import { getVodSeriesSeasonKey, @@ -50,6 +56,15 @@ import { } from '@iptvnator/ui/playback'; import { DownloadsService } from 'services'; +interface StalkerQuickStartButton { + labelKey: string; + episodeLabel: string | null; + icon: string; + disabled: boolean; + action: SeriesQuickStartAction | null; + lazySeason: VodSeriesSeasonVm | null; +} + /** * Component for displaying series/episodes for Stalker portal content. * Supports three modes: @@ -67,6 +82,7 @@ import { DownloadsService } from 'services'; PortalInlinePlayerComponent, TranslatePipe, SeasonContainerComponent, + MatIcon, ], }) export class StalkerSeriesViewComponent implements OnDestroy { @@ -142,14 +158,11 @@ export class StalkerSeriesViewComponent implements OnDestroy { const playlist = this.stalkerStore.currentPlaylist(); if (item && playlist?._id) { const normalizedSeriesId = this.toSeriesId(item.id); - this.logger.debug( - 'Loading positions for series', - { - id: item.id, - seriesId: normalizedSeriesId, - isSeries: item.is_series, - } - ); + this.logger.debug('Loading positions for series', { + id: item.id, + seriesId: normalizedSeriesId, + isSeries: item.is_series, + }); if (!isNaN(normalizedSeriesId)) { void this.loadSeriesPositions( playlist._id, @@ -258,6 +271,41 @@ export class StalkerSeriesViewComponent implements OnDestroy { } ); + readonly quickStartAction = computed(() => { + const action = getSeriesQuickStartAction({ + seasons: this.mappedSeasons(), + playbackPositions: this.episodePlaybackPositions(), + }); + + const lazySeason = this.getQuickStartLazyVodSeriesSeason(action); + if (lazySeason) { + return { + labelKey: + action?.kind === SERIES_QUICK_START_ACTION_KIND.Completed + ? 'XTREAM.PLAY_NEXT_EPISODE' + : 'XTREAM.PLAY_FIRST_EPISODE', + episodeLabel: this.getLazyVodSeriesEpisodeLabel(lazySeason), + icon: 'play_arrow', + disabled: lazySeason.isLoading, + action: null, + lazySeason, + }; + } + + if (!action) { + return null; + } + + return { + labelKey: action.labelKey, + episodeLabel: action.episodeLabel, + icon: action.icon, + disabled: action.disabled, + action, + lazySeason: null, + }; + }); + /** * Handles season selection from the container. * For VOD Series, triggers lazy loading of episodes. @@ -357,6 +405,22 @@ export class StalkerSeriesViewComponent implements OnDestroy { } } + async playQuickStartEpisode(): Promise { + const quickStart = this.quickStartAction(); + if (!quickStart || quickStart.disabled) { + return; + } + + if (quickStart.action) { + this.onEpisodeClicked(quickStart.action.episode); + return; + } + + if (quickStart.lazySeason) { + await this.loadAndPlayVodSeriesSeason(quickStart.lazySeason); + } + } + /** * Play episode - handles regular series and vclub mode */ @@ -513,8 +577,7 @@ export class StalkerSeriesViewComponent implements OnDestroy { } catch (error) { this.logger.error('Failed to start inline series playback', error); const errorMessage = - error instanceof Error && - error.message === 'nothing_to_play' + error instanceof Error && error.message === 'nothing_to_play' ? this.translateService.instant( 'PORTALS.CONTENT_NOT_AVAILABLE' ) @@ -589,7 +652,8 @@ export class StalkerSeriesViewComponent implements OnDestroy { const posterUrl = episodeInfo?.movie_image; const seasonNum = Number(episode.season || 1); const episodeNum = episode.episode_num || 1; - const seriesTitle = item.info?.name || this.displayItem()?.info?.name || 'Series'; + const seriesTitle = + item.info?.name || this.displayItem()?.info?.name || 'Series'; const episodeTitle = `${seriesTitle} - S${String(seasonNum).padStart( 2, '0' @@ -621,10 +685,11 @@ export class StalkerSeriesViewComponent implements OnDestroy { playlistId: string, seriesXtreamId: number ): Promise { - const positions = await this.playbackPositions.getSeriesPlaybackPositions( - playlistId, - seriesXtreamId - ); + const positions = + await this.playbackPositions.getSeriesPlaybackPositions( + playlistId, + seriesXtreamId + ); const positionsMap = new Map(); positions.forEach((position) => { positionsMap.set(position.contentXtreamId, position); @@ -632,7 +697,86 @@ export class StalkerSeriesViewComponent implements OnDestroy { this.episodePlaybackPositions.set(positionsMap); } - private updateEpisodePlaybackPosition(position: PlaybackPositionData): void { + private async loadAndPlayVodSeriesSeason( + season: VodSeriesSeasonVm + ): Promise { + if (season.episodes.length === 0) { + await this.loadEpisodesForSeason(season); + } + + const loadedSeason = + this.vodSeriesSeasons().find((item) => item.id === season.id) ?? + season; + const seasonKey = getVodSeriesSeasonKey(loadedSeason); + const episode = this.mappedSeasons()[seasonKey]?.[0]; + + if (episode) { + this.onEpisodeClicked(episode); + } + } + + private getQuickStartLazyVodSeriesSeason( + action: SeriesQuickStartAction | null + ): VodSeriesSeasonVm | null { + if (!this.isVodSeries()) { + return null; + } + + const seasons = this.vodSeriesSeasons(); + if (!action) { + return this.getFirstUnloadedVodSeriesSeason(seasons); + } + + if (action.kind !== SERIES_QUICK_START_ACTION_KIND.Completed) { + return null; + } + + const completedSeasonIndex = this.findVodSeriesSeasonIndexForEpisode( + action.episode, + seasons + ); + if (completedSeasonIndex === -1) { + return null; + } + + return this.getFirstUnloadedVodSeriesSeason( + seasons.slice(completedSeasonIndex + 1) + ); + } + + private getFirstUnloadedVodSeriesSeason( + seasons: ReadonlyArray + ): VodSeriesSeasonVm | null { + return seasons.find((season) => season.episodes.length === 0) ?? null; + } + + private findVodSeriesSeasonIndexForEpisode( + episode: XtreamSerieEpisode, + seasons: ReadonlyArray + ): number { + const mappedSeasons = this.mappedSeasons(); + + return seasons.findIndex((season) => + (mappedSeasons[getVodSeriesSeasonKey(season)] ?? []).some( + (mappedEpisode) => mappedEpisode.id === episode.id + ) + ); + } + + private getLazyVodSeriesEpisodeLabel(season: VodSeriesSeasonVm): string { + const seasonIndex = this.vodSeriesSeasons().findIndex( + (item) => item.id === season.id + ); + const seasonNumber = + getPositiveInteger(Number(season.season_number)) ?? + (seasonIndex >= 0 ? seasonIndex + 1 : 1); + + return `S${padEpisodePart(seasonNumber)}E01`; + } + + private updateEpisodePlaybackPosition( + position: PlaybackPositionData + ): void { const updated = new Map(this.episodePlaybackPositions()); updated.set(position.contentXtreamId, position); this.episodePlaybackPositions.set(updated); diff --git a/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.spec.ts new file mode 100644 index 000000000..c9b2a1b13 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.spec.ts @@ -0,0 +1,118 @@ +import { signal } from '@angular/core'; +import type { MatSnackBar } from '@angular/material/snack-bar'; +import type { TranslateService } from '@ngx-translate/core'; +import type { + Logger, + PortalPlaybackPositions, + PortalPlayer, +} from '@iptvnator/portal/shared/util'; +import type { PlaybackPositionData } from 'shared-interfaces'; +import { StalkerVodPlaybackController } from './stalker-vod-playback-controller'; + +interface Deferred { + promise: Promise; + resolve: (value: T) => void; +} + +function createDeferred(): Deferred { + let resolve!: (value: T) => void; + const promise = new Promise((resolver) => { + resolve = resolver; + }); + + return { + promise, + resolve, + }; +} + +function createPosition( + contentXtreamId: number, + positionSeconds: number +): PlaybackPositionData { + return { + contentXtreamId, + contentType: 'vod', + positionSeconds, + durationSeconds: 100, + }; +} + +function createController() { + const inlinePlayback = signal(null); + const selectedVodPosition = signal(null); + const playbackPositions = { + savePlaybackPosition: jest.fn(), + getPlaybackPosition: jest.fn(), + getSeriesPlaybackPositions: jest.fn(), + getAllPlaybackPositions: jest.fn(), + clearPlaybackPosition: jest.fn(), + } as unknown as jest.Mocked; + const portalPlayer = { + isEmbeddedPlayer: jest.fn(() => true), + openPlayer: jest.fn(), + openResolvedPlayback: jest.fn(), + openExternalPlayback: jest.fn(), + } as unknown as jest.Mocked; + const snackBar = { + open: jest.fn(), + } as unknown as MatSnackBar; + const translateService = { + instant: jest.fn((key: string) => key), + } as unknown as TranslateService; + const logger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + } as unknown as Logger; + + const controller = new StalkerVodPlaybackController({ + inlinePlayback, + selectedVodPosition, + playbackPositions, + portalPlayer, + snackBar, + translateService, + logger, + playbackErrorLogMessage: 'Playback failed', + }); + + return { + controller, + playbackPositions, + selectedVodPosition, + }; +} + +describe('StalkerVodPlaybackController', () => { + it('ignores stale VOD position loads that resolve after a newer selection', async () => { + const { controller, playbackPositions, selectedVodPosition } = + createController(); + const olderLoad = createDeferred(); + const newerLoad = createDeferred(); + + playbackPositions.getPlaybackPosition.mockImplementation( + (_playlistId, vodId) => + vodId === 101 ? olderLoad.promise : newerLoad.promise + ); + + const olderLoadPromise = controller.loadSelectedVodPosition( + 'playlist-1', + 101 + ); + const newerLoadPromise = controller.loadSelectedVodPosition( + 'playlist-1', + 202 + ); + + newerLoad.resolve(createPosition(202, 20)); + await newerLoadPromise; + expect(selectedVodPosition()?.contentXtreamId).toBe(202); + + olderLoad.resolve(createPosition(101, 10)); + await olderLoadPromise; + expect(selectedVodPosition()?.contentXtreamId).toBe(202); + expect(selectedVodPosition()?.positionSeconds).toBe(20); + }); +}); diff --git a/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.ts b/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.ts new file mode 100644 index 000000000..87cd75419 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.ts @@ -0,0 +1,138 @@ +import type { WritableSignal } from '@angular/core'; +import type { MatSnackBar } from '@angular/material/snack-bar'; +import type { TranslateService } from '@ngx-translate/core'; +import type { + Logger, + PortalPlaybackPositions, + PortalPlayer, +} from '@iptvnator/portal/shared/util'; +import type { PlaybackFallbackRequest } from '@iptvnator/ui/playback'; +import { + PlaybackPositionData, + ResolvedPortalPlayback, +} from 'shared-interfaces'; + +interface StalkerVodPlaybackControllerConfig { + inlinePlayback: WritableSignal; + selectedVodPosition: WritableSignal; + playbackPositions: PortalPlaybackPositions; + portalPlayer: PortalPlayer; + snackBar: MatSnackBar; + translateService: TranslateService; + logger: Logger; + playbackErrorLogMessage: string; +} + +export class StalkerVodPlaybackController { + private lastInlineSaveTime = 0; + private loadSelectedVodPositionRequestId = 0; + + constructor(private readonly config: StalkerVodPlaybackControllerConfig) {} + + async startVodPlayback( + resolvePlayback: () => Promise + ): Promise { + try { + const playback = await resolvePlayback(); + + this.lastInlineSaveTime = 0; + if (this.config.portalPlayer.isEmbeddedPlayer()) { + this.config.inlinePlayback.set(playback); + return; + } + + this.closeInlinePlayer(); + void this.config.portalPlayer.openResolvedPlayback(playback, true); + } catch (error) { + this.config.logger.error( + this.config.playbackErrorLogMessage, + error + ); + const errorMessage = + error instanceof Error && error.message === 'nothing_to_play' + ? this.config.translateService.instant( + 'PORTALS.CONTENT_NOT_AVAILABLE' + ) + : this.config.translateService.instant( + 'PORTALS.PLAYBACK_ERROR' + ); + this.config.snackBar.open(errorMessage, null, { + duration: 3000, + }); + } + } + + async loadSelectedVodPosition( + playlistId: string, + vodId: number + ): Promise { + const requestId = ++this.loadSelectedVodPositionRequestId; + + if (!playlistId || !Number.isFinite(vodId)) { + this.config.selectedVodPosition.set(null); + return; + } + + const position = + await this.config.playbackPositions.getPlaybackPosition( + playlistId, + vodId, + 'vod' + ); + if (requestId !== this.loadSelectedVodPositionRequestId) { + return; + } + + this.config.selectedVodPosition.set(position ?? null); + } + + handleInlineTimeUpdate(event: { + currentTime: number; + duration: number; + }): void { + const playback = this.config.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.config.playbackPositions.savePlaybackPosition( + playback.contentInfo.playlistId, + position + ); + this.config.selectedVodPosition.set(position); + } + + closeInlinePlayer(): void { + this.config.inlinePlayback.set(null); + this.lastInlineSaveTime = 0; + } + + showCopyNotification(): void { + this.config.snackBar.open( + this.config.translateService.instant('PORTALS.STREAM_URL_COPIED'), + null, + { + duration: 2000, + } + ); + } + + handleExternalFallbackRequest(request: PlaybackFallbackRequest): void { + void this.config.portalPlayer.openExternalPlayback( + request.playback, + request.player + ); + } +} diff --git a/libs/portal/stalker/feature/src/lib/styles/detail-view.scss b/libs/portal/stalker/feature/src/lib/styles/detail-view.scss index 23d60ef58..0345bd764 100644 --- a/libs/portal/stalker/feature/src/lib/styles/detail-view.scss +++ b/libs/portal/stalker/feature/src/lib/styles/detail-view.scss @@ -129,11 +129,11 @@ display: inline-flex; align-items: center; gap: 12px; - padding: 12px 26px; - min-height: 54px; + padding: 10px 24px 10px 18px; + min-height: 64px; font-size: 1rem; font-weight: 600; - letter-spacing: 0.01em; + letter-spacing: 0; background: var(--play-btn-bg); background-size: 140% 140%; background-position: 0% 50%; @@ -221,10 +221,33 @@ box-shadow 220ms ease; } - &__label { + &__copy { display: inline-flex; - align-items: center; + flex-direction: column; + align-items: flex-start; + gap: 2px; min-width: 0; + line-height: 1.1; + text-align: left; + } + + &__label { + display: block; + min-width: 0; + line-height: 1.15; + } + + &__meta { + display: block; + max-width: min(42vw, 360px); + overflow: hidden; + color: var(--play-btn-fg); + font-size: 0.78rem; + font-weight: 500; + line-height: 1.2; + opacity: 0.72; + text-overflow: ellipsis; + white-space: nowrap; } &--state-launching, @@ -339,6 +362,21 @@ } } +.favorite-btn, +.download-btn { + min-height: 64px; + padding: 0 24px; + border-radius: 16px; + font-size: 1rem; + font-weight: 600; + + mat-icon { + font-size: 24px; + width: 24px; + height: 24px; + } +} + .favorite-btn { &--active { background: rgba(229, 9, 20, 0.8); @@ -540,9 +578,14 @@ .download-btn { flex: 1; justify-content: center; + min-height: 58px; padding: 10px 16px; } + .play-btn__meta { + max-width: 100%; + } + .youtube-trailer { margin-top: 16px; diff --git a/libs/portal/xtream/feature/src/index.ts b/libs/portal/xtream/feature/src/index.ts index 27233563f..7de311c3d 100644 --- a/libs/portal/xtream/feature/src/index.ts +++ b/libs/portal/xtream/feature/src/index.ts @@ -3,7 +3,6 @@ export * from './lib/category-management-dialog/category-management-dialog.compo export * from './lib/global-search-results/global-search-results.component'; export * from './lib/live-stream-layout/live-stream-layout.component'; export * from './lib/loading-overlay.component'; -export * from './lib/player-dialog/player-dialog.component'; export * from './lib/portal-channels-list/portal-channels-list.component'; export * from './lib/recently-added/recently-added.component'; export * from './lib/search-results/search-results.component'; diff --git a/libs/portal/xtream/feature/src/lib/player-dialog/player-dialog.component.html b/libs/portal/xtream/feature/src/lib/player-dialog/player-dialog.component.html deleted file mode 100644 index 92910b7b6..000000000 --- a/libs/portal/xtream/feature/src/lib/player-dialog/player-dialog.component.html +++ /dev/null @@ -1,25 +0,0 @@ -

{{ title }}

- - - - - - - diff --git a/libs/portal/xtream/feature/src/lib/player-dialog/player-dialog.component.scss b/libs/portal/xtream/feature/src/lib/player-dialog/player-dialog.component.scss deleted file mode 100644 index a77331c8c..000000000 --- a/libs/portal/xtream/feature/src/lib/player-dialog/player-dialog.component.scss +++ /dev/null @@ -1,16 +0,0 @@ -@use '@angular/material' as mat; - -.content { - overflow: hidden; - padding: 10px !important; -} - -.align-actions { - justify-content: space-between; -} - -mat-dialog-content { - .video-js { - height: 500px !important; - } -} diff --git a/libs/portal/xtream/feature/src/lib/player-dialog/player-dialog.component.ts b/libs/portal/xtream/feature/src/lib/player-dialog/player-dialog.component.ts deleted file mode 100644 index 2bb3eb771..000000000 --- a/libs/portal/xtream/feature/src/lib/player-dialog/player-dialog.component.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { ClipboardModule } from '@angular/cdk/clipboard'; -import { Component, inject, ViewEncapsulation } from '@angular/core'; -import { MatButton } from '@angular/material/button'; -import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; -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 { - PORTAL_PLAYBACK_POSITIONS, - PORTAL_PLAYER, -} from '@iptvnator/portal/shared/util'; -import { PlayerContentInfo, ResolvedPortalPlayback } from 'shared-interfaces'; -import { - type PlaybackFallbackRequest, - WebPlayerViewComponent, -} from 'shared-portals'; - -export interface PlayerDialogData { - streamUrl: string; - title: string; - contentInfo?: PlayerContentInfo; - startTime?: number; - playback?: ResolvedPortalPlayback; -} - -@Component({ - templateUrl: './player-dialog.component.html', - imports: [ - ClipboardModule, - MatButton, - MatDialogModule, - MatIcon, - MatTooltip, - TranslatePipe, - WebPlayerViewComponent, - ], - styleUrl: './player-dialog.component.scss', - encapsulation: ViewEncapsulation.None, -}) -export class PlayerDialogComponent { - readonly data = inject(MAT_DIALOG_DATA); - private snackBar = inject(MatSnackBar); - private translateService = inject(TranslateService); - private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS); - private readonly portalPlayer = inject(PORTAL_PLAYER); - - readonly title: string; - readonly streamUrl: string; - - private lastSaveTime = 0; - - constructor() { - this.streamUrl = this.data.playback?.streamUrl ?? this.data.streamUrl; - this.title = this.data.playback?.title ?? this.data.title; - } - - handleTimeUpdate(event: { currentTime: number; duration: number }) { - if (!this.data.contentInfo) return; - - const now = Date.now(); - // Save every 15 seconds - if (now - this.lastSaveTime > 15000) { - this.lastSaveTime = now; - void this.playbackPositions.savePlaybackPosition( - this.data.contentInfo.playlistId, - { - ...this.data.contentInfo, - positionSeconds: Math.floor(event.currentTime), - durationSeconds: Math.floor(event.duration), - } - ); - } - } - - showCopyNotification() { - this.snackBar.open( - this.translateService.instant('PORTALS.STREAM_URL_COPIED'), - null, - { - duration: 2000, - } - ); - } - - handleExternalFallbackRequest(request: PlaybackFallbackRequest): void { - void this.portalPlayer.openExternalPlayback( - request.playback, - request.player - ); - } -} diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html index 0e64b6ab6..cd8558b1f 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html @@ -46,6 +46,28 @@
+ @if (quickStartAction(); as action) { + + } @if (!isFavorite()) {