mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat(playback): show an "Up next" card in the last minutes of an episode
Phase 4 of the "Hybrid" player controls redesign. Series hosts hand the episode after the playing one to the shared controls (`upNext` on PortalInlinePlayerComponent → WebPlayerViewComponent → the four engine hosts → app-player-controls). ControlsUpNext shows the glass card once at most eight minutes remain on a seekable episode with a next-episode capable engine; it yields to the settings panel and to hidden controls. A click emits nextEpisodeRequested, so the switch keeps fullscreen like the transport button. Compact docks get a smaller card. Also dedupes the web-player-view spec's local engine stubs in favour of the shared stub file its sibling specs already use. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
e2a846b430
commit
d0c1d49aee
51 files changed
+856
-96
No files matched your search
@@ -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.
|
||||
@@ -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,
|
||||
|
||||
@@ -622,6 +622,8 @@
|
||||
"CLEAR_SEARCH": "مسح البحث",
|
||||
"LOAD_SUBTITLE_FILE": "تحميل ملف الترجمة…",
|
||||
"SETTINGS": "الإعدادات",
|
||||
"UP_NEXT": "التالي",
|
||||
"UP_NEXT_IN": "خلال {{minutes}} دقيقة",
|
||||
"CLOSE_SETTINGS": "إغلاق الإعدادات",
|
||||
"SUBTITLE_DELAY": "تأخير الترجمة",
|
||||
"SUBTITLE_DELAY_DECREASE": "عرض الترجمة مبكرًا",
|
||||
|
||||
@@ -622,6 +622,8 @@
|
||||
"CLEAR_SEARCH": "مسح البحث",
|
||||
"LOAD_SUBTITLE_FILE": "تحميل ملف الترجمة…",
|
||||
"SETTINGS": "الإعدادات",
|
||||
"UP_NEXT": "اللي جاي",
|
||||
"UP_NEXT_IN": "فـ {{minutes}} دقيقة",
|
||||
"CLOSE_SETTINGS": "سد الإعدادات",
|
||||
"SUBTITLE_DELAY": "تأخير الترجمة",
|
||||
"SUBTITLE_DELAY_DECREASE": "ورّي الترجمة قبل",
|
||||
|
||||
@@ -622,6 +622,8 @@
|
||||
"CLEAR_SEARCH": "Ачысціць пошук",
|
||||
"LOAD_SUBTITLE_FILE": "Загрузіць файл субтытраў…",
|
||||
"SETTINGS": "Налады",
|
||||
"UP_NEXT": "Далей",
|
||||
"UP_NEXT_IN": "праз {{minutes}} хв",
|
||||
"CLOSE_SETTINGS": "Закрыць налады",
|
||||
"SUBTITLE_DELAY": "Затрымка субтытраў",
|
||||
"SUBTITLE_DELAY_DECREASE": "Паказваць субтытры раней",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -622,6 +622,8 @@
|
||||
"CLEAR_SEARCH": "Καθαρισμός αναζήτησης",
|
||||
"LOAD_SUBTITLE_FILE": "Φόρτωση αρχείου υποτίτλων…",
|
||||
"SETTINGS": "Ρυθμίσεις",
|
||||
"UP_NEXT": "Επόμενο",
|
||||
"UP_NEXT_IN": "σε {{minutes}} λεπτά",
|
||||
"CLOSE_SETTINGS": "Κλείσιμο ρυθμίσεων",
|
||||
"SUBTITLE_DELAY": "Καθυστέρηση υποτίτλων",
|
||||
"SUBTITLE_DELAY_DECREASE": "Εμφάνιση υποτίτλων νωρίτερα",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -622,6 +622,8 @@
|
||||
"CLEAR_SEARCH": "検索をクリア",
|
||||
"LOAD_SUBTITLE_FILE": "字幕ファイルを読み込む…",
|
||||
"SETTINGS": "設定",
|
||||
"UP_NEXT": "次のエピソード",
|
||||
"UP_NEXT_IN": "あと{{minutes}}分",
|
||||
"CLOSE_SETTINGS": "設定を閉じる",
|
||||
"SUBTITLE_DELAY": "字幕の遅延",
|
||||
"SUBTITLE_DELAY_DECREASE": "字幕を早く表示",
|
||||
|
||||
@@ -622,6 +622,8 @@
|
||||
"CLEAR_SEARCH": "검색 지우기",
|
||||
"LOAD_SUBTITLE_FILE": "자막 파일 불러오기…",
|
||||
"SETTINGS": "설정",
|
||||
"UP_NEXT": "다음 에피소드",
|
||||
"UP_NEXT_IN": "{{minutes}}분 후",
|
||||
"CLOSE_SETTINGS": "설정 닫기",
|
||||
"SUBTITLE_DELAY": "자막 지연",
|
||||
"SUBTITLE_DELAY_DECREASE": "자막을 더 빨리 표시",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -622,6 +622,8 @@
|
||||
"CLEAR_SEARCH": "Очистить поиск",
|
||||
"LOAD_SUBTITLE_FILE": "Загрузить файл субтитров…",
|
||||
"SETTINGS": "Настройки",
|
||||
"UP_NEXT": "Далее",
|
||||
"UP_NEXT_IN": "через {{minutes}} мин",
|
||||
"CLOSE_SETTINGS": "Закрыть настройки",
|
||||
"SUBTITLE_DELAY": "Задержка субтитров",
|
||||
"SUBTITLE_DELAY_DECREASE": "Показывать субтитры раньше",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -622,6 +622,8 @@
|
||||
"CLEAR_SEARCH": "清除搜索",
|
||||
"LOAD_SUBTITLE_FILE": "加载字幕文件…",
|
||||
"SETTINGS": "设置",
|
||||
"UP_NEXT": "接下来",
|
||||
"UP_NEXT_IN": "{{minutes}} 分钟后",
|
||||
"CLOSE_SETTINGS": "关闭设置",
|
||||
"SUBTITLE_DELAY": "字幕延迟",
|
||||
"SUBTITLE_DELAY_DECREASE": "提前显示字幕",
|
||||
|
||||
@@ -622,6 +622,8 @@
|
||||
"CLEAR_SEARCH": "清除搜尋",
|
||||
"LOAD_SUBTITLE_FILE": "載入字幕檔案…",
|
||||
"SETTINGS": "設定",
|
||||
"UP_NEXT": "接下來",
|
||||
"UP_NEXT_IN": "{{minutes}} 分鐘後",
|
||||
"CLOSE_SETTINGS": "關閉設定",
|
||||
"SUBTITLE_DELAY": "字幕延遲",
|
||||
"SUBTITLE_DELAY_DECREASE": "提前顯示字幕",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
[playerSurface]="playerRoot"
|
||||
[fullscreenTarget]="fullscreenTarget()"
|
||||
[mediaTitle]="mediaTitle()"
|
||||
[upNext]="upNext()"
|
||||
[showControls]="interactionEnabled()"
|
||||
[shortcutsEnabled]="interactionEnabled()"
|
||||
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
|
||||
|
||||
@@ -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<PlayerMediaTitle | null>(null);
|
||||
readonly upNext = input<PlayerUpNextItem | null>(null);
|
||||
/** See `PlayerControlsComponent.fullscreenTarget`; null keeps the shell. */
|
||||
readonly fullscreenTarget = input<HTMLElement | null>(null);
|
||||
|
||||
|
||||
@@ -103,6 +103,7 @@
|
||||
[playerSurface]="playerSurface()"
|
||||
[fullscreenTarget]="fullscreenTarget()"
|
||||
[mediaTitle]="mediaTitle()"
|
||||
[upNext]="upNext()"
|
||||
[showControls]="showControls()"
|
||||
[shortcutsEnabled]="sharedShortcutsEnabled()"
|
||||
(previousEpisodeRequested)="requestPreviousEpisode()"
|
||||
|
||||
@@ -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<RecordingStartMetadata | null>(null);
|
||||
readonly seriesNavigation = input<SeriesPlaybackNavigation | null>(null);
|
||||
readonly mediaTitle = input<PlayerMediaTitle | null>(null);
|
||||
readonly upNext = input<PlayerUpNextItem | null>(null);
|
||||
/** See `PlayerControlsComponent.fullscreenTarget`; null keeps the root. */
|
||||
readonly fullscreenTarget = input<HTMLElement | null>(null);
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
[playerSurface]="playerRoot"
|
||||
[fullscreenTarget]="fullscreenTarget()"
|
||||
[mediaTitle]="mediaTitle()"
|
||||
[upNext]="upNext()"
|
||||
[showControls]="interactionEnabled()"
|
||||
[shortcutsEnabled]="interactionEnabled()"
|
||||
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
|
||||
|
||||
@@ -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<PlayerMediaTitle | null>(null);
|
||||
readonly upNext = input<PlayerUpNextItem | null>(null);
|
||||
/** See `PlayerControlsComponent.fullscreenTarget`; null keeps the shell. */
|
||||
readonly fullscreenTarget = input<HTMLElement | null>(null);
|
||||
@Output() timeUpdate = new EventEmitter<{
|
||||
|
||||
@@ -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<PlayerUpNextItem | null>;
|
||||
let state: WritableSignal<PlayerControlsState>;
|
||||
let capabilities: WritableSignal<PlayerControlsCapabilities>;
|
||||
let showControls: WritableSignal<boolean>;
|
||||
let settingsOpen: WritableSignal<boolean>;
|
||||
let upNext: ControlsUpNext;
|
||||
|
||||
const setState = (overrides: Partial<PlayerControlsState>) =>
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<PlayerUpNextItem | null>;
|
||||
state: Signal<PlayerControlsState>;
|
||||
capabilities: Signal<PlayerControlsCapabilities>;
|
||||
showControls: Signal<boolean>;
|
||||
/** The settings panel covers the card's corner; it yields while open. */
|
||||
settingsOpen: Signal<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<PlayerUpNextItem | null>(() => {
|
||||
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))
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -689,4 +689,22 @@
|
||||
(focusout)="chrome.onFocusOut($event)"
|
||||
/>
|
||||
}
|
||||
|
||||
@if (upNextCard.item(); as item) {
|
||||
<app-player-up-next-card
|
||||
class="player-controls__up-next"
|
||||
[class.player-controls__up-next--compact]="isCompact()"
|
||||
[item]="item"
|
||||
[minutesLeft]="upNextCard.minutesLeft()"
|
||||
[compact]="isCompact()"
|
||||
(selected)="requestNextEpisode()"
|
||||
(pointerenter)="chrome.onPointerEnter()"
|
||||
(pointerleave)="chrome.onPointerLeave()"
|
||||
(pointerdown)="chrome.onPointerDown()"
|
||||
(click)="chrome.onClick($event)"
|
||||
(keydown)="chrome.onKeyDown()"
|
||||
(focusin)="chrome.onFocusIn($event)"
|
||||
(focusout)="chrome.onFocusOut($event)"
|
||||
/>
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<readonly PlayerTimelineSegment[] | null>(
|
||||
null
|
||||
);
|
||||
/** The next episode, for the "Up next" card near the end of this one. */
|
||||
readonly upNext = input<PlayerUpNextItem | null>(null);
|
||||
readonly previousEpisodeRequested = output<void>();
|
||||
readonly nextEpisodeRequested = output<void>();
|
||||
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;
|
||||
|
||||
@@ -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<T> {
|
||||
value: T;
|
||||
label: string;
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<button
|
||||
type="button"
|
||||
class="player-up-next__button"
|
||||
data-test-id="player-controls-up-next"
|
||||
(click)="selected.emit()"
|
||||
[attr.aria-label]="
|
||||
('EMBEDDED_MPV.PLAYER.UP_NEXT' | translate) +
|
||||
': ' +
|
||||
item().label +
|
||||
(item().title ? ' – ' + item().title : '')
|
||||
"
|
||||
>
|
||||
<span class="player-up-next__thumb" aria-hidden="true">
|
||||
@if (item().thumbnailUrl) {
|
||||
<img
|
||||
class="player-up-next__image"
|
||||
[src]="item().thumbnailUrl"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
/>
|
||||
} @else {
|
||||
<span class="player-up-next__placeholder">{{ item().label }}</span>
|
||||
}
|
||||
@if (item().progressPercent !== null) {
|
||||
<span class="player-up-next__progress">
|
||||
<span
|
||||
class="player-up-next__progress-bar"
|
||||
[style.width.%]="item().progressPercent"
|
||||
></span>
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
<span class="player-up-next__meta">
|
||||
<span class="player-up-next__eyebrow">
|
||||
{{ 'EMBEDDED_MPV.PLAYER.UP_NEXT' | translate }}
|
||||
·
|
||||
{{
|
||||
'EMBEDDED_MPV.PLAYER.UP_NEXT_IN'
|
||||
| translate: { minutes: minutesLeft() }
|
||||
}}
|
||||
</span>
|
||||
<span class="player-up-next__title">
|
||||
{{ item().title || item().label }}
|
||||
</span>
|
||||
</span>
|
||||
@if (!compact()) {
|
||||
<mat-icon class="player-up-next__icon" aria-hidden="true"
|
||||
>skip_next</mat-icon
|
||||
>
|
||||
}
|
||||
</button>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<PlayerUpNextCardComponent>;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<PlayerUpNextItem>();
|
||||
readonly minutesLeft = input.required<number>();
|
||||
readonly compact = input(false);
|
||||
readonly selected = output<void>();
|
||||
}
|
||||
+1
@@ -24,6 +24,7 @@ class StubWebPlayerViewComponent {
|
||||
readonly streamUrl = input.required<string>();
|
||||
readonly title = input('');
|
||||
readonly mediaTitle = input<unknown>(null);
|
||||
readonly upNext = input<unknown>(null);
|
||||
readonly playback = input<unknown>(null);
|
||||
readonly volume = input(1);
|
||||
readonly playerOverride = input<unknown>(null);
|
||||
|
||||
@@ -25,6 +25,7 @@ class StubWebPlayerViewComponent {
|
||||
readonly streamUrl = input.required<string>();
|
||||
readonly title = input('');
|
||||
readonly mediaTitle = input<unknown>(null);
|
||||
readonly upNext = input<unknown>(null);
|
||||
readonly playback = input<unknown>(null);
|
||||
readonly volume = input(1);
|
||||
readonly playerOverride = input<unknown>(null);
|
||||
|
||||
@@ -22,6 +22,7 @@ class StubWebPlayerViewComponent {
|
||||
readonly streamUrl = input.required<string>();
|
||||
readonly title = input('');
|
||||
readonly mediaTitle = input<unknown>(null);
|
||||
readonly upNext = input<unknown>(null);
|
||||
readonly playback = input<unknown>(null);
|
||||
readonly volume = input(1);
|
||||
readonly playerOverride = input<unknown>(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();
|
||||
|
||||
@@ -77,6 +77,7 @@
|
||||
[streamUrl]="streamUrl()"
|
||||
[title]="title()"
|
||||
[mediaTitle]="playerMediaTitle()"
|
||||
[upNext]="playerUpNext()"
|
||||
[playback]="playback()"
|
||||
[volume]="volume()"
|
||||
[playerOverride]="playerOverride()"
|
||||
|
||||
@@ -25,6 +25,7 @@ class StubWebPlayerViewComponent {
|
||||
readonly streamUrl = input.required<string>();
|
||||
readonly title = input('');
|
||||
readonly mediaTitle = input<unknown>(null);
|
||||
readonly upNext = input<unknown>(null);
|
||||
readonly playback = input<unknown>(null);
|
||||
readonly volume = input(1);
|
||||
readonly playerOverride = input<unknown>(null);
|
||||
|
||||
@@ -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<UpNextRailItem[]>(
|
||||
() => this.upNextEpisodes() ?? []
|
||||
);
|
||||
/** The episode after the playing one, for the controls' "Up next" card. */
|
||||
readonly playerUpNext = computed<PlayerUpNextItem | null>(() => {
|
||||
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<FullscreenChannelPanelContext>
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
[playerSurface]="playerRoot"
|
||||
[fullscreenTarget]="fullscreenTarget()"
|
||||
[mediaTitle]="mediaTitle()"
|
||||
[upNext]="upNext()"
|
||||
[showControls]="interactionEnabled()"
|
||||
[shortcutsEnabled]="interactionEnabled()"
|
||||
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
|
||||
|
||||
@@ -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<PlayerMediaTitle | null>(null);
|
||||
readonly upNext = input<PlayerUpNextItem | null>(null);
|
||||
/** See `PlayerControlsComponent.fullscreenTarget`; null keeps the shell. */
|
||||
readonly fullscreenTarget = input<HTMLElement | null>(null);
|
||||
|
||||
|
||||
@@ -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()"
|
||||
|
||||
@@ -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: '<div data-test-id="stub-vjs-player"></div>',
|
||||
})
|
||||
class StubVjsPlayerComponent {
|
||||
readonly options = input<unknown>();
|
||||
readonly fullscreenTarget = input<HTMLElement | null>(null);
|
||||
readonly mediaTitle = input<unknown>(null);
|
||||
readonly volume = input(1);
|
||||
readonly showCaptions = input(false);
|
||||
readonly interactionEnabled = input(true);
|
||||
readonly startTime = input(0);
|
||||
readonly seriesNavigation = input<unknown>(null);
|
||||
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
|
||||
readonly playbackIssue = output<PlaybackDiagnostic | null>();
|
||||
readonly playbackEnded = output<void>();
|
||||
readonly previousEpisodeRequested = output<void>();
|
||||
readonly nextEpisodeRequested = output<void>();
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-html-video-player',
|
||||
template: '<div data-test-id="stub-html-player"></div>',
|
||||
})
|
||||
class StubHtmlVideoPlayerComponent {
|
||||
readonly channel = input<unknown>();
|
||||
readonly fullscreenTarget = input<HTMLElement | null>(null);
|
||||
readonly mediaTitle = input<unknown>(null);
|
||||
readonly volume = input(1);
|
||||
readonly showCaptions = input(false);
|
||||
readonly isLive = input(true);
|
||||
readonly interactionEnabled = input(true);
|
||||
readonly startTime = input(0);
|
||||
readonly seriesNavigation = input<unknown>(null);
|
||||
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
|
||||
readonly playbackIssue = output<PlaybackDiagnostic | null>();
|
||||
readonly playbackEnded = output<void>();
|
||||
readonly previousEpisodeRequested = output<void>();
|
||||
readonly nextEpisodeRequested = output<void>();
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-art-player',
|
||||
template: '<div data-test-id="stub-art-player"></div>',
|
||||
})
|
||||
class StubArtPlayerComponent {
|
||||
readonly channel = input<unknown>();
|
||||
readonly fullscreenTarget = input<HTMLElement | null>(null);
|
||||
readonly mediaTitle = input<unknown>(null);
|
||||
readonly volume = input(1);
|
||||
readonly showCaptions = input(false);
|
||||
readonly isLive = input(true);
|
||||
readonly interactionEnabled = input(true);
|
||||
readonly startTime = input(0);
|
||||
readonly seriesNavigation = input<unknown>(null);
|
||||
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
|
||||
readonly playbackIssue = output<PlaybackDiagnostic | null>();
|
||||
readonly playbackEnded = output<void>();
|
||||
readonly previousEpisodeRequested = output<void>();
|
||||
readonly nextEpisodeRequested = output<void>();
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-embedded-mpv-player',
|
||||
template: '<div data-test-id="stub-embedded-mpv-player"></div>',
|
||||
})
|
||||
class StubEmbeddedMpvPlayerComponent {
|
||||
/** Read by the view's channel-panel gate (`EmbeddedMpvEngineReporter`). */
|
||||
readonly support = signal<EmbeddedMpvSupport | null>(null);
|
||||
readonly playback = input.required<unknown>();
|
||||
readonly fullscreenTarget = input<HTMLElement | null>(null);
|
||||
readonly mediaTitle = input<unknown>(null);
|
||||
readonly recordingFolder = input('');
|
||||
readonly recordingMetadata = input<RecordingStartMetadata | null>(null);
|
||||
readonly seriesNavigation = input<unknown>(null);
|
||||
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
|
||||
readonly playbackEnded = output<void>();
|
||||
readonly previousEpisodeRequested = output<void>();
|
||||
readonly nextEpisodeRequested = output<void>();
|
||||
readonly recordingStopped = output<RecordingStoppedEvent>();
|
||||
}
|
||||
|
||||
describe('WebPlayerViewComponent', () => {
|
||||
let WebPlayerViewComponent: typeof import('./web-player-view.component').WebPlayerViewComponent;
|
||||
let fixture: ComponentFixture<WebPlayerViewComponentInstance>;
|
||||
|
||||
@@ -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<VideoPlayer | null>(null);
|
||||
readonly seriesNavigation = input<SeriesPlaybackNavigation | null>(null);
|
||||
readonly mediaTitle = input<PlayerMediaTitle | null>(null);
|
||||
/** Next episode for the shared controls' "Up next" card; series hosts only. */
|
||||
readonly upNext = input<PlayerUpNextItem | null>(null);
|
||||
readonly alternativeSources = input<VodSourceDescriptor[]>([]);
|
||||
/** Channel/EPG snapshot for the embedded-MPV recording tracker. */
|
||||
readonly recordingMetadata = input<RecordingStartMetadata | null>(null);
|
||||
|
||||
@@ -20,6 +20,7 @@ export class StubVjsPlayerComponent {
|
||||
readonly options = input<unknown>();
|
||||
readonly fullscreenTarget = input<HTMLElement | null>(null);
|
||||
readonly mediaTitle = input<unknown>(null);
|
||||
readonly upNext = input<unknown>(null);
|
||||
readonly volume = input(1);
|
||||
readonly showCaptions = input(false);
|
||||
readonly interactionEnabled = input(true);
|
||||
@@ -41,6 +42,7 @@ export class StubHtmlVideoPlayerComponent {
|
||||
readonly channel = input<unknown>();
|
||||
readonly fullscreenTarget = input<HTMLElement | null>(null);
|
||||
readonly mediaTitle = input<unknown>(null);
|
||||
readonly upNext = input<unknown>(null);
|
||||
readonly volume = input(1);
|
||||
readonly showCaptions = input(false);
|
||||
readonly isLive = input(true);
|
||||
@@ -63,6 +65,7 @@ export class StubArtPlayerComponent {
|
||||
readonly channel = input<unknown>();
|
||||
readonly fullscreenTarget = input<HTMLElement | null>(null);
|
||||
readonly mediaTitle = input<unknown>(null);
|
||||
readonly upNext = input<unknown>(null);
|
||||
readonly volume = input(1);
|
||||
readonly showCaptions = input(false);
|
||||
readonly isLive = input(true);
|
||||
@@ -95,6 +98,7 @@ export class StubEmbeddedMpvPlayerComponent {
|
||||
readonly playback = input.required<unknown>();
|
||||
readonly fullscreenTarget = input<HTMLElement | null>(null);
|
||||
readonly mediaTitle = input<unknown>(null);
|
||||
readonly upNext = input<unknown>(null);
|
||||
readonly recordingFolder = input('');
|
||||
readonly recordingMetadata = input<RecordingStartMetadata | null>(null);
|
||||
readonly seriesNavigation = input<unknown>(null);
|
||||
|
||||
Reference in new issue
Block a user