fix(playback): show the Up next card near the real end of an episode (#1768)

* fix(playback): show the Up next card near the real end of an episode

The card appeared a fixed 8 minutes before the end: most of a short
episode, and far into the story of a long one, with no way to hide it.

- Adaptive lead: 4% of the episode, clamped to 40 s … 3 min.
- A closing-credits chapter in the last third, when timeline segments
  carry one, brings the card forward to its start (capped at 5 min).
- Close button and Escape dismiss the card for the current next episode.
- After 10 s (not while hovered) the card collapses into a one-line pill.
- Seconds countdown in the last minute.
- New playback setting "Up next card" (default on) turns it off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(playback): address Up next card review feedback

- Offer the Up next card setting for Embedded MPV only under the
  frame-copy engine; native view never mounts the shared controls.
- Hovering pauses the collapse delay instead of restarting it.
- Document that the card stays visible when the controls auto-hide.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(playback): restart the Up next collapse delay for a new episode

- A card that stays mounted while its next episode changes gives the new
  item the full delay instead of the previous item's leftover.
- The setting description no longer promises credit-based timing: no
  current series path supplies chapters yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 authored and GitHub committed 2026-09-30 21:56:48 +02:00
1 parent cb317bad4c
commit 525ca7bc44
44 files changed
+782 -106

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
---
type: fix
area: playback
---
The "Up next" card now appears near the actual end of an episode — under a minute before the end of a short episode, about two minutes before the end of an hour-long one — and shrinks to a small pill after a few seconds. A close button or Escape hides it for the episode, and a playback setting turns it off.
+14 -2
View File
@@ -143,8 +143,8 @@ test.describe('@xtream inline series fullscreen', () => {
)
.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.
// The 30 s fixture clip is shorter than the card's 40 s minimum
// lead, so the card is up as soon as the duration is known.
const card = playerView.locator(
'[data-test-id="player-controls-up-next"]'
);
@@ -169,6 +169,18 @@ test.describe('@xtream inline series fullscreen', () => {
await expect(card).toHaveAttribute('aria-label', /S01E03/, {
timeout: 15_000,
});
// The corner button hides the card for this episode; bringing the
// controls back does not bring it back.
await playerView
.locator('[data-test-id="player-controls-up-next-close"]')
.click();
await expect(card).toHaveCount(0);
await playerView.hover({ position: { x: 40, y: 40 } });
await expect(
playerView.locator('[data-test-id="player-controls-next-episode"]')
).toBeVisible();
await expect(card).toHaveCount(0);
});
test('switches episodes from the fullscreen episode panel without leaving fullscreen', async ({
@@ -65,6 +65,7 @@ export function createSettingsForm(
webPlayerSharedControls: true,
playerAmbientMode: false,
playerUpNextRail: true,
playerUpNextCard: true,
fullscreenChannelPanel: true,
vodAutoFailover: false,
m3uVodDetails: true,
@@ -176,6 +177,7 @@ export function createSettingsFromFormValue(
webPlayerSharedControls: value.webPlayerSharedControls ?? true,
playerAmbientMode: value.playerAmbientMode ?? false,
playerUpNextRail: value.playerUpNextRail ?? true,
playerUpNextCard: value.playerUpNextCard ?? true,
fullscreenChannelPanel: value.fullscreenChannelPanel ?? true,
vodAutoFailover: value.vodAutoFailover ?? false,
m3uVodDetails: value.m3uVodDetails ?? true,
@@ -74,6 +74,7 @@ function createForm(player: VideoPlayer): FormGroup {
webPlayerSharedControls: new FormControl(false),
playerAmbientMode: new FormControl(false),
playerUpNextRail: new FormControl(true),
playerUpNextCard: new FormControl(true),
fullscreenChannelPanel: new FormControl(true),
streamFormat: new FormControl(StreamFormat.AutoStreamFormat),
openStreamOnDoubleClick: new FormControl(false),
@@ -137,6 +137,30 @@
</div>
}
@if (supportsUpNextCard()) {
<div
class="setting-item"
data-setting-id="player-up-next-card"
data-test-id="player-up-next-card-setting"
>
<div class="setting-item__meta">
<h4>
{{ 'SETTINGS.PLAYER_UP_NEXT_CARD' | translate }}
</h4>
<p>
{{ 'SETTINGS.PLAYER_UP_NEXT_CARD_DESCRIPTION' | translate }}
</p>
</div>
<div class="setting-item__control setting-item__toggle">
<mat-checkbox
[aria-label]="'SETTINGS.PLAYER_UP_NEXT_CARD' | translate"
formControlName="playerUpNextCard"
data-test-id="player-up-next-card-toggle"
></mat-checkbox>
</div>
</div>
}
@if (supportsFullscreenChannelPanel()) {
<div
class="setting-item"
@@ -411,6 +411,20 @@ describe('SettingsPlaybackSectionComponent', () => {
).not.toBeNull();
});
it('offers the up next card toggle for Embedded MPV only under the frame-copy engine', () => {
// The native-view engine never mounts the shared controls.
fixture.componentRef.setInput(
'form',
createForm(VideoPlayer.EmbeddedMpv)
);
fixture.detectChanges();
expect(queryByTestId('player-up-next-card-setting')).toBeNull();
fixture.componentRef.setInput('frameCopyActive', true);
fixture.detectChanges();
expect(queryByTestId('player-up-next-card-setting')).not.toBeNull();
});
it('hides the fullscreen channel panel toggle for a web player on the legacy vendor chrome', () => {
// The vendor chrome fullscreens the engine's own element, outside
// which the panel cannot render, so the toggle would do nothing.
@@ -420,6 +434,7 @@ describe('SettingsPlaybackSectionComponent', () => {
fixture.detectChanges();
expect(queryByTestId('fullscreen-channel-panel-setting')).toBeNull();
expect(queryByTestId('player-up-next-card-setting')).toBeNull();
});
it.each([VideoPlayer.MPV, VideoPlayer.VLC])(
@@ -443,6 +458,7 @@ function createForm(player = VideoPlayer.VideoJs): FormGroup {
webPlayerSharedControls: new FormControl(false),
playerAmbientMode: new FormControl(false),
playerUpNextRail: new FormControl(true),
playerUpNextCard: new FormControl(true),
fullscreenChannelPanel: new FormControl(true),
streamFormat: new FormControl(StreamFormat.AutoStreamFormat),
openStreamOnDoubleClick: new FormControl(false),
@@ -89,6 +89,20 @@ export class SettingsPlaybackSectionComponent {
);
}
/**
* The Up next card is part of the shared controls. Embedded MPV mounts
* them only under the frame-copy engine; the native-view engine keeps
* its own dock, where the toggle would control nothing.
*/
supportsUpNextCard(): boolean {
const value = this.form().value;
return (
(this.isWebPlayerSelected() &&
value.webPlayerSharedControls !== false) ||
(value.player === VideoPlayer.EmbeddedMpv && this.frameCopyActive())
);
}
isExternalPlayerSelected(): boolean {
const player = this.form().value.player;
return player === VideoPlayer.MPV || player === VideoPlayer.VLC;
@@ -87,6 +87,7 @@ export const DEFAULT_SETTINGS = {
webPlayerSharedControls: true,
playerAmbientMode: false,
playerUpNextRail: true,
playerUpNextCard: true,
fullscreenChannelPanel: true,
vodAutoFailover: false,
m3uVodDetails: true,
+4
View File
@@ -377,6 +377,8 @@
"PLAYER_AMBIENT_MODE_DESCRIPTION": "تعبئة المساحة حول المشغل بنسخة مشوشة ومعتمة من الملصق بدلاً من الخطوط السوداء.",
"PLAYER_UP_NEXT_RAIL": "شريط الحلقات التالية",
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "في النوافذ العريضة، تثبيت مشغل المسلسل على اليسار وعرض الحلقات القادمة بجانبه.",
"PLAYER_UP_NEXT_CARD": "بطاقة «التالي»",
"PLAYER_UP_NEXT_CARD_DESCRIPTION": "عرض الحلقة التالية في زاوية المشغل قبل انتهاء الحلقة بقليل.",
"FULLSCREEN_CHANNEL_PANEL": "قائمة القنوات والحلقات في وضع ملء الشاشة",
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "ضع الماوس على الحافة اليسرى أو انقر عليها أو اضغط C في وضع ملء الشاشة لإظهار قائمة قنوات البث المباشر أو قائمة حلقات المسلسل والتبديل دون مغادرة وضع ملء الشاشة.",
"STREAM_FORMAT_DESCRIPTION": "اختر تنسيق البث الافتراضي (xtream)",
@@ -651,6 +653,8 @@
"SETTINGS": "الإعدادات",
"UP_NEXT": "التالي",
"UP_NEXT_IN": "خلال {{minutes}} دقيقة",
"UP_NEXT_IN_SECONDS": "خلال {{seconds}} ثانية",
"UP_NEXT_DISMISS": "إخفاء «التالي»",
"CLOSE_SETTINGS": "إغلاق الإعدادات",
"SUBTITLE_DELAY": "تأخير الترجمة",
"SUBTITLE_DELAY_DECREASE": "عرض الترجمة مبكرًا",
+4
View File
@@ -377,6 +377,8 @@
"PLAYER_AMBIENT_MODE_DESCRIPTION": "ملي المساحة حول المشغل بغلاف معتم وطابت بلاصة الشرائط السوداء.",
"PLAYER_UP_NEXT_RAIL": "شريط الحلقات الجاية",
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "فالنوافذ العريضة، حط مشغل السلسلة على اليسار وبيّن الحلقات الجاية بحداه.",
"PLAYER_UP_NEXT_CARD": "بطاقة «الجاي»",
"PLAYER_UP_NEXT_CARD_DESCRIPTION": "بيّن الحلقة الجاية فالزاوية ديال المشغل شوية قبل ما تسالي الحلقة.",
"FULLSCREEN_CHANNEL_PANEL": "لائحة القنوات والحلقات فوضع ملء الشاشة",
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "حط الفارة فالحافة اليسرى ولا كليكي عليها ولا ضغط C فوضع ملء الشاشة باش تبان لائحة القنوات ديال البث المباشر ولا لائحة الحلقات ديال المسلسل وتبدل بلا ما تخرج من ملء الشاشة.",
"STREAM_FORMAT_DESCRIPTION": "اختار صيغة البث الافتراضية (xtream)",
@@ -651,6 +653,8 @@
"SETTINGS": "الإعدادات",
"UP_NEXT": "اللي جاي",
"UP_NEXT_IN": "فـ {{minutes}} دقيقة",
"UP_NEXT_IN_SECONDS": "فـ {{seconds}} ثانية",
"UP_NEXT_DISMISS": "خبّي «الجاي»",
"CLOSE_SETTINGS": "سد الإعدادات",
"SUBTITLE_DELAY": "تأخير الترجمة",
"SUBTITLE_DELAY_DECREASE": "ورّي الترجمة قبل",
+4
View File
@@ -377,6 +377,8 @@
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Запаўніць прастор вакол плэйра размытай, затемнёнай вокладкай замяст чорных палос.",
"PLAYER_UP_NEXT_RAIL": "Панэль наступных эпізодаў",
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "На шырокіх вокнах замацоўваць прайгравальнік серыяла злева і паказваць наступныя эпізоды побач з ім.",
"PLAYER_UP_NEXT_CARD": "Картка «Далей»",
"PLAYER_UP_NEXT_CARD_DESCRIPTION": "Паказваць наступны эпізод у куце плэера незадоўга да канца эпізоду.",
"FULLSCREEN_CHANNEL_PANEL": "Спіс каналаў і серый у поўнаэкранным рэжыме",
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "Навядзіце мыш на левы край, націсніце на яго або націсніце C у поўнаэкранным рэжыме, каб паказаць спіс каналаў прамога эфіру або спіс серый серыяла і пераключыцца, не выходзячы з поўнаэкраннага рэжыму.",
"STREAM_FORMAT_DESCRIPTION": "Выберыце фармат патоку па змаўчанні (xtream)",
@@ -651,6 +653,8 @@
"SETTINGS": "Налады",
"UP_NEXT": "Далей",
"UP_NEXT_IN": "праз {{minutes}} хв",
"UP_NEXT_IN_SECONDS": "праз {{seconds}} с",
"UP_NEXT_DISMISS": "Схаваць «Далей»",
"CLOSE_SETTINGS": "Закрыць налады",
"SUBTITLE_DELAY": "Затрымка субтытраў",
"SUBTITLE_DELAY_DECREASE": "Паказваць субтытры раней",
+4
View File
@@ -377,6 +377,8 @@
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Füllt den Bereich um den Player mit einem unscharfen, gedimmten Poster auf, anstelle von einfachen schwarzen Balken.",
"PLAYER_UP_NEXT_RAIL": "Folgenleiste „Als Nächstes“",
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "Dockt den Serienplayer in breiten Fenstern links an und zeigt daneben die kommenden Folgen an.",
"PLAYER_UP_NEXT_CARD": "Karte „Als Nächstes“",
"PLAYER_UP_NEXT_CARD_DESCRIPTION": "Zeigt die nächste Folge kurz vor dem Ende einer Folge in der Ecke des Players.",
"FULLSCREEN_CHANNEL_PANEL": "Sender- und Episodenliste im Vollbild",
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "Bewege die Maus im Vollbild an den linken Rand, klicke darauf oder drücke C, um die Senderliste eines Live-Streams oder die Episodenliste einer Serie einzublenden und zu wechseln, ohne das Vollbild zu verlassen.",
"STREAM_FORMAT_DESCRIPTION": "Auto bevorzugt HLS. Schlägt Live-TV in MPV, VLC oder Embedded MPV fehl, den Player stoppen, ts wählen, speichern und den Sender erneut öffnen. Webplayer können bei einem anfänglichen HLS-HTTP-Fehler einmal angebotenes TS versuchen.",
@@ -651,6 +653,8 @@
"SETTINGS": "Einstellungen",
"UP_NEXT": "Als Nächstes",
"UP_NEXT_IN": "in {{minutes}} Min.",
"UP_NEXT_IN_SECONDS": "in {{seconds}} Sek.",
"UP_NEXT_DISMISS": "„Als Nächstes“ ausblenden",
"CLOSE_SETTINGS": "Einstellungen schließen",
"SUBTITLE_DELAY": "Untertitelverzögerung",
"SUBTITLE_DELAY_DECREASE": "Untertitel früher anzeigen",
+4
View File
@@ -377,6 +377,8 @@
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Γεμίστε το χώρο γύρω από τον αναπαραγωγό με θολωμένη, σκοτεινή αφίσα αντί για απλές μαύρες ράβδους.",
"PLAYER_UP_NEXT_RAIL": "Λίστα επόμενων επεισοδίων",
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "Σε πλατιά παράθυρα, ο αναπαραγωγός σειρών αγκυρώνεται στα αριστερά και τα επόμενα επεισόδια εμφανίζονται δίπλα του.",
"PLAYER_UP_NEXT_CARD": "Κάρτα «Επόμενο»",
"PLAYER_UP_NEXT_CARD_DESCRIPTION": "Εμφάνιση του επόμενου επεισοδίου στη γωνία του προγράμματος αναπαραγωγής λίγο πριν το τέλος ενός επεισοδίου.",
"FULLSCREEN_CHANNEL_PANEL": "Λίστα καναλιών και επεισοδίων σε πλήρη οθόνη",
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "Αφήστε το ποντίκι στην αριστερή άκρη, κάντε κλικ σε αυτήν ή πατήστε C σε πλήρη οθόνη για να εμφανίσετε τη λίστα καναλιών μιας ζωντανής ροής ή τη λίστα επεισοδίων μιας σειράς και να αλλάξετε χωρίς να βγείτε από την πλήρη οθόνη.",
"STREAM_FORMAT_DESCRIPTION": "Επιλογή προεπιλεγμένης μορφής ροής (xtream)",
@@ -651,6 +653,8 @@
"SETTINGS": "Ρυθμίσεις",
"UP_NEXT": "Επόμενο",
"UP_NEXT_IN": "σε {{minutes}} λεπτά",
"UP_NEXT_IN_SECONDS": "σε {{seconds}} δευτ.",
"UP_NEXT_DISMISS": "Απόκρυψη «Επόμενο»",
"CLOSE_SETTINGS": "Κλείσιμο ρυθμίσεων",
"SUBTITLE_DELAY": "Καθυστέρηση υποτίτλων",
"SUBTITLE_DELAY_DECREASE": "Εμφάνιση υποτίτλων νωρίτερα",
+4
View File
@@ -377,6 +377,8 @@
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Fill the space around the player with a blurred, dimmed poster instead of plain black bars.",
"PLAYER_UP_NEXT_RAIL": "Up Next episode rail",
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "On wide windows, dock the series player to the left and show the upcoming episodes beside it.",
"PLAYER_UP_NEXT_CARD": "\"Up next\" card",
"PLAYER_UP_NEXT_CARD_DESCRIPTION": "Show the next episode in the player's corner shortly before an episode ends.",
"FULLSCREEN_CHANNEL_PANEL": "Channel and episode list in fullscreen",
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "Rest the mouse on the left edge, click it, or press C while in fullscreen to slide in the channel list of a live stream or the episode list of a series and switch without leaving fullscreen.",
"STREAM_FORMAT_DESCRIPTION": "Auto prefers HLS. If live playback fails in MPV, VLC or Embedded MPV, stop the player, select ts, save and reopen the channel. Web players can try advertised TS once after an initial HLS HTTP failure.",
@@ -651,6 +653,8 @@
"SETTINGS": "Settings",
"UP_NEXT": "Up next",
"UP_NEXT_IN": "in {{minutes}} min",
"UP_NEXT_IN_SECONDS": "in {{seconds}} s",
"UP_NEXT_DISMISS": "Hide up next",
"CLOSE_SETTINGS": "Close settings",
"SUBTITLE_DELAY": "Subtitle delay",
"SUBTITLE_DELAY_DECREASE": "Show subtitles earlier",
+4
View File
@@ -377,6 +377,8 @@
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Rellena el espacio alrededor del reproductor con una imagen de póster desenfocada y oscurecida en lugar de barras negras simples.",
"PLAYER_UP_NEXT_RAIL": "Panel de próximos episodios",
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "En ventanas anchas, ancla el reproductor de series a la izquierda y muestra los próximos episodios a su lado.",
"PLAYER_UP_NEXT_CARD": "Tarjeta «A continuación»",
"PLAYER_UP_NEXT_CARD_DESCRIPTION": "Muestra el siguiente episodio en la esquina del reproductor poco antes de que termine el episodio.",
"FULLSCREEN_CHANNEL_PANEL": "Lista de canales y episodios en pantalla completa",
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "Deja el ratón en el borde izquierdo, haz clic en él o pulsa C en pantalla completa para mostrar la lista de canales de una emisión en directo o la lista de episodios de una serie y cambiar sin salir de la pantalla completa.",
"STREAM_FORMAT_DESCRIPTION": "Selecciona el formato de transmision predeterminado (Xtream)",
@@ -651,6 +653,8 @@
"SETTINGS": "Ajustes",
"UP_NEXT": "A continuación",
"UP_NEXT_IN": "en {{minutes}} min",
"UP_NEXT_IN_SECONDS": "en {{seconds}} s",
"UP_NEXT_DISMISS": "Ocultar «A continuación»",
"CLOSE_SETTINGS": "Cerrar ajustes",
"SUBTITLE_DELAY": "Retardo de subtítulos",
"SUBTITLE_DELAY_DECREASE": "Mostrar subtítulos antes",
+4
View File
@@ -377,6 +377,8 @@
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Remplit l'espace autour du lecteur avec une affiche floue et assombrie au lieu de barres noires simples.",
"PLAYER_UP_NEXT_RAIL": "Rail d'épisodes « À suivre »",
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "Sur les fenêtres larges, ancre le lecteur de séries à gauche et affiche les épisodes à venir à côté.",
"PLAYER_UP_NEXT_CARD": "Carte « À suivre »",
"PLAYER_UP_NEXT_CARD_DESCRIPTION": "Afficher l'épisode suivant dans le coin du lecteur peu avant la fin d'un épisode.",
"FULLSCREEN_CHANNEL_PANEL": "Liste des chaînes et des épisodes en plein écran",
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "Laissez la souris sur le bord gauche, cliquez dessus ou appuyez sur C en plein écran pour afficher la liste des chaînes d'un direct ou la liste des épisodes d'une série et changer sans quitter le plein écran.",
"STREAM_FORMAT_DESCRIPTION": "Sélectionnez le format de flux par défaut (xtream)",
@@ -651,6 +653,8 @@
"SETTINGS": "Réglages",
"UP_NEXT": "À suivre",
"UP_NEXT_IN": "dans {{minutes}} min",
"UP_NEXT_IN_SECONDS": "dans {{seconds}} s",
"UP_NEXT_DISMISS": "Masquer « À suivre »",
"CLOSE_SETTINGS": "Fermer les réglages",
"SUBTITLE_DELAY": "Décalage des sous-titres",
"SUBTITLE_DELAY_DECREASE": "Afficher les sous-titres plus tôt",
+4
View File
@@ -377,6 +377,8 @@
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Fill the space around the player with a blurred, dimmed poster instead of plain black bars.",
"PLAYER_UP_NEXT_RAIL": "„Következik” epizódsáv",
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "Széles ablakban a sorozatlejátszó balra kerül, mellette pedig megjelennek a következő epizódok.",
"PLAYER_UP_NEXT_CARD": "„Következik” kártya",
"PLAYER_UP_NEXT_CARD_DESCRIPTION": "A következő epizód megjelenítése a lejátszó sarkában röviddel az epizód vége előtt.",
"FULLSCREEN_CHANNEL_PANEL": "Csatorna- és epizódlista teljes képernyőn",
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "Teljes képernyőn vidd az egeret a bal szélre, kattints rá, vagy nyomd meg a C billentyűt az élő adás csatornalistájának vagy a sorozat epizódlistájának megjelenítéséhez, és válts a teljes képernyő elhagyása nélkül.",
"STREAM_FORMAT_DESCRIPTION": "Az alapértelmezett adatfolyam-formátum kiválasztása (Xtream)",
@@ -651,6 +653,8 @@
"SETTINGS": "Beállítások",
"UP_NEXT": "Következő",
"UP_NEXT_IN": "{{minutes}} perc múlva",
"UP_NEXT_IN_SECONDS": "{{seconds}} mp múlva",
"UP_NEXT_DISMISS": "„Következik” elrejtése",
"CLOSE_SETTINGS": "Beállítások bezárása",
"SUBTITLE_DELAY": "Felirat késleltetése",
"SUBTITLE_DELAY_DECREASE": "Feliratok megjelenítése korábban",
+4
View File
@@ -377,6 +377,8 @@
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Riempie lo spazio intorno al lettore con un poster sfocato e scurito invece delle semplici barre nere.",
"PLAYER_UP_NEXT_RAIL": "Pannello \"A seguire\" con i prossimi episodi",
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "Nelle finestre ampie, ancora il lettore delle serie a sinistra e mostra i prossimi episodi accanto.",
"PLAYER_UP_NEXT_CARD": "Scheda \"A seguire\"",
"PLAYER_UP_NEXT_CARD_DESCRIPTION": "Mostra l'episodio successivo nell'angolo del player poco prima della fine dell'episodio.",
"FULLSCREEN_CHANNEL_PANEL": "Elenco canali ed episodi a schermo intero",
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "A schermo intero, lascia il mouse sul bordo sinistro, fai clic su di esso o premi C per mostrare l'elenco dei canali di una diretta o l'elenco degli episodi di una serie e cambiare senza uscire dallo schermo intero.",
"STREAM_FORMAT_DESCRIPTION": "Seleziona il formato di streaming predefinito (xtream)",
@@ -651,6 +653,8 @@
"SETTINGS": "Impostazioni",
"UP_NEXT": "Prossimo",
"UP_NEXT_IN": "tra {{minutes}} min",
"UP_NEXT_IN_SECONDS": "tra {{seconds}} s",
"UP_NEXT_DISMISS": "Nascondi \"A seguire\"",
"CLOSE_SETTINGS": "Chiudi impostazioni",
"SUBTITLE_DELAY": "Ritardo sottotitoli",
"SUBTITLE_DELAY_DECREASE": "Mostra i sottotitoli prima",
+4
View File
@@ -377,6 +377,8 @@
"PLAYER_AMBIENT_MODE_DESCRIPTION": "プレーヤーの周囲をぼかしたポスター画像で埋め、通常の黒い枠の代わりに表示します。",
"PLAYER_UP_NEXT_RAIL": "「次に再生」エピソードレール",
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "ウィンドウ幅が広い場合、シリーズのプレーヤーを左側に配置し、その横に次のエピソードの一覧を表示します。",
"PLAYER_UP_NEXT_CARD": "「次に再生」カード",
"PLAYER_UP_NEXT_CARD_DESCRIPTION": "エピソードの終了直前に、プレーヤーの隅に次のエピソードを表示します。",
"FULLSCREEN_CHANNEL_PANEL": "全画面表示でのチャンネル・エピソードリスト",
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "全画面表示中に左端にマウスを置く、クリックする、または C キーを押すと、ライブ配信のチャンネルリストやシリーズのエピソードリストが表示され、全画面表示のまま切り替えられます。",
"STREAM_FORMAT_DESCRIPTION": "デフォルトのストリーム形式を選択(Xtream)",
@@ -651,6 +653,8 @@
"SETTINGS": "設定",
"UP_NEXT": "次のエピソード",
"UP_NEXT_IN": "あと{{minutes}}分",
"UP_NEXT_IN_SECONDS": "あと{{seconds}}秒",
"UP_NEXT_DISMISS": "「次に再生」を隠す",
"CLOSE_SETTINGS": "設定を閉じる",
"SUBTITLE_DELAY": "字幕の遅延",
"SUBTITLE_DELAY_DECREASE": "字幕を早く表示",
+4
View File
@@ -377,6 +377,8 @@
"PLAYER_AMBIENT_MODE_DESCRIPTION": "플레이어 주변을 검은 막대 대신 흐려지고 어두워진 포스터로 채웁니다.",
"PLAYER_UP_NEXT_RAIL": "다음 에피소드 레일",
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "넓은 창에서 시리즈 플레이어를 왼쪽에 고정하고 그 옆에 다음 에피소드를 표시합니다.",
"PLAYER_UP_NEXT_CARD": "다음 에피소드 카드",
"PLAYER_UP_NEXT_CARD_DESCRIPTION": "에피소드가 끝나기 직전에 플레이어 모서리에 다음 에피소드를 표시합니다.",
"FULLSCREEN_CHANNEL_PANEL": "전체 화면에서 채널 및 에피소드 목록",
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "전체 화면에서 마우스를 왼쪽 가장자리에 두거나 클릭하거나 C 키를 누르면 라이브 방송의 채널 목록이나 시리즈의 에피소드 목록이 나타나 전체 화면을 벗어나지 않고 전환할 수 있습니다.",
"STREAM_FORMAT_DESCRIPTION": "기본 스트림 형식 선택 (xtream)",
@@ -651,6 +653,8 @@
"SETTINGS": "설정",
"UP_NEXT": "다음 에피소드",
"UP_NEXT_IN": "{{minutes}}분 후",
"UP_NEXT_IN_SECONDS": "{{seconds}}초 후",
"UP_NEXT_DISMISS": "다음 에피소드 숨기기",
"CLOSE_SETTINGS": "설정 닫기",
"SUBTITLE_DELAY": "자막 지연",
"SUBTITLE_DELAY_DECREASE": "자막을 더 빨리 표시",
+4
View File
@@ -377,6 +377,8 @@
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Vult de ruimte rond de speler met een vervaagde, gedimde posterafbeelding in plaats van zwarte balken.",
"PLAYER_UP_NEXT_RAIL": "Hierna-paneel met afleveringen",
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "Zet de seriespeler in brede vensters links vast en toont de komende afleveringen ernaast.",
"PLAYER_UP_NEXT_CARD": "'Hierna'-kaart",
"PLAYER_UP_NEXT_CARD_DESCRIPTION": "Toon de volgende aflevering in de hoek van de speler kort voordat een aflevering eindigt.",
"FULLSCREEN_CHANNEL_PANEL": "Zender- en afleveringenlijst in volledig scherm",
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "Laat de muis op de linkerrand rusten, klik erop of druk op C in volledig scherm om de zenderlijst van een live-uitzending of de afleveringenlijst van een serie te tonen en te wisselen zonder het volledige scherm te verlaten.",
"STREAM_FORMAT_DESCRIPTION": "Selecteer het standaard streamformaat (xtream)",
@@ -651,6 +653,8 @@
"SETTINGS": "Instellingen",
"UP_NEXT": "Hierna",
"UP_NEXT_IN": "over {{minutes}} min",
"UP_NEXT_IN_SECONDS": "over {{seconds}} s",
"UP_NEXT_DISMISS": "'Hierna' verbergen",
"CLOSE_SETTINGS": "Instellingen sluiten",
"SUBTITLE_DELAY": "Ondertitelvertraging",
"SUBTITLE_DELAY_DECREASE": "Ondertitels eerder tonen",
+4
View File
@@ -377,6 +377,8 @@
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Wypełnij przestrzeń wokół odtwarzacza rozmytym, przyciemnianym plakatem zamiast zwykłych czarnych pasków.",
"PLAYER_UP_NEXT_RAIL": "Panel następnych odcinków",
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "W szerokich oknach zadokuj odtwarzacz serialu po lewej stronie i pokaż obok kolejne odcinki.",
"PLAYER_UP_NEXT_CARD": "Karta „Następny”",
"PLAYER_UP_NEXT_CARD_DESCRIPTION": "Pokazuj następny odcinek w rogu odtwarzacza krótko przed końcem odcinka.",
"FULLSCREEN_CHANNEL_PANEL": "Lista kanałów i odcinków w trybie pełnoekranowym",
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "W trybie pełnoekranowym przytrzymaj mysz przy lewej krawędzi, kliknij ją lub naciśnij C, aby wysunąć listę kanałów transmisji na żywo lub listę odcinków serialu i przełączać bez wychodzenia z pełnego ekranu.",
"STREAM_FORMAT_DESCRIPTION": "Wybierz domyślny format strumienia (xtream)",
@@ -651,6 +653,8 @@
"SETTINGS": "Ustawienia",
"UP_NEXT": "Następny",
"UP_NEXT_IN": "za {{minutes}} min",
"UP_NEXT_IN_SECONDS": "za {{seconds}} s",
"UP_NEXT_DISMISS": "Ukryj „Następny”",
"CLOSE_SETTINGS": "Zamknij ustawienia",
"SUBTITLE_DELAY": "Opóźnienie napisów",
"SUBTITLE_DELAY_DECREASE": "Pokaż napisy wcześniej",
+4
View File
@@ -377,6 +377,8 @@
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Preenche o espaço ao redor do reprodutor com um pôster desfocado e escurecido em vez de barras pretas simples.",
"PLAYER_UP_NEXT_RAIL": "Painel de próximos episódios",
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "Em janelas largas, ancora o reprodutor de séries à esquerda e mostra os próximos episódios ao lado.",
"PLAYER_UP_NEXT_CARD": "Cartão \"A seguir\"",
"PLAYER_UP_NEXT_CARD_DESCRIPTION": "Mostrar o próximo episódio no canto do player pouco antes de o episódio terminar.",
"FULLSCREEN_CHANNEL_PANEL": "Lista de canais e episódios em tela cheia",
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "Deixe o mouse na borda esquerda, clique nela ou pressione C em tela cheia para mostrar a lista de canais de uma transmissão ao vivo ou a lista de episódios de uma série e trocar sem sair da tela cheia.",
"STREAM_FORMAT_DESCRIPTION": "Selecionar formato de stream padrão (xtream)",
@@ -651,6 +653,8 @@
"SETTINGS": "Configurações",
"UP_NEXT": "A seguir",
"UP_NEXT_IN": "em {{minutes}} min",
"UP_NEXT_IN_SECONDS": "em {{seconds}} s",
"UP_NEXT_DISMISS": "Ocultar \"A seguir\"",
"CLOSE_SETTINGS": "Fechar configurações",
"SUBTITLE_DELAY": "Atraso das legendas",
"SUBTITLE_DELAY_DECREASE": "Mostrar legendas mais cedo",
+4
View File
@@ -377,6 +377,8 @@
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Заполнять пространство вокруг плеера размытым затемнённым постером вместо чёрных полос.",
"PLAYER_UP_NEXT_RAIL": "Панель следующих серий",
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "В широких окнах закреплять плеер сериала слева и показывать следующие серии рядом с ним.",
"PLAYER_UP_NEXT_CARD": "Карточка «Далее»",
"PLAYER_UP_NEXT_CARD_DESCRIPTION": "Показывать следующую серию в углу плеера незадолго до конца серии.",
"FULLSCREEN_CHANNEL_PANEL": "Список каналов и серий в полноэкранном режиме",
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "В полноэкранном режиме наведите мышь на левый край, нажмите на него или нажмите C, чтобы показать список каналов прямого эфира или список серий сериала и переключиться, не выходя из полноэкранного режима.",
"STREAM_FORMAT_DESCRIPTION": "Auto предпочитает HLS. При сбое в MPV, VLC или Embedded MPV остановите плеер, выберите ts, сохраните настройки и снова откройте канал. Веб-плееры могут один раз попробовать заявленный TS после первоначальной HTTP-ошибки HLS.",
@@ -651,6 +653,8 @@
"SETTINGS": "Настройки",
"UP_NEXT": "Далее",
"UP_NEXT_IN": "через {{minutes}} мин",
"UP_NEXT_IN_SECONDS": "через {{seconds}} с",
"UP_NEXT_DISMISS": "Скрыть «Далее»",
"CLOSE_SETTINGS": "Закрыть настройки",
"SUBTITLE_DELAY": "Задержка субтитров",
"SUBTITLE_DELAY_DECREASE": "Показывать субтитры раньше",
+4
View File
@@ -377,6 +377,8 @@
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Oynatıcının çevresindeki boşluğu siyah çubuklar yerine bulanık ve karartılmış bir poster ile doldurur.",
"PLAYER_UP_NEXT_RAIL": "Sıradaki bölümler paneli",
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "Geniş pencerelerde dizi oynatıcısını sola yaslar ve sıradaki bölümleri yanında gösterir.",
"PLAYER_UP_NEXT_CARD": "\"Sıradaki\" kartı",
"PLAYER_UP_NEXT_CARD_DESCRIPTION": "Bölüm bitmeden kısa süre önce sıradaki bölümü oynatıcının köşesinde göster.",
"FULLSCREEN_CHANNEL_PANEL": "Tam ekranda kanal ve bölüm listesi",
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "Tam ekrandayken fareyi sol kenara getirin, tıklayın veya C tuşuna basın; canlı yayının kanal listesi ya da dizinin bölüm listesi açılır ve tam ekrandan çıkmadan geçiş yapabilirsiniz.",
"STREAM_FORMAT_DESCRIPTION": "Varsayılan yayın formatını seçin (xtream)",
@@ -651,6 +653,8 @@
"SETTINGS": "Ayarlar",
"UP_NEXT": "Sıradaki",
"UP_NEXT_IN": "{{minutes}} dk içinde",
"UP_NEXT_IN_SECONDS": "{{seconds}} sn içinde",
"UP_NEXT_DISMISS": "\"Sıradaki\"yi gizle",
"CLOSE_SETTINGS": "Ayarları kapat",
"SUBTITLE_DELAY": "Altyazı gecikmesi",
"SUBTITLE_DELAY_DECREASE": "Altyazıları daha erken göster",
+4
View File
@@ -377,6 +377,8 @@
"PLAYER_AMBIENT_MODE_DESCRIPTION": "用模糊、暗淡的海报填充播放器周围的空白,而不是黑条纹。",
"PLAYER_UP_NEXT_RAIL": "「接下来播放」剧集栏",
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "在宽窗口下,将剧集播放器停靠在左侧,并在旁边显示后续剧集。",
"PLAYER_UP_NEXT_CARD": "「接下来播放」卡片",
"PLAYER_UP_NEXT_CARD_DESCRIPTION": "在剧集即将结束时,在播放器角落显示下一集。",
"FULLSCREEN_CHANNEL_PANEL": "全屏时的频道和剧集列表",
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "全屏时将鼠标停在左边缘、点击它或按 C 键,即可滑出直播的频道列表或剧集的分集列表,无需退出全屏即可切换。",
"STREAM_FORMAT_DESCRIPTION": "选择默认的流格式(xtream)",
@@ -651,6 +653,8 @@
"SETTINGS": "设置",
"UP_NEXT": "接下来",
"UP_NEXT_IN": "{{minutes}} 分钟后",
"UP_NEXT_IN_SECONDS": "{{seconds}} 秒后",
"UP_NEXT_DISMISS": "隐藏「接下来播放」",
"CLOSE_SETTINGS": "关闭设置",
"SUBTITLE_DELAY": "字幕延迟",
"SUBTITLE_DELAY_DECREASE": "提前显示字幕",
+4
View File
@@ -377,6 +377,8 @@
"PLAYER_AMBIENT_MODE_DESCRIPTION": "用模糊、昏暗的海報填充播放器周圍的空間,取代純黑色邊框。",
"PLAYER_UP_NEXT_RAIL": "即將播放集數側欄",
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "在較寬的視窗中,將影集播放器停靠在左側,並在旁邊顯示即將播放的集數。",
"PLAYER_UP_NEXT_CARD": "「即將播放」卡片",
"PLAYER_UP_NEXT_CARD_DESCRIPTION": "在影集即將結束時,於播放器角落顯示下一集。",
"FULLSCREEN_CHANNEL_PANEL": "全螢幕時的頻道與集數清單",
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "全螢幕時將滑鼠停在左側邊緣、點擊它或按 C 鍵,即可滑出直播的頻道清單或影集的集數清單,不需離開全螢幕即可切換。",
"STREAM_FORMAT_DESCRIPTION": "選擇預設串流格式(xtream)",
@@ -651,6 +653,8 @@
"SETTINGS": "設定",
"UP_NEXT": "接下來",
"UP_NEXT_IN": "{{minutes}} 分鐘後",
"UP_NEXT_IN_SECONDS": "{{seconds}} 秒後",
"UP_NEXT_DISMISS": "隱藏「即將播放」",
"CLOSE_SETTINGS": "關閉設定",
"SUBTITLE_DELAY": "字幕延遲",
"SUBTITLE_DELAY_DECREASE": "提前顯示字幕",
+23 -7
View File
@@ -405,10 +405,17 @@ bottom-right corner above the controls: the next episode's still (or its
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, a finite duration with at
most `UP_NEXT_THRESHOLD_SECONDS` (8 min) left, not live, not `ended` (with
autoplay off nothing is scheduled, so no countdown), controls shown,
settings panel closed — and how many minutes remain (never below one). A
`seriesNavigation` capability, a finite duration, not live, not `ended`
(with autoplay off nothing is scheduled, so no countdown), controls shown,
settings panel closed, not dismissed — and when. The lead adapts to the
episode: `upNextThresholdSeconds` is 4% of the duration clamped to
40 s … 3 min (about 50 s for a 22-minute episode, 2.4 min for an hour). When
`timelineSegments` carry a closing-credits chapter in the last third
(`creditsStartSeconds`: "Credits", "Outro", "Ending", an anime `ED`, and a
few localized names), the card appears when it starts instead, at most
5 min before the end. No engine reports file chapters today, so in practice
the adaptive lead applies. The countdown shows whole minutes, and seconds
in the last minute. A
still that fails to load falls back to the label tile. A
click emits `nextEpisodeRequested` directly — not through the
transport's `canNextEpisode` guard, which is season-local — so the card
@@ -417,12 +424,21 @@ routes such a request through the Up Next rail selection
(`upNextEpisodeSelected`) whenever `seriesNavigation.canNext` is false, which
plays the next season's first episode; either path 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.
The card is a glass surface that stays visible when the controls auto-hide
(`showControls` is the host's input, not the idle state); the compact dock
uses a smaller variant without the trailing icon. After
`UP_NEXT_COLLAPSE_DELAY_MS` (10 s, paused while hovered) the card asks to
collapse and `ControlsUpNext.collapse()` turns it into a one-line pill
(label and countdown) for the rest of the episode. A corner close button
(`player-controls-up-next-close`) or Escape on the focused card calls
`dismiss()`, which hides it until the item it points to changes; Escape is
stopped at the card so it does not reach the player's own Escape handling.
The `playerUpNextCard` setting (default on) turns the card off: the
producer below then passes `null`.
Plumbing mirrors `mediaTitle`: `PortalInlinePlayerComponent.playerUpNext`
derives the item after the playing one from its `upNextEpisodes` input
(episodes only) → `WebPlayerViewComponent.upNext` → the four engine hosts →
(episodes only, and only while `playerUpNextCard` is not `false`) → `WebPlayerViewComponent.upNext` → the four engine hosts →
`app-player-controls`. Movie and live hosts pass nothing.
### Settings panel
@@ -16,6 +16,7 @@ export const DEFAULT_SETTINGS: Settings = {
webPlayerSharedControls: true,
playerAmbientMode: false,
playerUpNextRail: true,
playerUpNextCard: true,
fullscreenChannelPanel: true,
vodAutoFailover: false,
m3uVodDetails: true,
@@ -445,6 +445,24 @@ describe('SettingsStore dashboard rail settings', () => {
expect(store.getSettings().playerUpNextRail).toBe(false);
});
it('defaults the up next card to true when the stored field is missing', async () => {
storedSettings = {};
const store = injector.get(SettingsStore);
await store.loadSettings();
expect(store.getSettings().playerUpNextCard).toBe(true);
});
it('restores a persisted false up next card preference', async () => {
storedSettings = { playerUpNextCard: false };
const store = injector.get(SettingsStore);
await store.loadSettings();
expect(store.getSettings().playerUpNextCard).toBe(false);
});
it('defaults the fullscreen channel panel to true when the stored field is missing', async () => {
storedSettings = {};
const store = injector.get(SettingsStore);
@@ -201,6 +201,9 @@ export const SettingsStore = signalStore(
playerUpNextRail:
store.playerUpNextRail?.() ??
DEFAULT_SETTINGS.playerUpNextRail,
playerUpNextCard:
store.playerUpNextCard?.() ??
DEFAULT_SETTINGS.playerUpNextCard,
fullscreenChannelPanel:
store.fullscreenChannelPanel?.() ??
DEFAULT_SETTINGS.fullscreenChannelPanel,
@@ -151,6 +151,11 @@ export interface Settings {
* missing values mean enabled. Only affects the built-in web players.
*/
playerUpNextRail?: boolean;
/**
* Show the "Up next" card in the shared player controls shortly before a
* series episode ends. On by default; missing values mean enabled.
*/
playerUpNextCard?: boolean;
/**
* Slide the live channel list over the video in fullscreen — resting the
* mouse on the left edge, tapping that edge on touch, or pressing `C` —
@@ -1,5 +1,10 @@
import { WritableSignal, signal } from '@angular/core';
import { ControlsUpNext, UP_NEXT_THRESHOLD_SECONDS } from './controls-up-next';
import {
ControlsUpNext,
UP_NEXT_MAX_CREDITS_LEAD_SECONDS,
creditsStartSeconds,
upNextThresholdSeconds,
} from './controls-up-next';
import {
DEFAULT_PLAYER_CAPABILITIES,
createEmptyControlsState,
@@ -7,9 +12,64 @@ import {
import type {
PlayerControlsCapabilities,
PlayerControlsState,
PlayerTimelineSegment,
PlayerUpNextItem,
} from './player-controls.model';
describe('upNextThresholdSeconds', () => {
it.each([
[10 * 60, 40],
[22 * 60, 53],
[45 * 60, 108],
[60 * 60, 144],
[90 * 60, 180],
[3 * 60 * 60, 180],
])('gives a %i s episode a %i s lead', (duration, lead) => {
expect(upNextThresholdSeconds(duration)).toBe(lead);
});
});
describe('creditsStartSeconds', () => {
const chapter = (
startSeconds: number,
title: string | null
): PlayerTimelineSegment => ({
startSeconds,
endSeconds: startSeconds + 60,
title,
});
it('finds the earliest credits chapter in the last third', () => {
expect(
creditsStartSeconds(
[
chapter(0, 'Opening Credits'),
chapter(600, 'Part 2'),
chapter(2820, 'Post-credits scene'),
chapter(2700, 'End Credits'),
],
3000
)
).toBe(2700);
});
it('recognises anime ending themes and localized titles', () => {
expect(creditsStartSeconds([chapter(1300, 'ED')], 1440)).toBe(1300);
expect(creditsStartSeconds([chapter(1300, 'Титры')], 1440)).toBe(1300);
expect(creditsStartSeconds([chapter(1300, 'Edge')], 1440)).toBeNull();
});
it('ignores untitled chapters and anything before the last third', () => {
expect(
creditsStartSeconds(
[chapter(100, 'Credits'), chapter(2900, null)],
3000
)
).toBeNull();
expect(creditsStartSeconds(null, 3000)).toBeNull();
});
});
describe('ControlsUpNext', () => {
const next: PlayerUpNextItem = {
label: 'S01E03',
@@ -17,15 +77,21 @@ describe('ControlsUpNext', () => {
thumbnailUrl: null,
progressPercent: null,
};
// A 20-minute episode gets the 48 s adaptive lead (4 %).
const duration = 1200;
const lead = 48;
let item: WritableSignal<PlayerUpNextItem | null>;
let state: WritableSignal<PlayerControlsState>;
let capabilities: WritableSignal<PlayerControlsCapabilities>;
let showControls: WritableSignal<boolean>;
let settingsOpen: WritableSignal<boolean>;
let segments: WritableSignal<readonly PlayerTimelineSegment[] | null>;
let upNext: ControlsUpNext;
const setState = (overrides: Partial<PlayerControlsState>) =>
state.set({ ...createEmptyControlsState(), ...overrides });
const at = (positionSeconds: number, durationSeconds = duration) =>
setState({ canNextEpisode: true, durationSeconds, positionSeconds });
beforeEach(() => {
item = signal(next);
@@ -36,48 +102,79 @@ describe('ControlsUpNext', () => {
});
showControls = signal(true);
settingsOpen = signal(false);
segments = signal(null);
upNext = new ControlsUpNext({
item,
state,
capabilities,
showControls,
settingsOpen,
segments,
});
setState({
canNextEpisode: true,
durationSeconds: 1200,
positionSeconds: 1200 - 7 * 60 - 3,
});
at(duration - 30);
});
it('shows the next episode inside the threshold with minutes left', () => {
it('shows the next episode inside the adaptive lead', () => {
expect(upNext.thresholdSeconds()).toBe(lead);
expect(upNext.visible()).toBe(true);
expect(upNext.item()).toBe(next);
expect(upNext.remainingSeconds()).toBe(423);
expect(upNext.minutesLeft()).toBe(8);
expect(upNext.remainingSeconds()).toBe(30);
});
it('stays hidden until the threshold and reports at least one minute', () => {
setState({
canNextEpisode: true,
durationSeconds: 1200,
positionSeconds: 1200 - UP_NEXT_THRESHOLD_SECONDS - 1,
});
it('stays hidden until the adaptive lead is reached', () => {
// The old fixed 8-minute lead would already show the card here.
at(duration - 7 * 60);
expect(upNext.visible()).toBe(false);
setState({
canNextEpisode: true,
durationSeconds: 1200,
positionSeconds: 1200 - UP_NEXT_THRESHOLD_SECONDS,
});
expect(upNext.visible()).toBe(true);
at(duration - lead - 1);
expect(upNext.visible()).toBe(false);
setState({
canNextEpisode: true,
durationSeconds: 1200,
positionSeconds: 1199.5,
});
expect(upNext.minutesLeft()).toBe(1);
at(duration - lead);
expect(upNext.visible()).toBe(true);
});
it('appears when a closing-credits chapter starts instead', () => {
segments.set([
{ startSeconds: 0, endSeconds: 1080, title: 'Episode' },
{ startSeconds: 1080, endSeconds: 1200, title: 'Credits' },
]);
expect(upNext.thresholdSeconds()).toBe(120);
at(1079);
expect(upNext.visible()).toBe(false);
at(1080);
expect(upNext.visible()).toBe(true);
});
it('caps how far long credits bring the card forward', () => {
segments.set([
{ startSeconds: 2400, endSeconds: 3600, title: 'End Credits' },
]);
at(3000, 3600);
expect(upNext.thresholdSeconds()).toBe(
UP_NEXT_MAX_CREDITS_LEAD_SECONDS
);
expect(upNext.visible()).toBe(false);
});
it('stays dismissed for this next episode and returns for the following one', () => {
upNext.dismiss();
expect(upNext.visible()).toBe(false);
at(duration - 10);
expect(upNext.visible()).toBe(false);
item.set({ ...next, label: 'S01E04', title: 'Four' });
expect(upNext.visible()).toBe(true);
});
it('remembers the collapse for the episode it points to', () => {
expect(upNext.collapsed()).toBe(false);
upNext.collapse();
expect(upNext.collapsed()).toBe(true);
item.set({ ...next, label: 'S01E04', title: 'Four' });
expect(upNext.collapsed()).toBe(false);
});
it('needs a supplied episode, a finite duration and a series-capable engine', () => {
@@ -89,32 +186,29 @@ describe('ControlsUpNext', () => {
// transport cannot step forward, but the next season's first can.
setState({
canNextEpisode: false,
durationSeconds: 1200,
positionSeconds: 1100,
durationSeconds: duration,
positionSeconds: duration - 20,
});
expect(upNext.visible()).toBe(true);
setState({
canNextEpisode: true,
durationSeconds: null,
positionSeconds: 1100,
positionSeconds: duration - 20,
});
expect(upNext.visible()).toBe(false);
expect(upNext.remainingSeconds()).toBeNull();
expect(upNext.thresholdSeconds()).toBeNull();
setState({
canNextEpisode: true,
isLive: true,
durationSeconds: 1200,
positionSeconds: 1100,
durationSeconds: duration,
positionSeconds: duration - 20,
});
expect(upNext.visible()).toBe(false);
setState({
canNextEpisode: true,
durationSeconds: 1200,
positionSeconds: 1100,
});
at(duration - 20);
capabilities.set({ ...DEFAULT_PLAYER_CAPABILITIES });
expect(upNext.visible()).toBe(false);
});
@@ -123,8 +217,8 @@ describe('ControlsUpNext', () => {
setState({
status: 'ended',
canNextEpisode: true,
durationSeconds: 1200,
positionSeconds: 1200,
durationSeconds: duration,
positionSeconds: duration,
});
expect(upNext.visible()).toBe(false);
});
@@ -1,12 +1,65 @@
import { Signal, computed } from '@angular/core';
import { Signal, computed, signal } from '@angular/core';
import type {
PlayerControlsCapabilities,
PlayerControlsState,
PlayerTimelineSegment,
PlayerUpNextItem,
} from './player-controls.model';
/** The card appears once this little of the episode is left. */
export const UP_NEXT_THRESHOLD_SECONDS = 8 * 60;
/** Share of the episode's length the card is shown for. */
export const UP_NEXT_THRESHOLD_RATIO = 0.04;
/** Bounds of the adaptive lead: long enough to read, short enough not to nag. */
export const UP_NEXT_MIN_THRESHOLD_SECONDS = 40;
export const UP_NEXT_MAX_THRESHOLD_SECONDS = 3 * 60;
/** A credits chapter only counts in the episode's last third. */
const CREDITS_CHAPTER_MIN_RATIO = 2 / 3;
/** Long credits still bring the card no further forward than this. */
export const UP_NEXT_MAX_CREDITS_LEAD_SECONDS = 5 * 60;
const CREDITS_CHAPTER_TITLE =
/\bcredits?\b|\boutro\b|\bending\b|титры|abspann|générique|genérico|créditos|crediti|napisy/i;
/** Anime chapter lists mark the ending theme as `ED` / `ED1`. */
const ENDING_THEME_TITLE = /^\s*ED\d*\s*$/;
/**
* Seconds before the end at which the card appears: 4% of the episode,
* clamped to 40 s … 3 min, so a 22-minute sitcom gets under a minute and an
* hour-long drama a little over two.
*/
export function upNextThresholdSeconds(durationSeconds: number): number {
return Math.min(
UP_NEXT_MAX_THRESHOLD_SECONDS,
Math.max(
UP_NEXT_MIN_THRESHOLD_SECONDS,
Math.round(durationSeconds * UP_NEXT_THRESHOLD_RATIO)
)
);
}
/**
* Where the closing credits start, when the file's chapters name them.
* Only chapters in the last third count, so an opening "Credits" chapter
* never brings the card forward.
*/
export function creditsStartSeconds(
segments: readonly PlayerTimelineSegment[] | null,
durationSeconds: number
): number | null {
const earliest = durationSeconds * CREDITS_CHAPTER_MIN_RATIO;
let start: number | null = null;
for (const segment of segments ?? []) {
if (
segment.title &&
(CREDITS_CHAPTER_TITLE.test(segment.title) ||
ENDING_THEME_TITLE.test(segment.title)) &&
segment.startSeconds >= earliest &&
segment.startSeconds < durationSeconds &&
(start === null || segment.startSeconds < start)
) {
start = segment.startSeconds;
}
}
return start;
}
export interface ControlsUpNextDeps {
item: Signal<PlayerUpNextItem | null>;
@@ -15,38 +68,69 @@ export interface ControlsUpNextDeps {
showControls: Signal<boolean>;
/** The settings panel covers the card's corner; it yields while open. */
settingsOpen: Signal<boolean>;
/** Chapters; a closing-credits chapter decides when the card appears. */
segments: Signal<readonly PlayerTimelineSegment[] | null>;
}
/**
* When the "Up next" card shows: a series host supplied the next episode
* (possibly the first of the following season, which the transport's
* season-local `canNextEpisode` does not cover), the engine plays series,
* the episode has a known length, and no more than the threshold is left. Live streams and open-ended
* VOD never qualify because they have no remaining time to count down.
* the episode has a known length, and the closing credits have started — or,
* without a credits chapter, the adaptive lead is reached. Live streams and
* open-ended VOD never qualify because they have no remaining time to count
* down. The viewer can dismiss the card for the episode it points to, and it
* shrinks to a compact pill once it has been seen.
*/
export class ControlsUpNext {
constructor(private readonly deps: ControlsUpNextDeps) {}
/** Label of the next episode whose card the viewer closed. */
private readonly dismissedLabel = signal<string | null>(null);
/** Label of the next episode whose card has collapsed to a pill. */
private readonly collapsedLabel = signal<string | null>(null);
private readonly duration = computed(() => {
const { durationSeconds } = this.deps.state();
return typeof durationSeconds === 'number' &&
Number.isFinite(durationSeconds) &&
durationSeconds > 0
? durationSeconds
: null;
});
readonly remainingSeconds = computed(() => {
const { durationSeconds, positionSeconds } = this.deps.state();
if (
typeof durationSeconds !== 'number' ||
!Number.isFinite(durationSeconds) ||
durationSeconds <= 0
) {
const duration = this.duration();
if (duration === null) {
return null;
}
return Math.max(0, durationSeconds - Math.max(0, positionSeconds));
const position = Math.max(0, this.deps.state().positionSeconds);
return Math.max(0, duration - position);
});
/** Remaining time at which the card appears for this episode. */
readonly thresholdSeconds = computed(() => {
const duration = this.duration();
if (duration === null) {
return null;
}
const credits = creditsStartSeconds(this.deps.segments(), duration);
return credits !== null
? Math.min(duration - credits, UP_NEXT_MAX_CREDITS_LEAD_SECONDS)
: upNextThresholdSeconds(duration);
});
readonly item = computed<PlayerUpNextItem | null>(() => {
const item = this.deps.item();
const remaining = this.remainingSeconds();
const threshold = this.thresholdSeconds();
const state = this.deps.state();
if (
!item ||
remaining === null ||
remaining > UP_NEXT_THRESHOLD_SECONDS ||
threshold === null ||
remaining > threshold ||
item.label === this.dismissedLabel() ||
state.isLive ||
// An ended episode with autoplay off schedules no switch: a
// countdown would promise one. Autoplay replaces the playback.
@@ -62,8 +146,18 @@ export class ControlsUpNext {
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))
);
readonly collapsed = computed(() => {
const item = this.item();
return !!item && item.label === this.collapsedLabel();
});
/** Hides the card until the next episode it would point to changes. */
dismiss(): void {
this.dismissedLabel.set(this.item()?.label ?? null);
}
/** Shrinks the card to its pill for the rest of this episode. */
collapse(): void {
this.collapsedLabel.set(this.item()?.label ?? null);
}
}
@@ -401,7 +401,7 @@ describe('PlayerControlsComponent dock', () => {
canSeek: true,
canNextEpisode: true,
durationSeconds: 1200,
positionSeconds: 1200 - 5 * 60,
positionSeconds: 1200 - 30,
});
beforeEach(() => {
@@ -423,8 +423,10 @@ describe('PlayerControlsComponent dock', () => {
setState({
canSeek: true,
canNextEpisode: true,
// Five minutes left of a 20-minute episode: still too early
// for its 48 s adaptive lead.
durationSeconds: 1200,
positionSeconds: 60,
positionSeconds: 1200 - 5 * 60,
});
fixture.detectChanges();
expect(
@@ -448,7 +450,7 @@ describe('PlayerControlsComponent dock', () => {
canSeek: true,
canNextEpisode: false,
durationSeconds: 1200,
positionSeconds: 1200 - 5 * 60,
positionSeconds: 1200 - 30,
});
fixture.detectChanges();
const card = query('[data-test-id="player-controls-up-next"]');
@@ -493,6 +495,27 @@ describe('PlayerControlsComponent dock', () => {
).toBeNull();
});
it('stays closed for this episode once dismissed', () => {
nearTheEnd();
fixture.detectChanges();
query('[data-test-id="player-controls-up-next-close"]')?.click();
fixture.detectChanges();
expect(
query('[data-test-id="player-controls-up-next"]')
).toBeNull();
setState({
canSeek: true,
canNextEpisode: true,
durationSeconds: 1200,
positionSeconds: 1200 - 10,
});
fixture.detectChanges();
expect(
query('[data-test-id="player-controls-up-next"]')
).toBeNull();
});
it('uses the compact card on a compact dock', () => {
nearTheEnd();
resizeTo(400);
@@ -592,9 +592,12 @@
class="player-controls__up-next"
[class.player-controls__up-next--compact]="isCompact()"
[item]="item"
[minutesLeft]="upNextCard.minutesLeft()"
[remainingSeconds]="upNextCard.remainingSeconds() ?? 0"
[compact]="isCompact()"
[collapsed]="upNextCard.collapsed()"
(selected)="reveal(); nextEpisodeRequested.emit()"
(dismissed)="upNextCard.dismiss()"
(collapseRequested)="upNextCard.collapse()"
(pointerenter)="chrome.onPointerEnter()"
(pointerleave)="chrome.onPointerLeave()"
(pointerdown)="chrome.onPointerDown()"
@@ -160,6 +160,7 @@ export class PlayerControlsComponent implements OnDestroy {
capabilities: this.capabilities,
showControls: this.showControls,
settingsOpen: this.settings.isOpen,
segments: this.timelineSegments,
});
private readonly controllerVolume = computed(() => this.state().volume);
readonly timeline = new ControlsTimeline(this.state, this.timelineSegments);
@@ -10,39 +10,45 @@
(item().title ? ' – ' + item().title : '')
"
>
<span class="player-up-next__thumb" aria-hidden="true">
@if (thumbnail(); as src) {
<img
class="player-up-next__image"
[src]="src"
alt=""
loading="lazy"
(error)="onThumbnailError()"
/>
} @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>
@if (!collapsed()) {
<span class="player-up-next__thumb" aria-hidden="true">
@if (thumbnail(); as src) {
<img
class="player-up-next__image"
[src]="src"
alt=""
loading="lazy"
(error)="onThumbnailError()"
/>
} @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 }}
@if (collapsed()) {
· {{ item().label }}
}
·
{{
'EMBEDDED_MPV.PLAYER.UP_NEXT_IN'
| translate: { minutes: minutesLeft() }
}}
</span>
<span class="player-up-next__title">
{{ item().title || item().label }}
{{ countdown().key | translate: countdown().params }}
</span>
@if (!collapsed()) {
<span class="player-up-next__title">
{{ item().title || item().label }}
</span>
}
</span>
@if (!compact()) {
<mat-icon class="player-up-next__icon" aria-hidden="true"
@@ -50,3 +56,12 @@
>
}
</button>
<button
type="button"
class="player-up-next__close"
data-test-id="player-controls-up-next-close"
[attr.aria-label]="'EMBEDDED_MPV.PLAYER.UP_NEXT_DISMISS' | translate"
(click)="dismissed.emit()"
>
<mat-icon aria-hidden="true">close</mat-icon>
</button>
@@ -1,6 +1,7 @@
// Glass card, bottom-right above the dock, inside the controls host (the
// `player-controls` container) whose `--pc-*` palette it reads. It does not
// fade with the controls: near the end of an episode it is worth seeing.
// fade with idle controls; instead it collapses to a pill once seen, and the
// corner close button dismisses it for the episode.
:host {
position: absolute;
right: 32px;
@@ -159,3 +160,46 @@
:host(.player-up-next--compact) .player-up-next__title {
font-size: 11.5px;
}
// Collapsed: a single-line pill with the label and countdown; the thumbnail
// and title are gone, so it no longer covers the picture.
:host(.player-up-next--collapsed) .player-up-next__button {
gap: 8px;
padding: 7px 12px;
border-radius: 999px;
}
.player-up-next__close {
position: absolute;
top: -8px;
right: -8px;
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
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: 50%;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
backdrop-filter: blur(18px) saturate(1.3);
cursor: pointer;
transition: background 120ms ease-out;
mat-icon {
width: 14px;
height: 14px;
font-size: 14px;
}
}
.player-up-next__close:hover {
background: rgba(12, 16, 23, 0.9);
}
.player-up-next__close:focus-visible {
outline: 2px solid var(--pc-text, #e7ecf3);
outline-offset: 2px;
}
@@ -1,6 +1,9 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { PlayerUpNextCardComponent } from './player-up-next-card.component';
import {
PlayerUpNextCardComponent,
UP_NEXT_COLLAPSE_DELAY_MS,
} from './player-up-next-card.component';
describe('PlayerUpNextCardComponent', () => {
let fixture: ComponentFixture<PlayerUpNextCardComponent>;
@@ -18,6 +21,8 @@ describe('PlayerUpNextCardComponent', () => {
PLAYER: {
UP_NEXT: 'Up next',
UP_NEXT_IN: 'in {{minutes}} min',
UP_NEXT_IN_SECONDS: 'in {{seconds}} s',
UP_NEXT_DISMISS: 'Hide up next',
},
},
});
@@ -29,11 +34,14 @@ describe('PlayerUpNextCardComponent', () => {
thumbnailUrl: 'https://img.example/still.jpg',
progressPercent: 40,
});
fixture.componentRef.setInput('minutesLeft', 7);
fixture.componentRef.setInput('remainingSeconds', 6 * 60 + 40);
fixture.detectChanges();
});
afterEach(() => fixture.destroy());
afterEach(() => {
fixture.destroy();
jest.useRealTimers();
});
it('renders the still, progress, countdown and title, and names itself', () => {
expect(query('.player-up-next__image')?.getAttribute('src')).toBe(
@@ -106,4 +114,116 @@ describe('PlayerUpNextCardComponent', () => {
expect(selected).toHaveBeenCalledTimes(1);
});
it('counts whole seconds in the last minute', () => {
fixture.componentRef.setInput('remainingSeconds', 44.2);
fixture.detectChanges();
expect(
query('.player-up-next__eyebrow')?.textContent?.replace(/\s+/g, ' ')
).toContain('Up next · in 45 s');
});
it('dismisses from the close button and from Escape without reaching the player', () => {
const dismissed = jest.fn();
const selected = jest.fn();
fixture.componentInstance.dismissed.subscribe(dismissed);
fixture.componentInstance.selected.subscribe(selected);
const close = query('[data-test-id="player-controls-up-next-close"]');
expect(close?.getAttribute('aria-label')).toBe('Hide up next');
close?.click();
expect(dismissed).toHaveBeenCalledTimes(1);
expect(selected).not.toHaveBeenCalled();
const documentListener = jest.fn();
document.addEventListener('keydown', documentListener);
const escape = new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
cancelable: true,
});
query('[data-test-id="player-controls-up-next"]')?.dispatchEvent(
escape
);
document.removeEventListener('keydown', documentListener);
expect(dismissed).toHaveBeenCalledTimes(2);
expect(documentListener).not.toHaveBeenCalled();
});
it('asks to collapse after a while, pausing the count while hovered', () => {
jest.useFakeTimers();
fixture.destroy();
fixture = TestBed.createComponent(PlayerUpNextCardComponent);
fixture.componentRef.setInput('item', {
label: 'S01E03',
title: 'The Third One',
thumbnailUrl: null,
progressPercent: null,
});
fixture.componentRef.setInput('remainingSeconds', 100);
fixture.detectChanges();
const collapse = jest.fn();
fixture.componentInstance.collapseRequested.subscribe(collapse);
jest.advanceTimersByTime(UP_NEXT_COLLAPSE_DELAY_MS - 1000);
fixture.nativeElement.dispatchEvent(new MouseEvent('mouseenter'));
fixture.detectChanges();
jest.advanceTimersByTime(UP_NEXT_COLLAPSE_DELAY_MS * 2);
expect(collapse).not.toHaveBeenCalled();
// Leaving resumes the remaining second instead of a fresh delay.
fixture.nativeElement.dispatchEvent(new MouseEvent('mouseleave'));
fixture.detectChanges();
jest.advanceTimersByTime(999);
expect(collapse).not.toHaveBeenCalled();
jest.advanceTimersByTime(1);
expect(collapse).toHaveBeenCalledTimes(1);
});
it('renders the collapsed pill with the label and without the still or title', () => {
fixture.componentRef.setInput('collapsed', true);
fixture.detectChanges();
expect(fixture.nativeElement.classList).toContain(
'player-up-next--collapsed'
);
expect(query('.player-up-next__thumb')).toBeNull();
expect(query('.player-up-next__title')).toBeNull();
expect(
query('.player-up-next__eyebrow')?.textContent?.replace(/\s+/g, ' ')
).toContain('Up next · S01E03 · in 7 min');
});
it('gives a new next episode the full collapse delay', () => {
jest.useFakeTimers();
fixture.destroy();
fixture = TestBed.createComponent(PlayerUpNextCardComponent);
const item = {
label: 'S01E03',
title: 'Three',
thumbnailUrl: null,
progressPercent: null,
};
fixture.componentRef.setInput('item', item);
fixture.componentRef.setInput('remainingSeconds', 100);
fixture.detectChanges();
const collapse = jest.fn();
fixture.componentInstance.collapseRequested.subscribe(collapse);
jest.advanceTimersByTime(UP_NEXT_COLLAPSE_DELAY_MS);
expect(collapse).toHaveBeenCalledTimes(1);
fixture.componentRef.setInput('collapsed', true);
fixture.detectChanges();
// The mounted card now points at the following episode.
fixture.componentRef.setInput('item', { ...item, label: 'S01E04' });
fixture.componentRef.setInput('collapsed', false);
fixture.detectChanges();
jest.advanceTimersByTime(UP_NEXT_COLLAPSE_DELAY_MS - 1);
expect(collapse).toHaveBeenCalledTimes(1);
jest.advanceTimersByTime(1);
expect(collapse).toHaveBeenCalledTimes(2);
});
});
@@ -2,6 +2,7 @@ import {
ChangeDetectionStrategy,
Component,
computed,
effect,
input,
output,
signal,
@@ -10,10 +11,15 @@ import { MatIconModule } from '@angular/material/icon';
import { TranslatePipe } from '@ngx-translate/core';
import type { PlayerUpNextItem } from './player-controls.model';
/** How long the full card shows before it shrinks to a pill. */
export const UP_NEXT_COLLAPSE_DELAY_MS = 10_000;
/**
* 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.
* time left, the next episode's label and title. One click plays it through
* the host's ordinary next-episode path, so fullscreen survives. After a few
* seconds of being seen (hovering pauses the count) it asks to collapse into
* a pill, and the close button or Escape dismisses it for this episode.
*/
@Component({
selector: 'app-player-up-next-card',
@@ -24,13 +30,40 @@ import type { PlayerUpNextItem } from './player-controls.model';
host: {
class: 'player-up-next',
'[class.player-up-next--compact]': 'compact()',
'[class.player-up-next--collapsed]': 'collapsed()',
'(mouseenter)': 'hovered.set(true)',
'(mouseleave)': 'hovered.set(false)',
'(keydown.escape)': 'onEscape($event)',
},
})
export class PlayerUpNextCardComponent {
readonly item = input.required<PlayerUpNextItem>();
readonly minutesLeft = input.required<number>();
readonly remainingSeconds = input.required<number>();
readonly compact = input(false);
readonly collapsed = input(false);
readonly selected = output<void>();
readonly dismissed = output<void>();
readonly collapseRequested = output<void>();
readonly hovered = signal(false);
/** Collapse delay still owed; hovering pauses it rather than resetting. */
private collapseRemainingMs = UP_NEXT_COLLAPSE_DELAY_MS;
/** The episode the owed delay belongs to; a new one starts afresh. */
private collapseLabel: string | null = null;
/** Whole seconds under a minute, otherwise whole minutes (at least 1). */
readonly countdown = computed(() => {
const seconds = Math.max(0, Math.ceil(this.remainingSeconds()));
return seconds < 60
? {
key: 'EMBEDDED_MPV.PLAYER.UP_NEXT_IN_SECONDS',
params: { seconds },
}
: {
key: 'EMBEDDED_MPV.PLAYER.UP_NEXT_IN',
params: { minutes: Math.ceil(seconds / 60) },
};
});
/** The still that failed to load; a new URL gets its own attempt. */
private readonly failedThumbnail = signal<string | null>(null);
@@ -39,7 +72,39 @@ export class PlayerUpNextCardComponent {
return url && url !== this.failedThumbnail() ? url : null;
});
constructor() {
effect((onCleanup) => {
const label = this.item().label;
if (label !== this.collapseLabel) {
this.collapseLabel = label;
this.collapseRemainingMs = UP_NEXT_COLLAPSE_DELAY_MS;
}
if (this.collapsed() || this.hovered()) {
return;
}
const startedAt = Date.now();
const timer = setTimeout(
() => this.collapseRequested.emit(),
this.collapseRemainingMs
);
onCleanup(() => {
clearTimeout(timer);
this.collapseRemainingMs = Math.max(
0,
this.collapseRemainingMs - (Date.now() - startedAt)
);
});
});
}
onThumbnailError(): void {
this.failedThumbnail.set(this.item().thumbnailUrl);
}
/** Escape on the card closes it rather than reaching the player. */
onEscape(event: Event): void {
event.preventDefault();
event.stopPropagation();
this.dismissed.emit();
}
}
@@ -55,7 +55,11 @@ describe('PortalInlinePlayerComponent up next rail', () => {
fixture?.destroy();
});
async function setup(railEnabled: boolean, player = 'videojs') {
async function setup(
railEnabled: boolean,
player = 'videojs',
cardEnabled = true
) {
TestBed.resetTestingModule();
await TestBed.configureTestingModule({
imports: [PortalInlinePlayerComponent, TranslateModule.forRoot()],
@@ -66,6 +70,7 @@ describe('PortalInlinePlayerComponent up next rail', () => {
player: signal(player),
playerAmbientMode: signal(false),
playerUpNextRail: signal(railEnabled),
playerUpNextCard: signal(cardEnabled),
stripCountryPrefix: signal(false),
},
},
@@ -149,6 +154,15 @@ describe('PortalInlinePlayerComponent up next rail', () => {
expect(component.playerUpNext()).toBeNull();
});
it('hands no "up next" card to the player when the setting is off', async () => {
await setup(true, 'videojs', false);
fixture.componentRef.setInput('playback', seriesPlayback);
fixture.componentRef.setInput('upNextEpisodes', upNextItems);
fixture.detectChanges();
expect(component.playerUpNext()).toBeNull();
});
it('plays the next season through the rail path once the season is over', async () => {
await setup(true);
fixture.componentRef.setInput('playback', seriesPlayback);
@@ -236,7 +236,7 @@ export class PortalInlinePlayerComponent {
/** The episode after the playing one, for the controls' "Up next" card. */
readonly playerUpNext = computed<PlayerUpNextItem | null>(() => {
const next = this.upNextRailNext();
if (!next) {
if (!next || this.settingsStore.playerUpNextCard?.() === false) {
return null;
}
return {
@@ -52,6 +52,14 @@ export const PLAYBACK_SETTINGS_SEARCH_ENTRIES: readonly SettingsSearchEntry[] =
keywords: ['up next', 'next episode', 'autoplay'],
fallbackId: 'video-player',
},
{
id: 'player-up-next-card',
section: 'playback',
labelKey: 'SETTINGS.PLAYER_UP_NEXT_CARD',
descriptionKey: 'SETTINGS.PLAYER_UP_NEXT_CARD_DESCRIPTION',
keywords: ['up next', 'next episode', 'popup'],
fallbackId: 'video-player',
},
{
id: 'fullscreen-channel-panel',
section: 'playback',