mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
fix(playback): use inline player for collection VOD (#928)
* feat(portal): add series quick start CTA Entire-Checkpoint: f957cd9849e0 * fix(playback): use inline player for collection vod * fix(playback): address inline collection review feedback * fix(playback): ignore stale vod position loads
This commit is contained in:
1 parent
ce0e61a92d
commit
6ae1251ad1
55 files changed
+2421
-272
No files matched your search
@@ -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<void> {
|
||||
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<void> {
|
||||
const playButton = page.locator('button.play-btn').first();
|
||||
|
||||
await expect(playButton).toBeVisible({ timeout: 20000 });
|
||||
await playButton.click();
|
||||
}
|
||||
|
||||
async function playFirstSeriesEpisode(page: Page): Promise<void> {
|
||||
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
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
const playButton = page.locator('button.play-btn').first();
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
await backButton.click();
|
||||
}
|
||||
|
||||
async function playCurrentDetail(page: Page): Promise<void> {
|
||||
const playButton = page.locator('button.play-btn').first();
|
||||
|
||||
await expect(playButton).toBeVisible({ timeout: 20000 });
|
||||
await playButton.click();
|
||||
}
|
||||
|
||||
async function expectInlinePlayerWithoutDialog(page: Page): Promise<void> {
|
||||
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<Locator> {
|
||||
const count = await categories.count();
|
||||
let fallback = categories.first();
|
||||
|
||||
@@ -24,8 +24,6 @@ export default {
|
||||
snapshotSerializers: angularEsmPreset.snapshotSerializers,
|
||||
moduleNameMapper: {
|
||||
...(angularEsmPreset.moduleNameMapper ?? {}),
|
||||
'^@iptvnator/portal/xtream/feature$':
|
||||
'<rootDir>/src/test-stubs/xtream-feature.stub.ts',
|
||||
tslib: 'tslib/tslib.es6.js',
|
||||
'^iptv-playlist-parser$':
|
||||
'<rootDir>/src/test-stubs/iptv-playlist-parser.mjs',
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -67,7 +67,6 @@ export class PlayerService {
|
||||
hideExternalInfoDialog = true
|
||||
): Promise<ExternalPlayerSession | void> {
|
||||
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(
|
||||
|
||||
@@ -575,6 +575,10 @@
|
||||
"RESUME": "استئناف",
|
||||
"RESTART": "البدء من جديد",
|
||||
"CONTINUE_WATCHING": "متابعة المشاهدة",
|
||||
"PLAY_FIRST_EPISODE": "تشغيل الحلقة الأولى",
|
||||
"RESUME_EPISODE": "استئناف الحلقة",
|
||||
"PLAY_NEXT_EPISODE": "تشغيل الحلقة التالية",
|
||||
"SERIES_WATCHED": "تمت مشاهدة المسلسل",
|
||||
"WATCHED": "تمت المشاهدة",
|
||||
"IN_PROGRESS": "قيد التقدم",
|
||||
"MARK_WATCHED": "وضع علامة كمشاهد",
|
||||
|
||||
@@ -575,6 +575,10 @@
|
||||
"RESUME": "كمل",
|
||||
"RESTART": "بدا من الأول",
|
||||
"CONTINUE_WATCHING": "كمل المشاهدة",
|
||||
"PLAY_FIRST_EPISODE": "شغل الحلقة الأولى",
|
||||
"RESUME_EPISODE": "كمل الحلقة",
|
||||
"PLAY_NEXT_EPISODE": "شغل الحلقة الجاية",
|
||||
"SERIES_WATCHED": "السلسلة متشافة",
|
||||
"WATCHED": "متشاف",
|
||||
"IN_PROGRESS": "جاري",
|
||||
"MARK_WATCHED": "علم كمتشاف",
|
||||
|
||||
@@ -575,6 +575,10 @@
|
||||
"RESUME": "Працягнуць",
|
||||
"RESTART": "Спачатку",
|
||||
"CONTINUE_WATCHING": "Працягнуць прагляд",
|
||||
"PLAY_FIRST_EPISODE": "Прайграць першы эпізод",
|
||||
"RESUME_EPISODE": "Працягнуць эпізод",
|
||||
"PLAY_NEXT_EPISODE": "Прайграць наступны эпізод",
|
||||
"SERIES_WATCHED": "Серыял прагледжаны",
|
||||
"WATCHED": "Прагледжана",
|
||||
"IN_PROGRESS": "У працэсе",
|
||||
"MARK_WATCHED": "Адзначыць як прагледжанае",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -575,6 +575,10 @@
|
||||
"RESUME": "Συνέχιση",
|
||||
"RESTART": "Από την αρχή",
|
||||
"CONTINUE_WATCHING": "Συνέχιση παρακολούθησης",
|
||||
"PLAY_FIRST_EPISODE": "Αναπαραγωγή πρώτου επεισοδίου",
|
||||
"RESUME_EPISODE": "Συνέχιση επεισοδίου",
|
||||
"PLAY_NEXT_EPISODE": "Αναπαραγωγή επόμενου επεισοδίου",
|
||||
"SERIES_WATCHED": "Η σειρά προβλήθηκε",
|
||||
"WATCHED": "Έχει προβληθεί",
|
||||
"IN_PROGRESS": "Σε εξέλιξη",
|
||||
"MARK_WATCHED": "Σήμανση ως προβληθέν",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -575,6 +575,10 @@
|
||||
"RESUME": "続きから再生",
|
||||
"RESTART": "最初から再生",
|
||||
"CONTINUE_WATCHING": "視聴を再開",
|
||||
"PLAY_FIRST_EPISODE": "第1話を再生",
|
||||
"RESUME_EPISODE": "エピソードを再開",
|
||||
"PLAY_NEXT_EPISODE": "次のエピソードを再生",
|
||||
"SERIES_WATCHED": "シリーズ視聴済み",
|
||||
"WATCHED": "視聴済み",
|
||||
"IN_PROGRESS": "視聴中",
|
||||
"MARK_WATCHED": "視聴済みにする",
|
||||
|
||||
@@ -575,6 +575,10 @@
|
||||
"RESUME": "이어 보기",
|
||||
"RESTART": "처음부터 보기",
|
||||
"CONTINUE_WATCHING": "이어서 시청",
|
||||
"PLAY_FIRST_EPISODE": "첫 에피소드 재생",
|
||||
"RESUME_EPISODE": "에피소드 이어 보기",
|
||||
"PLAY_NEXT_EPISODE": "다음 에피소드 재생",
|
||||
"SERIES_WATCHED": "시리즈 시청 완료",
|
||||
"WATCHED": "시청 완료",
|
||||
"IN_PROGRESS": "시청 중",
|
||||
"MARK_WATCHED": "시청한 것으로 표시",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -579,6 +579,10 @@
|
||||
"RESUME": "Продолжить",
|
||||
"RESTART": "С начала",
|
||||
"CONTINUE_WATCHING": "Продолжить просмотр",
|
||||
"PLAY_FIRST_EPISODE": "Воспроизвести первую серию",
|
||||
"RESUME_EPISODE": "Продолжить серию",
|
||||
"PLAY_NEXT_EPISODE": "Следующая серия",
|
||||
"SERIES_WATCHED": "Сериал просмотрен",
|
||||
"WATCHED": "Просмотрено",
|
||||
"IN_PROGRESS": "В процессе",
|
||||
"MARK_WATCHED": "Отметить как просмотренное",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -575,6 +575,10 @@
|
||||
"RESUME": "继续观看",
|
||||
"RESTART": "从头开始",
|
||||
"CONTINUE_WATCHING": "继续观看",
|
||||
"PLAY_FIRST_EPISODE": "播放第一集",
|
||||
"RESUME_EPISODE": "继续播放本集",
|
||||
"PLAY_NEXT_EPISODE": "播放下一集",
|
||||
"SERIES_WATCHED": "剧集已看完",
|
||||
"WATCHED": "已观看",
|
||||
"IN_PROGRESS": "观看中",
|
||||
"MARK_WATCHED": "标记为已观看",
|
||||
|
||||
@@ -575,6 +575,10 @@
|
||||
"RESUME": "繼續",
|
||||
"RESTART": "從頭開始",
|
||||
"CONTINUE_WATCHING": "繼續觀看",
|
||||
"PLAY_FIRST_EPISODE": "播放第一集",
|
||||
"RESUME_EPISODE": "繼續播放本集",
|
||||
"PLAY_NEXT_EPISODE": "播放下一集",
|
||||
"SERIES_WATCHED": "劇集已看完",
|
||||
"WATCHED": "已觀看",
|
||||
"IN_PROGRESS": "觀看中",
|
||||
"MARK_WATCHED": "標記為已觀看",
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export class PlayerDialogComponent {}
|
||||
@@ -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`
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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> = {}
|
||||
): 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');
|
||||
});
|
||||
});
|
||||
@@ -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<string, XtreamSerieEpisode[]>;
|
||||
playbackPositions: Map<number, PlaybackPositionData>;
|
||||
}
|
||||
|
||||
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, 'episodeLabel'>
|
||||
): 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);
|
||||
}
|
||||
+43
@@ -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({
|
||||
|
||||
-2
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<unknown>(null);
|
||||
readonly isSeries = input(false);
|
||||
readonly vodDetailsItem = input<unknown>(null);
|
||||
readonly vodDetailsItem = input<VodDetailsItem | null>(null);
|
||||
readonly isFavorite = input(false);
|
||||
readonly playbackPosition = input<number | null>(null);
|
||||
readonly inlinePlayback = input<ResolvedPortalPlayback | null>(null);
|
||||
readonly externalPlayback = input<unknown>(null);
|
||||
readonly backClicked = output<void>();
|
||||
readonly playClicked = output<unknown>();
|
||||
readonly playClicked = output<VodDetailsItem>();
|
||||
readonly resumeClicked = output<unknown>();
|
||||
readonly favoriteToggled = output<unknown>();
|
||||
readonly inlineTimeUpdated = output<unknown>();
|
||||
readonly inlinePlaybackClosed = output<void>();
|
||||
readonly streamUrlCopied = output<void>();
|
||||
readonly inlineExternalFallbackRequested = output<unknown>();
|
||||
}
|
||||
|
||||
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(
|
||||
|
||||
@@ -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 {
|
||||
<app-content-hero [isLoading]="true" />
|
||||
@@ -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<StalkerSelectedVodItem | null>(null);
|
||||
readonly vodDetailsItem = signal<VodDetailsItem | null>(null);
|
||||
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
|
||||
private readonly selectedVodPosition = signal<PlaybackPositionData | null>(
|
||||
null
|
||||
);
|
||||
readonly isSelectedVodFavorite = signal(false);
|
||||
readonly detailCategoryOverride = signal<StalkerDetailCategory | null>(
|
||||
null
|
||||
@@ -109,8 +143,21 @@ export class StalkerCollectionDetailComponent {
|
||||
() => this.stalkerStore.currentPlaylist()?._id,
|
||||
() => this.favoritesRefresh.refreshVersion()
|
||||
);
|
||||
readonly selectedVodPlaybackPosition = computed<number | null>(
|
||||
() => 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<void> {
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
await this.vodPlayback.loadSelectedVodPosition(playlistId, vodId);
|
||||
}
|
||||
}
|
||||
+8
-7
@@ -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;
|
||||
}
|
||||
}
|
||||
+10
-1
@@ -13,10 +13,19 @@
|
||||
<app-vod-details
|
||||
[item]="vodDetailsItem()"
|
||||
[isFavorite]="isFavorite()"
|
||||
[externalPlayback]="externalPlayback.activeSession()"
|
||||
[playbackPosition]="playbackPosition()"
|
||||
[inlinePlayback]="inlinePlayback()"
|
||||
[externalPlayback]="externalPlayback()"
|
||||
(playClicked)="onPlayClicked($event)"
|
||||
(resumeClicked)="onResumeClicked($event)"
|
||||
(favoriteToggled)="onFavoriteToggled($event)"
|
||||
(backClicked)="onBackClicked()"
|
||||
(inlineTimeUpdated)="onInlineTimeUpdated($event)"
|
||||
(inlinePlaybackClosed)="onInlinePlaybackClosed()"
|
||||
(streamUrlCopied)="onStreamUrlCopied()"
|
||||
(inlineExternalFallbackRequested)="
|
||||
onInlineExternalFallbackRequested($event)
|
||||
"
|
||||
/>
|
||||
}
|
||||
} @else if (categoryId() === 'series') {
|
||||
|
||||
+47
-5
@@ -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<StalkerSelectedVodItem | null>(null);
|
||||
readonly isSeries = input<boolean>(false);
|
||||
readonly vodDetailsItem = input<VodDetailsItem | null>(null);
|
||||
readonly isFavorite = input<boolean>(false);
|
||||
readonly playbackPosition = input<number | null>(null);
|
||||
readonly inlinePlayback = input<ResolvedPortalPlayback | null>(null);
|
||||
readonly externalPlayback = input<ExternalPlayerSession | null>(null);
|
||||
|
||||
readonly backClicked = output<void>();
|
||||
readonly playClicked = output<VodDetailsItem>();
|
||||
readonly resumeClicked = output<{
|
||||
item: VodDetailsItem;
|
||||
positionSeconds: number;
|
||||
}>();
|
||||
readonly favoriteToggled = output<{
|
||||
item: VodDetailsItem;
|
||||
isFavorite: boolean;
|
||||
}>();
|
||||
readonly inlineTimeUpdated = output<{
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
}>();
|
||||
readonly inlinePlaybackClosed = output<void>();
|
||||
readonly streamUrlCopied = output<void>();
|
||||
readonly inlineExternalFallbackRequested =
|
||||
output<PlaybackFallbackRequest>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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()"
|
||||
/>
|
||||
|
||||
@@ -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<ResolvedPortalPlayback | null>(null);
|
||||
readonly selectedVodPosition = signal<PlaybackPositionData | null>(null);
|
||||
readonly selectedVodPlaybackPosition = computed<number | null>(
|
||||
() => 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<void> {
|
||||
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<void> {
|
||||
await this.vodPlayback.loadSelectedVodPosition(playlistId, vodId);
|
||||
}
|
||||
}
|
||||
+24
@@ -51,6 +51,30 @@
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<ng-container hero-actions>
|
||||
@if (quickStartAction(); as action) {
|
||||
<button
|
||||
class="play-btn"
|
||||
data-testid="series-quick-start"
|
||||
(click)="playQuickStartEpisode()"
|
||||
[disabled]="action.disabled"
|
||||
>
|
||||
<span class="play-btn__icon-shell" aria-hidden="true">
|
||||
<mat-icon class="play-btn__icon">{{
|
||||
action.icon
|
||||
}}</mat-icon>
|
||||
</span>
|
||||
<span class="play-btn__copy">
|
||||
<span class="play-btn__label">
|
||||
{{ action.labelKey | translate }}
|
||||
</span>
|
||||
@if (action.episodeLabel) {
|
||||
<span class="play-btn__meta">
|
||||
{{ action.episodeLabel }}
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
<app-favorites-button
|
||||
[itemId]="serial.id"
|
||||
[item]="serial"
|
||||
|
||||
+427
@@ -0,0 +1,427 @@
|
||||
import { Component, input, output, signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { Router } from '@angular/router';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { MockPipe } from 'ng-mocks';
|
||||
import {
|
||||
ContentHeroComponent,
|
||||
SeasonContainerComponent,
|
||||
} from 'components';
|
||||
import {
|
||||
PORTAL_EXTERNAL_PLAYBACK,
|
||||
PORTAL_PLAYBACK_POSITIONS,
|
||||
PORTAL_PLAYER,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
StalkerStore,
|
||||
StalkerVodSource,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import { PlaybackPositionData } from 'shared-interfaces';
|
||||
import { PortalInlinePlayerComponent } from '@iptvnator/ui/playback';
|
||||
import { DownloadsService } from 'services';
|
||||
import { of } from 'rxjs';
|
||||
import { FavoritesButtonComponent } from '../stalker-favorites-button/stalker-favorites-button.component';
|
||||
import { StalkerSeriesViewComponent } from './stalker-series-view.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-content-hero',
|
||||
standalone: true,
|
||||
template: '<ng-content />',
|
||||
})
|
||||
class StubContentHeroComponent {
|
||||
readonly title = input<string | undefined>(undefined);
|
||||
readonly description = input<string | undefined>(undefined);
|
||||
readonly posterUrl = input<string | undefined>(undefined);
|
||||
readonly backClicked = output<void>();
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-season-container',
|
||||
standalone: true,
|
||||
template: '<div data-testid="season-container"></div>',
|
||||
})
|
||||
class StubSeasonContainerComponent {
|
||||
readonly seasons = input<unknown>(null);
|
||||
readonly seriesId = input<number | string | null>(null);
|
||||
readonly playlistId = input('');
|
||||
readonly seriesTitle = input<string | undefined>(undefined);
|
||||
readonly playbackPositions = input<unknown>(null);
|
||||
readonly openingEpisodeId = input<number | null>(null);
|
||||
readonly activeEpisodeId = input<number | null>(null);
|
||||
readonly isLoading = input(false);
|
||||
readonly seasonSelected = output<string>();
|
||||
readonly episodeClicked = output<unknown>();
|
||||
readonly episodeDownloadRequested = output<unknown>();
|
||||
readonly playbackToggleRequested = output<unknown>();
|
||||
selectedSeason: string | undefined;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-portal-inline-player',
|
||||
standalone: true,
|
||||
template: '',
|
||||
})
|
||||
class StubPortalInlinePlayerComponent {
|
||||
readonly playback = input<unknown>(null);
|
||||
readonly timeUpdate = output<unknown>();
|
||||
readonly closed = output<void>();
|
||||
readonly streamUrlCopied = output<void>();
|
||||
readonly externalFallbackRequested = output<unknown>();
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-favorites-button',
|
||||
standalone: true,
|
||||
template: '<button class="favorite-btn">favorite</button>',
|
||||
})
|
||||
class StubFavoritesButtonComponent {
|
||||
readonly itemId = input<string | number | undefined>(undefined);
|
||||
readonly item = input<unknown>(null);
|
||||
}
|
||||
|
||||
describe('StalkerSeriesViewComponent', () => {
|
||||
let fixture: ComponentFixture<StalkerSeriesViewComponent>;
|
||||
const selectedContentType = signal<'series' | 'vod'>('series');
|
||||
const selectedItem = signal<StalkerVodSource | null>(null);
|
||||
const serialSeasonsResource = signal<unknown[]>([]);
|
||||
const vodSeriesSeasonsResource = signal<unknown[]>([]);
|
||||
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
|
||||
);
|
||||
});
|
||||
});
|
||||
+160
-16
@@ -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<StalkerQuickStartButton | null>(() => {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const positions = await this.playbackPositions.getSeriesPlaybackPositions(
|
||||
playlistId,
|
||||
seriesXtreamId
|
||||
);
|
||||
const positions =
|
||||
await this.playbackPositions.getSeriesPlaybackPositions(
|
||||
playlistId,
|
||||
seriesXtreamId
|
||||
);
|
||||
const positionsMap = new Map<number, PlaybackPositionData>();
|
||||
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<void> {
|
||||
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>
|
||||
): VodSeriesSeasonVm | null {
|
||||
return seasons.find((season) => season.episodes.length === 0) ?? null;
|
||||
}
|
||||
|
||||
private findVodSeriesSeasonIndexForEpisode(
|
||||
episode: XtreamSerieEpisode,
|
||||
seasons: ReadonlyArray<VodSeriesSeasonVm>
|
||||
): 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);
|
||||
|
||||
@@ -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<T> {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
}
|
||||
|
||||
function createDeferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((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<PlaybackPositionData | null>(null);
|
||||
const playbackPositions = {
|
||||
savePlaybackPosition: jest.fn(),
|
||||
getPlaybackPosition: jest.fn(),
|
||||
getSeriesPlaybackPositions: jest.fn(),
|
||||
getAllPlaybackPositions: jest.fn(),
|
||||
clearPlaybackPosition: jest.fn(),
|
||||
} as unknown as jest.Mocked<PortalPlaybackPositions>;
|
||||
const portalPlayer = {
|
||||
isEmbeddedPlayer: jest.fn(() => true),
|
||||
openPlayer: jest.fn(),
|
||||
openResolvedPlayback: jest.fn(),
|
||||
openExternalPlayback: jest.fn(),
|
||||
} as unknown as jest.Mocked<PortalPlayer>;
|
||||
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<PlaybackPositionData | null>();
|
||||
const newerLoad = createDeferred<PlaybackPositionData | null>();
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<ResolvedPortalPlayback | null>;
|
||||
selectedVodPosition: WritableSignal<PlaybackPositionData | null>;
|
||||
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<ResolvedPortalPlayback>
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
<h2 mat-dialog-title>{{ title }}</h2>
|
||||
<mat-dialog-content class="content">
|
||||
<app-web-player-view
|
||||
[streamUrl]="streamUrl"
|
||||
[title]="title"
|
||||
[playback]="data.playback ?? null"
|
||||
[startTime]="data.startTime || 0"
|
||||
(timeUpdate)="handleTimeUpdate($event)"
|
||||
(externalFallbackRequested)="handleExternalFallbackRequest($event)"
|
||||
/>
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions class="align-actions">
|
||||
<button
|
||||
mat-button
|
||||
[cdkCopyToClipboard]="streamUrl"
|
||||
(click)="showCopyNotification()"
|
||||
[matTooltip]="streamUrl"
|
||||
>
|
||||
<mat-icon>content_copy</mat-icon>
|
||||
{{ 'PORTALS.COPY_STREAM_URL' | translate }}
|
||||
</button>
|
||||
<button mat-button mat-dialog-close color="accent">
|
||||
{{ 'CLOSE' | translate }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<PlayerDialogData>(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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,28 @@
|
||||
</ng-container>
|
||||
|
||||
<ng-container hero-actions>
|
||||
@if (quickStartAction(); as action) {
|
||||
<button
|
||||
class="play-btn"
|
||||
data-testid="series-quick-start"
|
||||
(click)="playQuickStartEpisode()"
|
||||
[disabled]="action.disabled"
|
||||
>
|
||||
<span class="play-btn__icon-shell" aria-hidden="true">
|
||||
<mat-icon class="play-btn__icon">{{
|
||||
action.icon
|
||||
}}</mat-icon>
|
||||
</span>
|
||||
<span class="play-btn__copy">
|
||||
<span class="play-btn__label">
|
||||
{{ action.labelKey | translate }}
|
||||
</span>
|
||||
<span class="play-btn__meta">
|
||||
{{ action.episodeLabel }}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
@if (!isFavorite()) {
|
||||
<button class="favorite-btn" (click)="toggleFavorite()">
|
||||
<mat-icon>add</mat-icon>
|
||||
|
||||
+101
-3
@@ -89,6 +89,9 @@ describe('SerialDetailsComponent', () => {
|
||||
});
|
||||
const fetchSerialDetailsWithMetadata = jest.fn();
|
||||
const checkFavoriteStatus = jest.fn();
|
||||
const constructEpisodeStreamUrl = jest.fn();
|
||||
const addRecentItem = jest.fn();
|
||||
const openResolvedPlayback = jest.fn();
|
||||
const getSeriesPlaybackPositions = jest.fn().mockResolvedValue([]);
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -117,7 +120,14 @@ describe('SerialDetailsComponent', () => {
|
||||
detailsError.set(null);
|
||||
fetchSerialDetailsWithMetadata.mockClear();
|
||||
checkFavoriteStatus.mockClear();
|
||||
constructEpisodeStreamUrl.mockReset();
|
||||
constructEpisodeStreamUrl.mockReturnValue(
|
||||
'http://xtream.example/series/1001.mp4'
|
||||
);
|
||||
addRecentItem.mockClear();
|
||||
openResolvedPlayback.mockClear();
|
||||
getSeriesPlaybackPositions.mockClear();
|
||||
getSeriesPlaybackPositions.mockResolvedValue([]);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [SerialDetailsComponent],
|
||||
@@ -148,8 +158,8 @@ describe('SerialDetailsComponent', () => {
|
||||
selectedItem.set(value)
|
||||
),
|
||||
toggleFavorite: jest.fn(),
|
||||
constructEpisodeStreamUrl: jest.fn(),
|
||||
addRecentItem: jest.fn(),
|
||||
constructEpisodeStreamUrl,
|
||||
addRecentItem,
|
||||
backfillContentBackdrop: jest.fn(),
|
||||
},
|
||||
},
|
||||
@@ -171,7 +181,7 @@ describe('SerialDetailsComponent', () => {
|
||||
provide: PORTAL_PLAYER,
|
||||
useValue: {
|
||||
isEmbeddedPlayer: jest.fn().mockReturnValue(false),
|
||||
openResolvedPlayback: jest.fn(),
|
||||
openResolvedPlayback,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -267,4 +277,92 @@ describe('SerialDetailsComponent', () => {
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('renders quick start as the first episode action and opens that 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();
|
||||
|
||||
expect(constructEpisodeStreamUrl).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: '1001' })
|
||||
);
|
||||
expect(addRecentItem).toHaveBeenCalledWith({
|
||||
xtreamId: '103',
|
||||
contentType: 'series',
|
||||
playlist: currentPlaylist,
|
||||
backdropUrl: undefined,
|
||||
});
|
||||
expect(openResolvedPlayback).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
streamUrl: 'http://xtream.example/series/1001.mp4',
|
||||
title: 'Episode 1',
|
||||
startTime: undefined,
|
||||
contentInfo: expect.objectContaining({
|
||||
contentXtreamId: 1001,
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 103,
|
||||
}),
|
||||
}),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('resumes quick start from the stored episode position', async () => {
|
||||
getSeriesPlaybackPositions.mockResolvedValue([
|
||||
{
|
||||
contentXtreamId: 1001,
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 103,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 1,
|
||||
positionSeconds: 42,
|
||||
durationSeconds: 120,
|
||||
playlistId: 'xtream-1',
|
||||
updatedAt: '2026-05-10T12:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
const quickStartButton: HTMLButtonElement | null =
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-testid="series-quick-start"]'
|
||||
);
|
||||
|
||||
expect(quickStartButton?.textContent).toContain(
|
||||
'XTREAM.RESUME_EPISODE'
|
||||
);
|
||||
expect(quickStartButton?.textContent).toContain(
|
||||
'S01E01 · Episode 1'
|
||||
);
|
||||
|
||||
quickStartButton?.click();
|
||||
|
||||
expect(openResolvedPlayback).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
startTime: 42,
|
||||
contentInfo: expect.objectContaining({
|
||||
contentXtreamId: 1001,
|
||||
}),
|
||||
}),
|
||||
true
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Location, SlicePipe } from '@angular/common';
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
OnDestroy,
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
PORTAL_EXTERNAL_PLAYBACK,
|
||||
PORTAL_PLAYBACK_POSITIONS,
|
||||
PORTAL_PLAYER,
|
||||
getSeriesQuickStartAction,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
import {
|
||||
@@ -92,6 +94,17 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
|
||||
private unsubscribePositionUpdates: (() => void) | null = null;
|
||||
readonly openingEpisodeId = signal<number | null>(null);
|
||||
readonly activeEpisodeId = signal<number | null>(null);
|
||||
readonly quickStartAction = computed(() => {
|
||||
const item = this.selectedItem();
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getSeriesQuickStartAction({
|
||||
seasons: item.episodes ?? {},
|
||||
playbackPositions: this.episodePlaybackPositions(),
|
||||
});
|
||||
});
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
@@ -260,6 +273,15 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
|
||||
this.startPlayback(playback);
|
||||
}
|
||||
|
||||
playQuickStartEpisode(): void {
|
||||
const action = this.quickStartAction();
|
||||
if (!action || action.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.playEpisode(action.episode);
|
||||
}
|
||||
|
||||
toggleFavorite(): void {
|
||||
this.xtreamStore.toggleFavorite(
|
||||
this.route.snapshot.params.serialId,
|
||||
|
||||
@@ -426,14 +426,15 @@
|
||||
::ng-deep .play-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 28px;
|
||||
gap: 12px;
|
||||
min-height: 64px;
|
||||
padding: 10px 24px 10px 18px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
background: #fff;
|
||||
color: #141414;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
border-radius: 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
@@ -447,19 +448,50 @@
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.play-btn__copy {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
line-height: 1.1;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.play-btn__label {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.play-btn__meta {
|
||||
display: block;
|
||||
max-width: min(42vw, 360px);
|
||||
overflow: hidden;
|
||||
color: currentColor;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
opacity: 0.72;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
::ng-deep .favorite-btn {
|
||||
::ng-deep .favorite-btn,
|
||||
::ng-deep .download-btn {
|
||||
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;
|
||||
|
||||
@@ -467,6 +499,14 @@
|
||||
background: rgba(109, 109, 110, 0.5);
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 24px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
::ng-deep .favorite-btn {
|
||||
&--active {
|
||||
background: rgba(229, 9, 20, 0.8);
|
||||
|
||||
@@ -474,11 +514,31 @@
|
||||
background: rgba(229, 9, 20, 0.6);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
::ng-deep .download-btn {
|
||||
background: rgba(33, 150, 243, 0.8);
|
||||
|
||||
&:hover {
|
||||
background: rgba(33, 150, 243, 0.6);
|
||||
}
|
||||
|
||||
&--completed {
|
||||
background: rgba(76, 175, 80, 0.8);
|
||||
|
||||
&:hover {
|
||||
background: rgba(76, 175, 80, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
&--downloading {
|
||||
background: rgba(109, 109, 110, 0.5);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.7;
|
||||
|
||||
&:hover {
|
||||
background: rgba(109, 109, 110, 0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -551,11 +611,17 @@
|
||||
width: 100%;
|
||||
|
||||
::ng-deep .play-btn,
|
||||
::ng-deep .favorite-btn {
|
||||
::ng-deep .favorite-btn,
|
||||
::ng-deep .download-btn {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
min-height: 58px;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
|
||||
::ng-deep .play-btn__meta {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -538,9 +576,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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -538,9 +576,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;
|
||||
|
||||
|
||||
Reference in new issue
Block a user