diff --git a/.changes/playback-up-next-card.md b/.changes/playback-up-next-card.md new file mode 100644 index 000000000..670036029 --- /dev/null +++ b/.changes/playback-up-next-card.md @@ -0,0 +1,9 @@ +--- +type: feature +area: playback +highlight: Up next in the player +--- + +In the last minutes of a series episode the player shows an "Up next" card +with the following episode's still, title and time remaining. One click +plays it, and the switch keeps you in fullscreen. diff --git a/apps/web-e2e/src/xtream-series-playback.e2e.ts b/apps/web-e2e/src/xtream-series-playback.e2e.ts index ad1d0d432..76c610080 100644 --- a/apps/web-e2e/src/xtream-series-playback.e2e.ts +++ b/apps/web-e2e/src/xtream-series-playback.e2e.ts @@ -123,6 +123,54 @@ test.describe('@xtream inline series fullscreen', () => { expect(await fullscreenOwner()).not.toBeNull(); }); + test('shows the Up next card near the end and plays the next episode from it', async ({ + page, + request, + }) => { + await routeEpisodeClip(page); + await rewriteSeriesEpisodesToMp4(page); + await selectWebPlayer(page, 'HTML5 video player'); + const { playerView, video } = await playFirstSeriesEpisode( + page, + request + ); + await expect(playerView.locator('app-html-video-player')).toBeVisible({ + timeout: 15_000, + }); + await expect + .poll(() => + video.evaluate((el) => (el as HTMLVideoElement).readyState) + ) + .toBeGreaterThanOrEqual(1); + + // The 30 s fixture clip is inside the card's threshold from its + // first frame, so the card is up as soon as the duration is known. + const card = playerView.locator( + '[data-test-id="player-controls-up-next"]' + ); + await expect(card).toBeVisible(); + await expect(card).toContainText('Up next'); + await expect(card).toHaveAttribute('aria-label', /S01E02/); + + // The settings panel takes the same corner: the card yields to it. + await playerView.hover(); + await playerView + .locator('[data-test-id="player-controls-settings-button"]') + .click(); + await expect(card).toHaveCount(0); + await page.keyboard.press('Escape'); + await expect(card).toBeVisible(); + + // A click plays the next episode through the host's own path. + await card.click(); + await expect( + page.locator('app-portal-inline-player .player-shell__episode-meta') + ).toContainText('S01E02', { timeout: 15_000 }); + await expect(card).toHaveAttribute('aria-label', /S01E03/, { + timeout: 15_000, + }); + }); + test('switches episodes from the fullscreen episode panel without leaving fullscreen', async ({ page, request, diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index f84488ace..9e10050b7 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -622,6 +622,8 @@ "CLEAR_SEARCH": "مسح البحث", "LOAD_SUBTITLE_FILE": "تحميل ملف الترجمة…", "SETTINGS": "الإعدادات", + "UP_NEXT": "التالي", + "UP_NEXT_IN": "خلال {{minutes}} دقيقة", "CLOSE_SETTINGS": "إغلاق الإعدادات", "SUBTITLE_DELAY": "تأخير الترجمة", "SUBTITLE_DELAY_DECREASE": "عرض الترجمة مبكرًا", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 3b05a92b8..4ecd64552 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -622,6 +622,8 @@ "CLEAR_SEARCH": "مسح البحث", "LOAD_SUBTITLE_FILE": "تحميل ملف الترجمة…", "SETTINGS": "الإعدادات", + "UP_NEXT": "اللي جاي", + "UP_NEXT_IN": "فـ {{minutes}} دقيقة", "CLOSE_SETTINGS": "سد الإعدادات", "SUBTITLE_DELAY": "تأخير الترجمة", "SUBTITLE_DELAY_DECREASE": "ورّي الترجمة قبل", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 238500bdb..52b55f9cc 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -622,6 +622,8 @@ "CLEAR_SEARCH": "Ачысціць пошук", "LOAD_SUBTITLE_FILE": "Загрузіць файл субтытраў…", "SETTINGS": "Налады", + "UP_NEXT": "Далей", + "UP_NEXT_IN": "праз {{minutes}} хв", "CLOSE_SETTINGS": "Закрыць налады", "SUBTITLE_DELAY": "Затрымка субтытраў", "SUBTITLE_DELAY_DECREASE": "Паказваць субтытры раней", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index f92457b60..c591c5e66 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -622,6 +622,8 @@ "CLEAR_SEARCH": "Suche löschen", "LOAD_SUBTITLE_FILE": "Untertiteldatei laden…", "SETTINGS": "Einstellungen", + "UP_NEXT": "Als Nächstes", + "UP_NEXT_IN": "in {{minutes}} Min.", "CLOSE_SETTINGS": "Einstellungen schließen", "SUBTITLE_DELAY": "Untertitelverzögerung", "SUBTITLE_DELAY_DECREASE": "Untertitel früher anzeigen", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 2f87a8528..5f7a9d2b0 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -622,6 +622,8 @@ "CLEAR_SEARCH": "Καθαρισμός αναζήτησης", "LOAD_SUBTITLE_FILE": "Φόρτωση αρχείου υποτίτλων…", "SETTINGS": "Ρυθμίσεις", + "UP_NEXT": "Επόμενο", + "UP_NEXT_IN": "σε {{minutes}} λεπτά", "CLOSE_SETTINGS": "Κλείσιμο ρυθμίσεων", "SUBTITLE_DELAY": "Καθυστέρηση υποτίτλων", "SUBTITLE_DELAY_DECREASE": "Εμφάνιση υποτίτλων νωρίτερα", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 586673b50..ba3ed74c3 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -622,6 +622,8 @@ "CLEAR_SEARCH": "Clear search", "LOAD_SUBTITLE_FILE": "Load subtitle file…", "SETTINGS": "Settings", + "UP_NEXT": "Up next", + "UP_NEXT_IN": "in {{minutes}} min", "CLOSE_SETTINGS": "Close settings", "SUBTITLE_DELAY": "Subtitle delay", "SUBTITLE_DELAY_DECREASE": "Show subtitles earlier", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 506a87607..81a61a238 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -622,6 +622,8 @@ "CLEAR_SEARCH": "Borrar búsqueda", "LOAD_SUBTITLE_FILE": "Cargar archivo de subtítulos…", "SETTINGS": "Ajustes", + "UP_NEXT": "A continuación", + "UP_NEXT_IN": "en {{minutes}} min", "CLOSE_SETTINGS": "Cerrar ajustes", "SUBTITLE_DELAY": "Retardo de subtítulos", "SUBTITLE_DELAY_DECREASE": "Mostrar subtítulos antes", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 47cbf885f..f0704b057 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -622,6 +622,8 @@ "CLEAR_SEARCH": "Effacer la recherche", "LOAD_SUBTITLE_FILE": "Charger un fichier de sous-titres…", "SETTINGS": "Réglages", + "UP_NEXT": "À suivre", + "UP_NEXT_IN": "dans {{minutes}} min", "CLOSE_SETTINGS": "Fermer les réglages", "SUBTITLE_DELAY": "Décalage des sous-titres", "SUBTITLE_DELAY_DECREASE": "Afficher les sous-titres plus tôt", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 66ae7e57a..629e17ffe 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -622,6 +622,8 @@ "CLEAR_SEARCH": "Keresés törlése", "LOAD_SUBTITLE_FILE": "Feliratfájl betöltése…", "SETTINGS": "Beállítások", + "UP_NEXT": "Következő", + "UP_NEXT_IN": "{{minutes}} perc múlva", "CLOSE_SETTINGS": "Beállítások bezárása", "SUBTITLE_DELAY": "Felirat késleltetése", "SUBTITLE_DELAY_DECREASE": "Feliratok megjelenítése korábban", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index e37825a59..c6e00edc2 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -622,6 +622,8 @@ "CLEAR_SEARCH": "Cancella ricerca", "LOAD_SUBTITLE_FILE": "Carica file sottotitoli…", "SETTINGS": "Impostazioni", + "UP_NEXT": "Prossimo", + "UP_NEXT_IN": "tra {{minutes}} min", "CLOSE_SETTINGS": "Chiudi impostazioni", "SUBTITLE_DELAY": "Ritardo sottotitoli", "SUBTITLE_DELAY_DECREASE": "Mostra i sottotitoli prima", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index bcc09f487..394a8e301 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -622,6 +622,8 @@ "CLEAR_SEARCH": "検索をクリア", "LOAD_SUBTITLE_FILE": "字幕ファイルを読み込む…", "SETTINGS": "設定", + "UP_NEXT": "次のエピソード", + "UP_NEXT_IN": "あと{{minutes}}分", "CLOSE_SETTINGS": "設定を閉じる", "SUBTITLE_DELAY": "字幕の遅延", "SUBTITLE_DELAY_DECREASE": "字幕を早く表示", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 0eabc86df..24e033a16 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -622,6 +622,8 @@ "CLEAR_SEARCH": "검색 지우기", "LOAD_SUBTITLE_FILE": "자막 파일 불러오기…", "SETTINGS": "설정", + "UP_NEXT": "다음 에피소드", + "UP_NEXT_IN": "{{minutes}}분 후", "CLOSE_SETTINGS": "설정 닫기", "SUBTITLE_DELAY": "자막 지연", "SUBTITLE_DELAY_DECREASE": "자막을 더 빨리 표시", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 26ee0e6ce..2e4d8dea1 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -622,6 +622,8 @@ "CLEAR_SEARCH": "Zoekopdracht wissen", "LOAD_SUBTITLE_FILE": "Ondertitelbestand laden…", "SETTINGS": "Instellingen", + "UP_NEXT": "Hierna", + "UP_NEXT_IN": "over {{minutes}} min", "CLOSE_SETTINGS": "Instellingen sluiten", "SUBTITLE_DELAY": "Ondertitelvertraging", "SUBTITLE_DELAY_DECREASE": "Ondertitels eerder tonen", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 4a8a02c3e..e56b9cba6 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -622,6 +622,8 @@ "CLEAR_SEARCH": "Wyczyść wyszukiwanie", "LOAD_SUBTITLE_FILE": "Wczytaj plik napisów…", "SETTINGS": "Ustawienia", + "UP_NEXT": "Następny", + "UP_NEXT_IN": "za {{minutes}} min", "CLOSE_SETTINGS": "Zamknij ustawienia", "SUBTITLE_DELAY": "Opóźnienie napisów", "SUBTITLE_DELAY_DECREASE": "Pokaż napisy wcześniej", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 2e39b24c5..ec58910de 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -622,6 +622,8 @@ "CLEAR_SEARCH": "Limpar pesquisa", "LOAD_SUBTITLE_FILE": "Carregar ficheiro de legendas…", "SETTINGS": "Configurações", + "UP_NEXT": "A seguir", + "UP_NEXT_IN": "em {{minutes}} min", "CLOSE_SETTINGS": "Fechar configurações", "SUBTITLE_DELAY": "Atraso das legendas", "SUBTITLE_DELAY_DECREASE": "Mostrar legendas mais cedo", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 7f68d309a..e0733cd23 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -622,6 +622,8 @@ "CLEAR_SEARCH": "Очистить поиск", "LOAD_SUBTITLE_FILE": "Загрузить файл субтитров…", "SETTINGS": "Настройки", + "UP_NEXT": "Далее", + "UP_NEXT_IN": "через {{minutes}} мин", "CLOSE_SETTINGS": "Закрыть настройки", "SUBTITLE_DELAY": "Задержка субтитров", "SUBTITLE_DELAY_DECREASE": "Показывать субтитры раньше", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index ec8477f47..b840d528a 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -622,6 +622,8 @@ "CLEAR_SEARCH": "Aramayı temizle", "LOAD_SUBTITLE_FILE": "Altyazı dosyası yükle…", "SETTINGS": "Ayarlar", + "UP_NEXT": "Sıradaki", + "UP_NEXT_IN": "{{minutes}} dk içinde", "CLOSE_SETTINGS": "Ayarları kapat", "SUBTITLE_DELAY": "Altyazı gecikmesi", "SUBTITLE_DELAY_DECREASE": "Altyazıları daha erken göster", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 61ef9ac6e..cfc2b6c76 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -622,6 +622,8 @@ "CLEAR_SEARCH": "清除搜索", "LOAD_SUBTITLE_FILE": "加载字幕文件…", "SETTINGS": "设置", + "UP_NEXT": "接下来", + "UP_NEXT_IN": "{{minutes}} 分钟后", "CLOSE_SETTINGS": "关闭设置", "SUBTITLE_DELAY": "字幕延迟", "SUBTITLE_DELAY_DECREASE": "提前显示字幕", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 60720e823..55cf96cef 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -622,6 +622,8 @@ "CLEAR_SEARCH": "清除搜尋", "LOAD_SUBTITLE_FILE": "載入字幕檔案…", "SETTINGS": "設定", + "UP_NEXT": "接下來", + "UP_NEXT_IN": "{{minutes}} 分鐘後", "CLOSE_SETTINGS": "關閉設定", "SUBTITLE_DELAY": "字幕延遲", "SUBTITLE_DELAY_DECREASE": "提前顯示字幕", diff --git a/docs/architecture/player-controls-contract.md b/docs/architecture/player-controls-contract.md index 58d6ee192..c09a3fad7 100644 --- a/docs/architecture/player-controls-contract.md +++ b/docs/architecture/player-controls-contract.md @@ -293,7 +293,9 @@ It owns only transient presentation behavior: - `ControlsSettings` — the settings panel's groups, on/modified state and open/close transitions (`controls-settings-groups.ts` holds the pure group-availability rule); -- `app-player-settings-panel` — the panel / bottom sheet presentation; and +- `app-player-settings-panel` — the panel / bottom sheet presentation; +- `ControlsUpNext` and `app-player-up-next-card` — the "Up next" card's + gate and presentation; and - `controls-view-model.ts` — derived display state. ### The dock @@ -354,6 +356,28 @@ segment. Producers (EPG programmes for catch-up and timeshift playback, mpv's chapter list) are separate follow-ups; the rendering, model and rules are in place for them. +### Up next card + +Near the end of a series episode the dock shows an **"Up next" card** +(`app-player-up-next-card`, `data-test-id="player-controls-up-next"`) in the +bottom-right corner above the controls: the next episode's still (or its +`S01E03` label as a tile), a 3px accent progress line when it was partly +watched, "Up next · in 7 min" and the title. The host supplies the item +through the optional `upNext` input (`PlayerUpNextItem { label, title, +thumbnailUrl, progressPercent }`); `ControlsUpNext` decides when it shows — +`seriesNavigation` capability, `canNextEpisode`, a finite duration with at +most `UP_NEXT_THRESHOLD_SECONDS` (8 min) left, not live, controls shown, +settings panel closed — and how many minutes remain (never below one). A +click emits `nextEpisodeRequested`, the same output the hosts already +handle, so the switch keeps fullscreen exactly like the transport button. +The card is a glass surface that does not fade with the controls; the +compact dock uses a smaller variant without the trailing icon. + +Plumbing mirrors `mediaTitle`: `PortalInlinePlayerComponent.playerUpNext` +derives the item after the playing one from its `upNextEpisodes` input +(episodes only) → `WebPlayerViewComponent.upNext` → the four engine hosts → +`app-player-controls`. Movie and live hosts pass nothing. + ### Settings panel Every track, quality, speed and aspect choice lives behind one **`tune`** @@ -1516,6 +1540,10 @@ libs/ui/playback/src/lib/player-controls/ ├── controls-timeline-segments.ts ├── controls-settings.ts ├── controls-settings-groups.ts +├── controls-up-next.ts +├── player-up-next-card.component.ts +├── player-up-next-card.component.html +├── player-up-next-card.component.scss ├── player-settings-panel.component.ts ├── player-settings-panel.component.html ├── player-settings-panel.component.scss diff --git a/libs/ui/playback/src/lib/art-player/art-player.component.html b/libs/ui/playback/src/lib/art-player/art-player.component.html index 793e8dede..918f3a631 100644 --- a/libs/ui/playback/src/lib/art-player/art-player.component.html +++ b/libs/ui/playback/src/lib/art-player/art-player.component.html @@ -12,6 +12,7 @@ [playerSurface]="playerRoot" [fullscreenTarget]="fullscreenTarget()" [mediaTitle]="mediaTitle()" + [upNext]="upNext()" [showControls]="interactionEnabled()" [shortcutsEnabled]="interactionEnabled()" (previousEpisodeRequested)="previousEpisodeRequested.emit()" diff --git a/libs/ui/playback/src/lib/art-player/art-player.component.ts b/libs/ui/playback/src/lib/art-player/art-player.component.ts index 1dc49464a..986ff3923 100644 --- a/libs/ui/playback/src/lib/art-player/art-player.component.ts +++ b/libs/ui/playback/src/lib/art-player/art-player.component.ts @@ -20,6 +20,7 @@ import { type LegacyPlayerShortcuts, PlayerControlsComponent, type PlayerMediaTitle, + PlayerUpNextItem, WEB_PLAYER_SHARED_CONTROLS, WebVideoControlsAdapter, } from '../player-controls'; @@ -59,6 +60,7 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges { readonly isLive = input(true); readonly interactionEnabled = input(true); readonly mediaTitle = input(null); + readonly upNext = input(null); /** See `PlayerControlsComponent.fullscreenTarget`; null keeps the shell. */ readonly fullscreenTarget = input(null); diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.html b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.html index 0e2d44191..aff35a474 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.html +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.html @@ -103,6 +103,7 @@ [playerSurface]="playerSurface()" [fullscreenTarget]="fullscreenTarget()" [mediaTitle]="mediaTitle()" + [upNext]="upNext()" [showControls]="showControls()" [shortcutsEnabled]="sharedShortcutsEnabled()" (previousEpisodeRequested)="requestPreviousEpisode()" diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts index ed2fef2da..c768ba534 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts @@ -26,7 +26,10 @@ import { ResolvedPortalPlayback, } from '@iptvnator/shared/interfaces'; import { PlayerControlsComponent } from '../player-controls/player-controls.component'; -import type { PlayerMediaTitle } from '../player-controls/player-controls.model'; +import type { + PlayerMediaTitle, + PlayerUpNextItem, +} from '../player-controls/player-controls.model'; import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation'; import { EmbeddedMpvControlsAdapter } from './embedded-mpv-controls.adapter'; import { EmbeddedMpvDockPanelComponent } from './embedded-mpv-dock-panel.component'; @@ -82,6 +85,7 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { readonly recordingMetadata = input(null); readonly seriesNavigation = input(null); readonly mediaTitle = input(null); + readonly upNext = input(null); /** See `PlayerControlsComponent.fullscreenTarget`; null keeps the root. */ readonly fullscreenTarget = input(null); diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.html b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.html index 2e08db2f8..e554aaa6f 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.html +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.html @@ -12,6 +12,7 @@ [playerSurface]="playerRoot" [fullscreenTarget]="fullscreenTarget()" [mediaTitle]="mediaTitle()" + [upNext]="upNext()" [showControls]="interactionEnabled()" [shortcutsEnabled]="interactionEnabled()" (previousEpisodeRequested)="previousEpisodeRequested.emit()" diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts index b73989560..0e2feb5c9 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts @@ -29,6 +29,7 @@ import { type LegacyPlayerShortcuts, PlayerControlsComponent, type PlayerMediaTitle, + PlayerUpNextItem, WEB_PLAYER_SHARED_CONTROLS, WebVideoControlsAdapter, } from '../player-controls'; @@ -81,6 +82,7 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { readonly interactionEnabled = input(true); readonly showCaptions = input(false); readonly mediaTitle = input(null); + readonly upNext = input(null); /** See `PlayerControlsComponent.fullscreenTarget`; null keeps the shell. */ readonly fullscreenTarget = input(null); @Output() timeUpdate = new EventEmitter<{ diff --git a/libs/ui/playback/src/lib/player-controls/controls-up-next.spec.ts b/libs/ui/playback/src/lib/player-controls/controls-up-next.spec.ts new file mode 100644 index 000000000..9da587632 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/controls-up-next.spec.ts @@ -0,0 +1,130 @@ +import { WritableSignal, signal } from '@angular/core'; +import { ControlsUpNext, UP_NEXT_THRESHOLD_SECONDS } from './controls-up-next'; +import { + DEFAULT_PLAYER_CAPABILITIES, + createEmptyControlsState, +} from './player-controls-defaults'; +import type { + PlayerControlsCapabilities, + PlayerControlsState, + PlayerUpNextItem, +} from './player-controls.model'; + +describe('ControlsUpNext', () => { + const next: PlayerUpNextItem = { + label: 'S01E03', + title: 'The Third One', + thumbnailUrl: null, + progressPercent: null, + }; + let item: WritableSignal; + let state: WritableSignal; + let capabilities: WritableSignal; + let showControls: WritableSignal; + let settingsOpen: WritableSignal; + let upNext: ControlsUpNext; + + const setState = (overrides: Partial) => + state.set({ ...createEmptyControlsState(), ...overrides }); + + beforeEach(() => { + item = signal(next); + state = signal(createEmptyControlsState()); + capabilities = signal({ + ...DEFAULT_PLAYER_CAPABILITIES, + seriesNavigation: true, + }); + showControls = signal(true); + settingsOpen = signal(false); + upNext = new ControlsUpNext({ + item, + state, + capabilities, + showControls, + settingsOpen, + }); + setState({ + canNextEpisode: true, + durationSeconds: 1200, + positionSeconds: 1200 - 7 * 60 - 3, + }); + }); + + it('shows the next episode inside the threshold with minutes left', () => { + expect(upNext.visible()).toBe(true); + expect(upNext.item()).toBe(next); + expect(upNext.remainingSeconds()).toBe(423); + expect(upNext.minutesLeft()).toBe(8); + }); + + it('stays hidden until the threshold and reports at least one minute', () => { + setState({ + canNextEpisode: true, + durationSeconds: 1200, + positionSeconds: 1200 - UP_NEXT_THRESHOLD_SECONDS - 1, + }); + expect(upNext.visible()).toBe(false); + + setState({ + canNextEpisode: true, + durationSeconds: 1200, + positionSeconds: 1200 - UP_NEXT_THRESHOLD_SECONDS, + }); + expect(upNext.visible()).toBe(true); + + setState({ + canNextEpisode: true, + durationSeconds: 1200, + positionSeconds: 1199.5, + }); + expect(upNext.minutesLeft()).toBe(1); + }); + + it('needs a next episode, a finite duration and a series-capable engine', () => { + item.set(null); + expect(upNext.visible()).toBe(false); + item.set(next); + + setState({ + canNextEpisode: false, + durationSeconds: 1200, + positionSeconds: 1100, + }); + expect(upNext.visible()).toBe(false); + + setState({ + canNextEpisode: true, + durationSeconds: null, + positionSeconds: 1100, + }); + expect(upNext.visible()).toBe(false); + expect(upNext.remainingSeconds()).toBeNull(); + + setState({ + canNextEpisode: true, + isLive: true, + durationSeconds: 1200, + positionSeconds: 1100, + }); + expect(upNext.visible()).toBe(false); + + setState({ + canNextEpisode: true, + durationSeconds: 1200, + positionSeconds: 1100, + }); + capabilities.set({ ...DEFAULT_PLAYER_CAPABILITIES }); + expect(upNext.visible()).toBe(false); + }); + + it('yields to hidden controls and to the open settings panel', () => { + showControls.set(false); + expect(upNext.visible()).toBe(false); + showControls.set(true); + + settingsOpen.set(true); + expect(upNext.visible()).toBe(false); + settingsOpen.set(false); + expect(upNext.visible()).toBe(true); + }); +}); diff --git a/libs/ui/playback/src/lib/player-controls/controls-up-next.ts b/libs/ui/playback/src/lib/player-controls/controls-up-next.ts new file mode 100644 index 000000000..d2dfbc39a --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/controls-up-next.ts @@ -0,0 +1,66 @@ +import { Signal, computed } from '@angular/core'; +import type { + PlayerControlsCapabilities, + PlayerControlsState, + PlayerUpNextItem, +} from './player-controls.model'; + +/** The card appears once this little of the episode is left. */ +export const UP_NEXT_THRESHOLD_SECONDS = 8 * 60; + +export interface ControlsUpNextDeps { + item: Signal; + state: Signal; + capabilities: Signal; + showControls: Signal; + /** The settings panel covers the card's corner; it yields while open. */ + settingsOpen: Signal; +} + +/** + * When the "Up next" card shows: a series host supplied the next episode, + * the engine can actually switch to it, the episode has a known length, + * and no more than the threshold of it is left. Live streams and open-ended + * VOD never qualify because they have no remaining time to count down. + */ +export class ControlsUpNext { + constructor(private readonly deps: ControlsUpNextDeps) {} + + readonly remainingSeconds = computed(() => { + const { durationSeconds, positionSeconds } = this.deps.state(); + if ( + typeof durationSeconds !== 'number' || + !Number.isFinite(durationSeconds) || + durationSeconds <= 0 + ) { + return null; + } + return Math.max(0, durationSeconds - Math.max(0, positionSeconds)); + }); + + readonly item = computed(() => { + const item = this.deps.item(); + const remaining = this.remainingSeconds(); + const state = this.deps.state(); + if ( + !item || + remaining === null || + remaining > UP_NEXT_THRESHOLD_SECONDS || + state.isLive || + !state.canNextEpisode || + !this.deps.capabilities().seriesNavigation || + !this.deps.showControls() || + this.deps.settingsOpen() + ) { + return null; + } + return item; + }); + + readonly visible = computed(() => this.item() !== null); + + /** Whole minutes left, never below one while the card is showing. */ + readonly minutesLeft = computed(() => + Math.max(1, Math.ceil((this.remainingSeconds() ?? 0) / 60)) + ); +} diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.dock.spec.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.dock.spec.ts index b3b20e9f0..4fd97aa9d 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.dock.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.dock.spec.ts @@ -393,6 +393,90 @@ describe('PlayerControlsComponent dock', () => { }); }); + describe('up next card', () => { + const nearTheEnd = () => + setState({ + canSeek: true, + canNextEpisode: true, + durationSeconds: 1200, + positionSeconds: 1200 - 5 * 60, + }); + + beforeEach(() => { + setCapabilities({ + seek: true, + seriesNavigation: true, + playbackSpeed: true, + }); + fixture.componentRef.setInput('upNext', { + label: 'S01E03', + title: 'The Third One', + thumbnailUrl: null, + progressPercent: null, + }); + fixture.detectChanges(); + }); + + it('appears within the last minutes and plays the next episode on click', () => { + setState({ + canSeek: true, + canNextEpisode: true, + durationSeconds: 1200, + positionSeconds: 60, + }); + fixture.detectChanges(); + expect( + query('[data-test-id="player-controls-up-next"]') + ).toBeNull(); + + nearTheEnd(); + fixture.detectChanges(); + const card = query('[data-test-id="player-controls-up-next"]'); + expect(card).not.toBeNull(); + expect(card?.textContent).toContain('The Third One'); + + const next = jest.fn(); + component.nextEpisodeRequested.subscribe(next); + card?.click(); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('yields to the settings panel and needs a next episode', () => { + nearTheEnd(); + fixture.detectChanges(); + expect( + query('[data-test-id="player-controls-up-next"]') + ).not.toBeNull(); + + component.settings.open('speed'); + fixture.detectChanges(); + expect( + query('[data-test-id="player-controls-up-next"]') + ).toBeNull(); + component.settings.close(); + fixture.detectChanges(); + expect( + query('[data-test-id="player-controls-up-next"]') + ).not.toBeNull(); + + fixture.componentRef.setInput('upNext', null); + fixture.detectChanges(); + expect( + query('[data-test-id="player-controls-up-next"]') + ).toBeNull(); + }); + + it('uses the compact card on a compact dock', () => { + nearTheEnd(); + resizeTo(400); + expect( + query('[data-test-id="player-controls-up-next"]')?.closest( + '.player-controls__up-next--compact' + ) + ).not.toBeNull(); + }); + }); + describe('layout modes', () => { beforeEach(() => { setCapabilities({ volume: true }); diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.html b/libs/ui/playback/src/lib/player-controls/player-controls.component.html index b994fc0a8..c4e552b9e 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.html +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.html @@ -689,4 +689,22 @@ (focusout)="chrome.onFocusOut($event)" /> } + + @if (upNextCard.item(); as item) { + + } } diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.scss b/libs/ui/playback/src/lib/player-controls/player-controls.component.scss index cd47bdc2e..33ba4a5bd 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.scss +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.scss @@ -287,6 +287,23 @@ animation: player-controls-sheet-in 220ms cubic-bezier(0.2, 0.8, 0.2, 1); } +// "Up next" card: bottom-right, above the dock. It does not fade with the +// controls — near the end of an episode it is the thing worth seeing. +.player-controls__up-next { + position: absolute; + right: 32px; + bottom: 136px; + z-index: 3; + max-width: calc(100cqw - 64px); + animation: player-controls-panel-in 220ms cubic-bezier(0.2, 0.8, 0.2, 1); +} + +.player-controls__up-next--compact { + right: 12px; + bottom: 92px; + max-width: calc(100cqw - 24px); +} + @keyframes player-controls-panel-in { from { opacity: 0; diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.ts index 34896ed38..c8586904f 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.ts +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.ts @@ -26,6 +26,7 @@ import { ControlsStreamStats } from './controls-stream-stats'; import { ControlsSurface } from './controls-surface'; import { ControlsTimeline } from './controls-timeline'; import { ControlsTimelineHover } from './controls-timeline-hover'; +import { ControlsUpNext } from './controls-up-next'; import { ControlsVisibility } from './controls-visibility'; import { createControlsViewModel } from './controls-view-model'; import { ControlsVolume } from './controls-volume'; @@ -36,8 +37,10 @@ import type { PlayerController, PlayerMediaTitle, PlayerTimelineSegment, + PlayerUpNextItem, } from './player-controls.model'; import { PlayerSettingsPanelComponent } from './player-settings-panel.component'; +import { PlayerUpNextCardComponent } from './player-up-next-card.component'; @Component({ selector: 'app-player-controls', @@ -49,6 +52,7 @@ import { PlayerSettingsPanelComponent } from './player-settings-panel.component' MatTooltipModule, TranslatePipe, PlayerSettingsPanelComponent, + PlayerUpNextCardComponent, ], changeDetection: ChangeDetectionStrategy.OnPush, host: { @@ -77,6 +81,8 @@ export class PlayerControlsComponent implements OnDestroy { readonly timelineSegments = input( null ); + /** The next episode, for the "Up next" card near the end of this one. */ + readonly upNext = input(null); readonly previousEpisodeRequested = output(); readonly nextEpisodeRequested = output(); readonly menus = new ControlsMenuState(); @@ -146,6 +152,13 @@ export class PlayerControlsComponent implements OnDestroy { commands: () => this.controller().commands, reveal: (options) => this.reveal(options), }); + readonly upNextCard = new ControlsUpNext({ + item: this.upNext, + state: this.state, + capabilities: this.capabilities, + showControls: this.showControls, + settingsOpen: this.settings.isOpen, + }); private readonly controllerVolume = computed(() => this.state().volume); readonly timeline = new ControlsTimeline(this.state, this.timelineSegments); readonly scrubPosition = this.timeline.scrubPosition; @@ -204,9 +217,6 @@ export class PlayerControlsComponent implements OnDestroy { readonly isPaused = this.vm.isPaused; readonly isPlaying = this.vm.isPlaying; readonly canTogglePlay = this.vm.canTogglePlay; - readonly hasAudioTracks = this.vm.hasAudioTracks; - readonly hasSubtitleTracks = this.vm.hasSubtitleTracks; - readonly hasQualityLevels = this.vm.hasQualityLevels; readonly canRecord = this.vm.canRecord; readonly isRecording = this.vm.isRecording; readonly recordingStatusText = this.vm.recordingStatusText; diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.model.ts b/libs/ui/playback/src/lib/player-controls/player-controls.model.ts index 7b21a4039..e4997b56a 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.model.ts +++ b/libs/ui/playback/src/lib/player-controls/player-controls.model.ts @@ -69,6 +69,19 @@ export interface PlayerTimelineSegment { title: string | null; } +/** + * The episode after the current one, for the "Up next" card. Series hosts + * build it from their episode list; the controls decide when to show it. + */ +export interface PlayerUpNextItem { + /** `S01E03`. */ + label: string; + title: string; + thumbnailUrl: string | null; + /** Watch progress in percent (0–100); null when never started. */ + progressPercent: number | null; +} + export interface PlayerPreset { value: T; label: string; diff --git a/libs/ui/playback/src/lib/player-controls/player-up-next-card.component.html b/libs/ui/playback/src/lib/player-controls/player-up-next-card.component.html new file mode 100644 index 000000000..52b5e4bc2 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/player-up-next-card.component.html @@ -0,0 +1,51 @@ + diff --git a/libs/ui/playback/src/lib/player-controls/player-up-next-card.component.scss b/libs/ui/playback/src/lib/player-controls/player-up-next-card.component.scss new file mode 100644 index 000000000..23121748c --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/player-up-next-card.component.scss @@ -0,0 +1,131 @@ +// Glass card; the controls host positions it (bottom-right, above the dock) +// and reads the `--pc-*` palette it declares. +:host { + display: block; + pointer-events: auto; +} + +.player-up-next__button { + display: flex; + align-items: center; + gap: 12px; + max-width: 320px; + padding: 8px 14px 8px 8px; + color: var(--pc-text, #e7ecf3); + background: var(--pc-glass-bg, rgba(12, 16, 23, 0.72)); + border: 1px solid var(--pc-glass-border, rgba(255, 255, 255, 0.09)); + border-radius: 14px; + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45); + backdrop-filter: blur(18px) saturate(1.3); + cursor: pointer; + font: inherit; + text-align: left; + transition: + background 120ms ease-out, + transform 120ms ease-out; +} + +.player-up-next__button:hover { + background: rgba(12, 16, 23, 0.84); + transform: translateY(-1px); +} + +.player-up-next__button:focus-visible { + outline: 2px solid var(--pc-text, #e7ecf3); + outline-offset: 2px; +} + +.player-up-next__thumb { + position: relative; + display: flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; + width: 88px; + height: 50px; + overflow: hidden; + border-radius: 8px; + background: rgba(255, 255, 255, 0.08); +} + +.player-up-next__image { + width: 100%; + height: 100%; + object-fit: cover; +} + +// No still: the episode label as a large numeral tile, so the card still +// looks designed without artwork. +.player-up-next__placeholder { + color: var(--pc-text-secondary, #9aa3b2); + font-size: 13px; + font-weight: 700; + letter-spacing: 0.04em; +} + +.player-up-next__progress { + position: absolute; + right: 0; + bottom: 0; + left: 0; + height: 3px; + background: rgba(255, 255, 255, 0.18); +} + +.player-up-next__progress-bar { + display: block; + height: 100%; + background: var(--pc-accent-blue, #4f8eff); +} + +.player-up-next__meta { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; +} + +.player-up-next__eyebrow { + color: var(--pc-text-secondary, #9aa3b2); + font-size: 11px; + letter-spacing: 0.04em; + text-transform: uppercase; + white-space: nowrap; +} + +.player-up-next__title { + overflow: hidden; + font-size: 14px; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.player-up-next__icon { + flex: 0 0 auto; + width: 22px; + height: 22px; + font-size: 22px; + color: var(--pc-text-secondary, #9aa3b2); +} + +:host(.player-up-next--compact) .player-up-next__button { + gap: 10px; + max-width: 240px; + padding: 5px 10px 5px 5px; + border-radius: 10px; +} + +:host(.player-up-next--compact) .player-up-next__thumb { + width: 56px; + height: 32px; + border-radius: 6px; +} + +:host(.player-up-next--compact) .player-up-next__eyebrow { + font-size: 9px; +} + +:host(.player-up-next--compact) .player-up-next__title { + font-size: 11.5px; +} diff --git a/libs/ui/playback/src/lib/player-controls/player-up-next-card.component.spec.ts b/libs/ui/playback/src/lib/player-controls/player-up-next-card.component.spec.ts new file mode 100644 index 000000000..e5af90248 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/player-up-next-card.component.spec.ts @@ -0,0 +1,89 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { PlayerUpNextCardComponent } from './player-up-next-card.component'; + +describe('PlayerUpNextCardComponent', () => { + let fixture: ComponentFixture; + + const query = (selector: string) => + fixture.nativeElement.querySelector(selector) as HTMLElement | null; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [PlayerUpNextCardComponent, TranslateModule.forRoot()], + }).compileComponents(); + const translate = TestBed.inject(TranslateService); + translate.setTranslation('en', { + EMBEDDED_MPV: { + PLAYER: { + UP_NEXT: 'Up next', + UP_NEXT_IN: 'in {{minutes}} min', + }, + }, + }); + translate.use('en'); + fixture = TestBed.createComponent(PlayerUpNextCardComponent); + fixture.componentRef.setInput('item', { + label: 'S01E03', + title: 'The Third One', + thumbnailUrl: 'https://img.example/still.jpg', + progressPercent: 40, + }); + fixture.componentRef.setInput('minutesLeft', 7); + fixture.detectChanges(); + }); + + afterEach(() => fixture.destroy()); + + it('renders the still, progress, countdown and title, and names itself', () => { + expect(query('.player-up-next__image')?.getAttribute('src')).toBe( + 'https://img.example/still.jpg' + ); + expect(query('.player-up-next__progress-bar')?.style.width).toBe('40%'); + expect( + query('.player-up-next__eyebrow')?.textContent?.replace(/\s+/g, ' ') + ).toContain('Up next · in 7 min'); + expect(query('.player-up-next__title')?.textContent?.trim()).toBe( + 'The Third One' + ); + expect( + query('[data-test-id="player-controls-up-next"]')?.getAttribute( + 'aria-label' + ) + ).toBe('Up next: S01E03 – The Third One'); + expect(query('.player-up-next__icon')).not.toBeNull(); + }); + + it('falls back to the label tile without a still and hides the icon when compact', () => { + fixture.componentRef.setInput('item', { + label: 'S02E01', + title: '', + thumbnailUrl: null, + progressPercent: null, + }); + fixture.componentRef.setInput('compact', true); + fixture.detectChanges(); + + expect(query('.player-up-next__image')).toBeNull(); + expect(query('.player-up-next__placeholder')?.textContent?.trim()).toBe( + 'S02E01' + ); + expect(query('.player-up-next__progress')).toBeNull(); + expect(query('.player-up-next__title')?.textContent?.trim()).toBe( + 'S02E01' + ); + expect(query('.player-up-next__icon')).toBeNull(); + expect(fixture.nativeElement.classList).toContain( + 'player-up-next--compact' + ); + }); + + it('emits on click', () => { + const selected = jest.fn(); + fixture.componentInstance.selected.subscribe(selected); + + query('[data-test-id="player-controls-up-next"]')?.click(); + + expect(selected).toHaveBeenCalledTimes(1); + }); +}); diff --git a/libs/ui/playback/src/lib/player-controls/player-up-next-card.component.ts b/libs/ui/playback/src/lib/player-controls/player-up-next-card.component.ts new file mode 100644 index 000000000..a027a38ec --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/player-up-next-card.component.ts @@ -0,0 +1,32 @@ +import { + ChangeDetectionStrategy, + Component, + input, + output, +} from '@angular/core'; +import { MatIconModule } from '@angular/material/icon'; +import { TranslatePipe } from '@ngx-translate/core'; +import type { PlayerUpNextItem } from './player-controls.model'; + +/** + * The "Up next" card in the player's bottom-right corner: thumbnail, the + * minutes left, the next episode's label and title. One click plays it + * through the host's ordinary next-episode path, so fullscreen survives. + */ +@Component({ + selector: 'app-player-up-next-card', + templateUrl: './player-up-next-card.component.html', + styleUrl: './player-up-next-card.component.scss', + imports: [MatIconModule, TranslatePipe], + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + class: 'player-up-next', + '[class.player-up-next--compact]': 'compact()', + }, +}) +export class PlayerUpNextCardComponent { + readonly item = input.required(); + readonly minutesLeft = input.required(); + readonly compact = input(false); + readonly selected = output(); +} diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-episode-panel.spec.ts b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-episode-panel.spec.ts index 1aa24df4b..3fd6fbe35 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-episode-panel.spec.ts +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-episode-panel.spec.ts @@ -24,6 +24,7 @@ class StubWebPlayerViewComponent { readonly streamUrl = input.required(); readonly title = input(''); readonly mediaTitle = input(null); + readonly upNext = input(null); readonly playback = input(null); readonly volume = input(1); readonly playerOverride = input(null); diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-sources.spec.ts b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-sources.spec.ts index 40064d879..a3effea62 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-sources.spec.ts +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-sources.spec.ts @@ -25,6 +25,7 @@ class StubWebPlayerViewComponent { readonly streamUrl = input.required(); readonly title = input(''); readonly mediaTitle = input(null); + readonly upNext = input(null); readonly playback = input(null); readonly volume = input(1); readonly playerOverride = input(null); diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-up-next.spec.ts b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-up-next.spec.ts index 4d6e9124c..6183ee287 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-up-next.spec.ts +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-up-next.spec.ts @@ -22,6 +22,7 @@ class StubWebPlayerViewComponent { readonly streamUrl = input.required(); readonly title = input(''); readonly mediaTitle = input(null); + readonly upNext = input(null); readonly playback = input(null); readonly volume = input(1); readonly playerOverride = input(null); @@ -115,6 +116,39 @@ describe('PortalInlinePlayerComponent up next rail', () => { }, ]; + it('hands the episode after the playing one to the player as "up next"', async () => { + await setup(true); + fixture.componentRef.setInput('playback', seriesPlayback); + fixture.componentRef.setInput('upNextEpisodes', upNextItems); + fixture.detectChanges(); + + expect(component.playerUpNext()).toEqual({ + label: 'S01E03', + title: 'Episode 3', + thumbnailUrl: null, + progressPercent: null, + }); + + // The last episode of the list has nothing after it. + fixture.componentRef.setInput('upNextEpisodes', [ + { ...upNextItems[1], isPlaying: true }, + ]); + fixture.detectChanges(); + expect(component.playerUpNext()).toBeNull(); + + // Only episodes get a card: a movie never has a "next". + fixture.componentRef.setInput('upNextEpisodes', upNextItems); + fixture.componentRef.setInput('playback', { + ...seriesPlayback, + contentInfo: { + ...seriesPlayback.contentInfo, + contentType: 'movie', + }, + }); + fixture.detectChanges(); + expect(component.playerUpNext()).toBeNull(); + }); + /** Renders, then feeds a stage size as the ResizeObserver would. */ function renderWithStage(width: number, height: number): void { fixture.detectChanges(); diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html index 387a7ed98..02896ff8a 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html @@ -77,6 +77,7 @@ [streamUrl]="streamUrl()" [title]="title()" [mediaTitle]="playerMediaTitle()" + [upNext]="playerUpNext()" [playback]="playback()" [volume]="volume()" [playerOverride]="playerOverride()" diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.spec.ts b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.spec.ts index cfd0ee8e3..7b0a16165 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.spec.ts +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.spec.ts @@ -25,6 +25,7 @@ class StubWebPlayerViewComponent { readonly streamUrl = input.required(); readonly title = input(''); readonly mediaTitle = input(null); + readonly upNext = input(null); readonly playback = input(null); readonly volume = input(1); readonly playerOverride = input(null); diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts index 3b810ba97..b92088563 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts @@ -28,7 +28,7 @@ import type { PlaybackFallbackRequest } from '@iptvnator/playback/util'; import type { PlaybackDiagnosticCode } from '@iptvnator/playback/util'; import { SettingsStore } from '@iptvnator/services'; import { applyChannelNameStrip } from '@iptvnator/shared/m3u-utils'; -import type { PlayerMediaTitle } from '../player-controls'; +import type { PlayerMediaTitle, PlayerUpNextItem } from '../player-controls'; import { FULLSCREEN_CHANNEL_PANEL, type FullscreenChannelPanelContext, @@ -224,6 +224,21 @@ export class PortalInlinePlayerComponent { readonly upNextRailItems = computed( () => this.upNextEpisodes() ?? [] ); + /** The episode after the playing one, for the controls' "Up next" card. */ + readonly playerUpNext = computed(() => { + const items = this.upNextEpisodes() ?? []; + const playing = items.findIndex((item) => item.isPlaying); + const next = playing >= 0 ? items[playing + 1] : undefined; + if (!next || this.playback()?.contentInfo?.contentType !== 'episode') { + return null; + } + return { + label: next.label, + title: next.title, + thumbnailUrl: next.thumbnailUrl, + progressPercent: next.progressPercent, + }; + }); private readonly fullscreenEpisodePanelTemplate = viewChild< TemplateRef diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.html b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.html index 582657c9a..331a38777 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.html +++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.html @@ -14,6 +14,7 @@ [playerSurface]="playerRoot" [fullscreenTarget]="fullscreenTarget()" [mediaTitle]="mediaTitle()" + [upNext]="upNext()" [showControls]="interactionEnabled()" [shortcutsEnabled]="interactionEnabled()" (previousEpisodeRequested)="previousEpisodeRequested.emit()" diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts index ca208b60e..ab92365dd 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts +++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts @@ -24,6 +24,7 @@ import { type LegacyPlayerShortcuts, PlayerControlsComponent, type PlayerMediaTitle, + PlayerUpNextItem, WEB_PLAYER_SHARED_CONTROLS, WebVideoControlsAdapter, } from '../player-controls'; @@ -77,6 +78,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { readonly interactionEnabled = input(true); readonly showCaptions = input(false); readonly mediaTitle = input(null); + readonly upNext = input(null); /** See `PlayerControlsComponent.fullscreenTarget`; null keeps the shell. */ readonly fullscreenTarget = input(null); diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html index 58c30608f..5b204c4a9 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html @@ -7,6 +7,7 @@ [options]="options" [fullscreenTarget]="fullscreenSurface" [mediaTitle]="resolvedMediaTitle()" + [upNext]="upNext()" [volume]="volume()" [showCaptions]="showCaptions()" [interactionEnabled]="playbackInteractionEnabled()" @@ -35,6 +36,7 @@ [channel]="$any(channel)" [fullscreenTarget]="fullscreenSurface" [mediaTitle]="resolvedMediaTitle()" + [upNext]="upNext()" [volume]="volume()" [showCaptions]="showCaptions()" [isLive]="resolvedIsLive()" @@ -64,6 +66,7 @@ [channel]="$any(channel)" [fullscreenTarget]="fullscreenSurface" [mediaTitle]="resolvedMediaTitle()" + [upNext]="upNext()" [volume]="volume()" [showCaptions]="showCaptions()" [isLive]="resolvedIsLive()" @@ -93,6 +96,7 @@ [playback]="resolvedPlayback()" [fullscreenTarget]="fullscreenSurface" [mediaTitle]="resolvedMediaTitle()" + [upNext]="upNext()" [recordingFolder]="recordingFolder()" [recordingMetadata]="recordingMetadata()" [seriesNavigation]="seriesNavigation()" diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts index 81d391b7d..7cbf5a49d 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts @@ -1,4 +1,3 @@ -import { Component, input, output, signal } from '@angular/core'; import { ComponentFixture, DeferBlockBehavior, @@ -13,12 +12,7 @@ import { VodSourceRowComponent } from '@iptvnator/ui/components'; import { StorageMap } from '@ngx-pwa/local-storage'; import { TranslateModule } from '@ngx-translate/core'; import { of } from 'rxjs'; -import { - VideoPlayer, - type EmbeddedMpvSupport, - type RecordingStartMetadata, - type RecordingStoppedEvent, -} from '@iptvnator/shared/interfaces'; +import { VideoPlayer } from '@iptvnator/shared/interfaces'; import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services'; import { ErrorDetails, ErrorTypes } from 'hls.js'; import type { WebPlayerViewComponent as WebPlayerViewComponentInstance } from './web-player-view.component'; @@ -35,7 +29,13 @@ import { getDiagnosticMeta, getDiagnosticTitleKey, } from '../playback-diagnostic-panel/playback-diagnostic-view.util'; -import { StubFullscreenChannelPanelComponent } from './web-player-view.spec-stubs'; +import { + StubArtPlayerComponent, + StubEmbeddedMpvPlayerComponent, + StubFullscreenChannelPanelComponent, + StubHtmlVideoPlayerComponent, + StubVjsPlayerComponent, +} from './web-player-view.spec-stubs'; jest.unstable_mockModule('video.js', () => ({ default: jest.fn(), @@ -45,88 +45,6 @@ jest.unstable_mockModule('@yangkghjh/videojs-aspect-ratio-panel', () => ({})); jest.unstable_mockModule('videojs-contrib-quality-levels', () => ({})); jest.unstable_mockModule('videojs-quality-selector-hls', () => ({})); -@Component({ - selector: 'app-vjs-player', - template: '
', -}) -class StubVjsPlayerComponent { - readonly options = input(); - readonly fullscreenTarget = input(null); - readonly mediaTitle = input(null); - readonly volume = input(1); - readonly showCaptions = input(false); - readonly interactionEnabled = input(true); - readonly startTime = input(0); - readonly seriesNavigation = input(null); - readonly timeUpdate = output<{ currentTime: number; duration: number }>(); - readonly playbackIssue = output(); - readonly playbackEnded = output(); - readonly previousEpisodeRequested = output(); - readonly nextEpisodeRequested = output(); -} - -@Component({ - selector: 'app-html-video-player', - template: '
', -}) -class StubHtmlVideoPlayerComponent { - readonly channel = input(); - readonly fullscreenTarget = input(null); - readonly mediaTitle = input(null); - readonly volume = input(1); - readonly showCaptions = input(false); - readonly isLive = input(true); - readonly interactionEnabled = input(true); - readonly startTime = input(0); - readonly seriesNavigation = input(null); - readonly timeUpdate = output<{ currentTime: number; duration: number }>(); - readonly playbackIssue = output(); - readonly playbackEnded = output(); - readonly previousEpisodeRequested = output(); - readonly nextEpisodeRequested = output(); -} - -@Component({ - selector: 'app-art-player', - template: '
', -}) -class StubArtPlayerComponent { - readonly channel = input(); - readonly fullscreenTarget = input(null); - readonly mediaTitle = input(null); - readonly volume = input(1); - readonly showCaptions = input(false); - readonly isLive = input(true); - readonly interactionEnabled = input(true); - readonly startTime = input(0); - readonly seriesNavigation = input(null); - readonly timeUpdate = output<{ currentTime: number; duration: number }>(); - readonly playbackIssue = output(); - readonly playbackEnded = output(); - readonly previousEpisodeRequested = output(); - readonly nextEpisodeRequested = output(); -} - -@Component({ - selector: 'app-embedded-mpv-player', - template: '
', -}) -class StubEmbeddedMpvPlayerComponent { - /** Read by the view's channel-panel gate (`EmbeddedMpvEngineReporter`). */ - readonly support = signal(null); - readonly playback = input.required(); - readonly fullscreenTarget = input(null); - readonly mediaTitle = input(null); - readonly recordingFolder = input(''); - readonly recordingMetadata = input(null); - readonly seriesNavigation = input(null); - readonly timeUpdate = output<{ currentTime: number; duration: number }>(); - readonly playbackEnded = output(); - readonly previousEpisodeRequested = output(); - readonly nextEpisodeRequested = output(); - readonly recordingStopped = output(); -} - describe('WebPlayerViewComponent', () => { let WebPlayerViewComponent: typeof import('./web-player-view.component').WebPlayerViewComponent; let fixture: ComponentFixture; diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts index 75be963e1..b567e3f35 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts @@ -38,6 +38,7 @@ import { HtmlVideoPlayerComponent } from '../html-video-player/html-video-player import { PlaybackDiagnosticPanelComponent } from '../playback-diagnostic-panel/playback-diagnostic-panel.component'; import { type PlayerMediaTitle, + PlayerUpNextItem, WEB_PLAYER_SHARED_CONTROLS, } from '../player-controls'; import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation'; @@ -134,6 +135,8 @@ export class WebPlayerViewComponent implements OnDestroy { readonly playerOverride = input(null); readonly seriesNavigation = input(null); readonly mediaTitle = input(null); + /** Next episode for the shared controls' "Up next" card; series hosts only. */ + readonly upNext = input(null); readonly alternativeSources = input([]); /** Channel/EPG snapshot for the embedded-MPV recording tracker. */ readonly recordingMetadata = input(null); diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.spec-stubs.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.spec-stubs.ts index fb57beae0..1b67d68d8 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.spec-stubs.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.spec-stubs.ts @@ -20,6 +20,7 @@ export class StubVjsPlayerComponent { readonly options = input(); readonly fullscreenTarget = input(null); readonly mediaTitle = input(null); + readonly upNext = input(null); readonly volume = input(1); readonly showCaptions = input(false); readonly interactionEnabled = input(true); @@ -41,6 +42,7 @@ export class StubHtmlVideoPlayerComponent { readonly channel = input(); readonly fullscreenTarget = input(null); readonly mediaTitle = input(null); + readonly upNext = input(null); readonly volume = input(1); readonly showCaptions = input(false); readonly isLive = input(true); @@ -63,6 +65,7 @@ export class StubArtPlayerComponent { readonly channel = input(); readonly fullscreenTarget = input(null); readonly mediaTitle = input(null); + readonly upNext = input(null); readonly volume = input(1); readonly showCaptions = input(false); readonly isLive = input(true); @@ -95,6 +98,7 @@ export class StubEmbeddedMpvPlayerComponent { readonly playback = input.required(); readonly fullscreenTarget = input(null); readonly mediaTitle = input(null); + readonly upNext = input(null); readonly recordingFolder = input(''); readonly recordingMetadata = input(null); readonly seriesNavigation = input(null);