diff --git a/.changes/playback-up-next-card-timing.md b/.changes/playback-up-next-card-timing.md new file mode 100644 index 000000000..6aa6a226f --- /dev/null +++ b/.changes/playback-up-next-card-timing.md @@ -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. diff --git a/apps/web-e2e/src/xtream-series-playback.e2e.ts b/apps/web-e2e/src/xtream-series-playback.e2e.ts index 76c610080..80a5e7159 100644 --- a/apps/web-e2e/src/xtream-series-playback.e2e.ts +++ b/apps/web-e2e/src/xtream-series-playback.e2e.ts @@ -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 ({ diff --git a/apps/web/src/app/settings/settings-form.utils.ts b/apps/web/src/app/settings/settings-form.utils.ts index 317a76ed8..ddf8baa27 100644 --- a/apps/web/src/app/settings/settings-form.utils.ts +++ b/apps/web/src/app/settings/settings-form.utils.ts @@ -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, diff --git a/apps/web/src/app/settings/settings-playback-section.auto-failover.spec.ts b/apps/web/src/app/settings/settings-playback-section.auto-failover.spec.ts index 8b3dbb9d1..a43bfe1ad 100644 --- a/apps/web/src/app/settings/settings-playback-section.auto-failover.spec.ts +++ b/apps/web/src/app/settings/settings-playback-section.auto-failover.spec.ts @@ -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), diff --git a/apps/web/src/app/settings/settings-playback-section.component.html b/apps/web/src/app/settings/settings-playback-section.component.html index 7c93d5840..c3f312fd3 100644 --- a/apps/web/src/app/settings/settings-playback-section.component.html +++ b/apps/web/src/app/settings/settings-playback-section.component.html @@ -137,6 +137,30 @@ } + @if (supportsUpNextCard()) { +
+
+

+ {{ 'SETTINGS.PLAYER_UP_NEXT_CARD' | translate }} +

+

+ {{ 'SETTINGS.PLAYER_UP_NEXT_CARD_DESCRIPTION' | translate }} +

+
+
+ +
+
+ } + @if (supportsFullscreenChannelPanel()) {
{ ).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), diff --git a/apps/web/src/app/settings/settings-playback-section.component.ts b/apps/web/src/app/settings/settings-playback-section.component.ts index c6d980340..6b0afd01f 100644 --- a/apps/web/src/app/settings/settings-playback-section.component.ts +++ b/apps/web/src/app/settings/settings-playback-section.component.ts @@ -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; diff --git a/apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts b/apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts index 19a12e6a9..ed230adac 100644 --- a/apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts +++ b/apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts @@ -87,6 +87,7 @@ export const DEFAULT_SETTINGS = { webPlayerSharedControls: true, playerAmbientMode: false, playerUpNextRail: true, + playerUpNextCard: true, fullscreenChannelPanel: true, vodAutoFailover: false, m3uVodDetails: true, diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 630a3403f..2f73604da 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -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": "عرض الترجمة مبكرًا", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index ab95dc2b3..e23c291bc 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -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": "ورّي الترجمة قبل", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 5200ebf98..91f53d7b0 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -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": "Паказваць субтытры раней", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 40a3649a3..9f05ac68c 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -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", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 7399dec3e..f40ec13cf 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -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": "Εμφάνιση υποτίτλων νωρίτερα", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index a4e451c11..4e00a95c5 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -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", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index a301a8bd2..3fe7f484d 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -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", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index d0f515aa2..1c432081e 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -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", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 38af491ea..f62ce758d 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -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", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 17d75353d..78aee97ab 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -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", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index fabd87e46..ff22ff6ad 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -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": "字幕を早く表示", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index be9667f70..5065701fa 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -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": "자막을 더 빨리 표시", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 31f882591..60f8a51c1 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -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", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 01ed20280..692a3df29 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -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", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 35a2d69ee..aa718cf75 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -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", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 475f37650..0f6705621 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -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": "Показывать субтитры раньше", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 6abb29e49..ab028b62c 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -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", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 0a8c9fe33..355bc1d2e 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -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": "提前显示字幕", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 9749e845c..c04e86a2f 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -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": "提前顯示字幕", diff --git a/docs/architecture/player-controls-contract.md b/docs/architecture/player-controls-contract.md index 055121a8c..fc4e4c615 100644 --- a/docs/architecture/player-controls-contract.md +++ b/docs/architecture/player-controls-contract.md @@ -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 diff --git a/libs/services/src/lib/settings-store.defaults.ts b/libs/services/src/lib/settings-store.defaults.ts index fb5287558..794437f24 100644 --- a/libs/services/src/lib/settings-store.defaults.ts +++ b/libs/services/src/lib/settings-store.defaults.ts @@ -16,6 +16,7 @@ export const DEFAULT_SETTINGS: Settings = { webPlayerSharedControls: true, playerAmbientMode: false, playerUpNextRail: true, + playerUpNextCard: true, fullscreenChannelPanel: true, vodAutoFailover: false, m3uVodDetails: true, diff --git a/libs/services/src/lib/settings-store.service.spec.ts b/libs/services/src/lib/settings-store.service.spec.ts index 029d2aaca..4e106c341 100644 --- a/libs/services/src/lib/settings-store.service.spec.ts +++ b/libs/services/src/lib/settings-store.service.spec.ts @@ -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); diff --git a/libs/services/src/lib/settings-store.service.ts b/libs/services/src/lib/settings-store.service.ts index 760717374..71800a36e 100644 --- a/libs/services/src/lib/settings-store.service.ts +++ b/libs/services/src/lib/settings-store.service.ts @@ -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, diff --git a/libs/shared/interfaces/src/lib/settings.interface.ts b/libs/shared/interfaces/src/lib/settings.interface.ts index cb58da8aa..dfbd71a10 100644 --- a/libs/shared/interfaces/src/lib/settings.interface.ts +++ b/libs/shared/interfaces/src/lib/settings.interface.ts @@ -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` — diff --git a/libs/ui/playback/src/lib/player-controls/controls-up-next.spec.ts b/libs/ui/playback/src/lib/player-controls/controls-up-next.spec.ts index 69ba70311..2ea037f81 100644 --- a/libs/ui/playback/src/lib/player-controls/controls-up-next.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/controls-up-next.spec.ts @@ -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; let state: WritableSignal; let capabilities: WritableSignal; let showControls: WritableSignal; let settingsOpen: WritableSignal; + let segments: WritableSignal; let upNext: ControlsUpNext; const setState = (overrides: Partial) => 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); }); diff --git a/libs/ui/playback/src/lib/player-controls/controls-up-next.ts b/libs/ui/playback/src/lib/player-controls/controls-up-next.ts index 0b13db46f..0353c9262 100644 --- a/libs/ui/playback/src/lib/player-controls/controls-up-next.ts +++ b/libs/ui/playback/src/lib/player-controls/controls-up-next.ts @@ -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; @@ -15,38 +68,69 @@ export interface ControlsUpNextDeps { showControls: Signal; /** The settings panel covers the card's corner; it yields while open. */ settingsOpen: Signal; + /** Chapters; a closing-credits chapter decides when the card appears. */ + segments: Signal; } /** * 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(null); + /** Label of the next episode whose card has collapsed to a pill. */ + private readonly collapsedLabel = signal(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(() => { 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); + } } diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.dock.spec.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.dock.spec.ts index 57e0313fd..e57fed837 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.dock.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.dock.spec.ts @@ -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); diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.html b/libs/ui/playback/src/lib/player-controls/player-controls.component.html index 60acd35e0..f695c4453 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.html +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.html @@ -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()" diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.ts index 477898bec..d5552c155 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.ts +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.ts @@ -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); diff --git a/libs/ui/playback/src/lib/player-controls/player-up-next-card.component.html b/libs/ui/playback/src/lib/player-controls/player-up-next-card.component.html index b70e9151a..a1f28e499 100644 --- a/libs/ui/playback/src/lib/player-controls/player-up-next-card.component.html +++ b/libs/ui/playback/src/lib/player-controls/player-up-next-card.component.html @@ -10,39 +10,45 @@ (item().title ? ' – ' + item().title : '') " > - + @if (!collapsed()) { + + } {{ 'EMBEDDED_MPV.PLAYER.UP_NEXT' | translate }} + @if (collapsed()) { + · {{ item().label }} + } · - {{ - 'EMBEDDED_MPV.PLAYER.UP_NEXT_IN' - | translate: { minutes: minutesLeft() } - }} - - - {{ item().title || item().label }} + {{ countdown().key | translate: countdown().params }} + @if (!collapsed()) { + + {{ item().title || item().label }} + + } @if (!compact()) {