diff --git a/data/arabic/live/67574192.json b/data/arabic/live/67574192.json index 0ff34f8d6e8..aa1dfcc8ab9 100644 --- a/data/arabic/live/67574192.json +++ b/data/arabic/live/67574192.json @@ -32,7 +32,7 @@ "sportDataEvent": { "id": null }, "eavisEvent": { "id": null }, "article": { "id": null }, - "mediaCollections": [], + "mediaCollections": null, "keyHighlight": { "id": null }, "electionBanner": { "id": null }, "supportingLinks": { "id": null }, diff --git a/data/mundo/live/c7dkx155e626t.json b/data/mundo/live/c7dkx155e626t.json new file mode 100644 index 00000000000..49296d71fcb --- /dev/null +++ b/data/mundo/live/c7dkx155e626t.json @@ -0,0 +1,465 @@ +{ + "data": { + "title": "USA Elections Aftermath: Get the latest updates", + "description": "Live Streaming: The Inauguration of the 45th President of the United States", + "language": "es", + "headerImage": { + "url": "https://ichef.bbci.co.uk/ace/standard/480/cpsdevpb/ed77/test/98a4c130-b304-11ef-acb8-cfaabd95075c.jpg", + "urlTemplate": "https://ichef.bbci.co.uk/ace/standard/{width}/cpsdevpb/ed77/test/98a4c130-b304-11ef-acb8-cfaabd95075c.jpg", + "height": 549, + "width": 976, + "altText": "test", + "copyright": "BBC" + }, + "promoImage": { + "url": "https://ichef.bbci.co.uk/ace/standard/480/cpsdevpb/8edf/test/a9379d10-b304-11ef-acb8-cfaabd95075c.jpg", + "urlTemplate": "https://ichef.bbci.co.uk/ace/standard/{width}/cpsdevpb/8edf/test/a9379d10-b304-11ef-acb8-cfaabd95075c.jpg", + "height": 549, + "width": 976, + "altText": "test", + "copyright": "BBC" + }, + "home": "mundo", + "section": null, + "commercialInfo": { + "adCategory": null, + "adSubCategory": null, + "hasAdvertising": true + }, + "sportDataEvent": { + "id": null + }, + "eavisEvent": { + "id": null + }, + "article": { + "id": null + }, + "mediaCollections": [ + { + "type": "liveMedia", + "model": { + "urn": "urn:bbc:pips:pid:p0gh4n63", + "title": "Non-Stop Cartoons!", + "type": "episode", + "synopses": { + "short": "Toon in, kick back and relax to 100% cartoons!", + "medium": "Toon in, kick back and relax. From laugh out loud to mischief and mayhem. 100% cartoons all day long.", + "long": "Toon in, kick back and relax. From laugh out loud to mischief and mayhem. 100% cartoons all day long. Join your favourites Grizzy, Shaun, Taffy, Boy Girl Dog Cat Mouse Cheese, The Deep and those Monster Loving Maniacs." + }, + "mediaType": "audio_video", + "imageUrlTemplate": "https://ichef.bbci.co.uk/images/ic/$recipe/p0k31t4d.jpg", + "masterbrand": { + "id": "cbbc", + "name": "CBBC", + "networkName": "CBBC", + "type": "tv", + "imageUrlTemplate": "ichef.bbci.co.uk/images/ic/$recipe/p0f8qps2.jpg" + }, + "version": { + "vpid": "p0gh4n67", + "duration": "PT24H", + "availabilityType": "webcast", + "versionTypes": [ + { + "type": "Original", + "name": "Original version" + } + ], + "schedule": { + "start": "2024-12-19T07:00:20Z", + "accurateStart": "2024-12-19T07:00:20Z", + "end": "2024-12-19T12:00:20Z" + }, + "serviceId": null, + "authToken": null, + "status": "LIVE", + "warnings": { + "warning_text": "Contains some upsetting scenes.", + "warning": [ + { + "warning_code": "L1", + "short_description": "Some upsetting scenes" + } + ] + } + }, + "leadMedia": true + } + }, + { + "type": "liveMedia", + "model": { + "urn": "urn:bbc:pips:pid:l0056wdq", + "title": "Starmer Scrutinised By MPs", + "type": "episode", + "synopses": { + "short": "Keir Starmer facing questions in his first appearance before the Liaison Committee", + "medium": "", + "long": "" + }, + "mediaType": "audio_video", + "imageUrlTemplate": "https://ichef.bbci.co.uk/images/ic/$recipe/p0kd22h2.jpg", + "masterbrand": { + "id": "bbc_news", + "name": "BBC News", + "networkName": "BBC News", + "type": "", + "imageUrlTemplate": "ichef.bbci.co.uk/images/ic/$recipe/p0bvs4dq.jpg" + }, + "version": { + "vpid": "l0056wdr", + "duration": "PT23H56M13S", + "availabilityType": "webcast", + "versionTypes": [ + { + "type": "Original", + "name": "Original version" + } + ], + "schedule": { + "start": "2024-12-19T14:14:48Z", + "accurateStart": "2024-12-19T14:17:35Z", + "end": "2024-12-20T14:13:48Z" + }, + "serviceId": null, + "authToken": null, + "status": "LIVE", + "warnings": null + }, + "leadMedia": true + } + } + ], + "keyHighlight": { + "id": null + }, + "electionBanner": { + "id": null + }, + "supportingLinks": { + "id": null + }, + "riddle": { + "id": null + }, + "seo": { + "seoTitle": "USA Elections Aftermath: Get the latest updates", + "seoDescription": "Live Streaming: The Inauguration of the 45th President of the United States", + "datePublished": "2024-12-05T11:26:00.000Z", + "dateModified": "2024-12-05T12:23:32.000Z" + }, + "endDateTime": "2025-12-06T11:25:00.000Z", + "startDateTime": "2024-12-05T11:26:00.000Z", + "firstPublishedDateTime": "2024-12-05T11:51:25.301Z", + "type": "live-coverage", + "dataSource": "TIPO", + "isLive": true, + "summaryPoints": { + "id": "urn:bbc:optimo:asset:c12x81qd94vo", + "content": { + "model": { + "blocks": [ + { + "id": "982be8ac", + "type": "text", + "model": { + "blocks": [ + { + "id": "8596bac4", + "type": "unorderedList", + "model": { + "blocks": [ + { + "id": "9b4a62ca", + "type": "listItem", + "model": { + "blocks": [ + { + "id": "ae7b1150", + "type": "paragraph", + "model": { + "text": "Brian Thompson, director ejecutivo de United Health Care (UHC), la mayor aseguradora de salud privada de Estados Unidos, fue asesinado este miércoles en Nueva York por un hombre enmascarado", + "blocks": [ + { + "id": "c5947833", + "type": "fragment", + "model": { + "text": "Brian Thompson, director ejecutivo de United Health Care (UHC), la mayor aseguradora de salud privada de Estados Unidos, fue asesinado este miércoles en Nueva York por un hombre enmascarado", + "attributes": [] + } + } + ] + } + } + ] + } + }, + { + "id": "e9cb4a5b", + "type": "listItem", + "model": { + "blocks": [ + { + "id": "79250da1", + "type": "paragraph", + "model": { + "text": "En lo que los investigadores describieron como un \"ataque descarado y selectivo\".", + "blocks": [ + { + "id": "cde27120", + "type": "fragment", + "model": { + "text": "En lo que los investigadores describieron como un \"ataque descarado y selectivo\".", + "attributes": [] + } + } + ] + } + } + ] + } + }, + { + "id": "b4be2cf4", + "type": "listItem", + "model": { + "blocks": [ + { + "id": "11342a4b", + "type": "paragraph", + "model": { + "text": "Agentes de policía de la ciudad de Nueva York están utilizando tecnología de reconocimiento facial y un teléfono celular desechado para identificar al hombre que mató a Thompson.", + "blocks": [ + { + "id": "62fb4406", + "type": "fragment", + "model": { + "text": "Agentes de policía de la ciudad de Nueva York están utilizando tecnología de reconocimiento facial y un teléfono celular desechado para identificar al hombre que mató a Thompson.", + "attributes": [] + } + } + ] + } + } + ] + } + } + ] + } + } + ] + } + } + ] + } + } + }, + "liveTextStream": { + "id": "52C95343A578423ABFA312004999027F", + "contributors": null, + "content": { + "data": { + "results": [ + { + "typeCode": null, + "header": { + "model": { + "blocks": [ + { + "id": "c2934708", + "type": "headline", + "model": { + "blocks": [ + { + "id": "9b4e950e", + "type": "text", + "model": { + "blocks": [ + { + "id": "b706438f", + "type": "paragraph", + "model": { + "text": "Asesinan a tiros en Nueva York al director ejecutivo de la principal aseguradora privada de salud de EE.UU.", + "blocks": [ + { + "id": "c389157d", + "type": "fragment", + "model": { + "text": "Asesinan a tiros en Nueva York al director ejecutivo de la principal aseguradora privada de salud de EE.UU.", + "attributes": [] + } + } + ] + } + } + ] + } + } + ] + } + } + ] + } + }, + "content": { + "model": { + "blocks": [ + { + "id": "7eff4bad", + "type": "paragraph", + "model": { + "text": "Brian Thompson, director ejecutivo de United Health Care (UHC), la mayor aseguradora de salud privada de Estados Unidos, fue asesinado este miércoles en Nueva York por un hombre enmascarado, en lo que los investigadores describieron como un \"ataque descarado y selectivo\".", + "blocks": [ + { + "id": "1a200412", + "type": "fragment", + "model": { + "text": "Brian Thompson, director ejecutivo de United Health Care (UHC), la mayor aseguradora de salud privada de Estados Unidos, fue asesinado este miércoles en Nueva York por un hombre enmascarado, en lo que los investigadores describieron como un \"ataque descarado y selectivo\".", + "attributes": ["bold"] + } + } + ] + } + }, + { + "id": "a71ec7cb", + "type": "paragraph", + "model": { + "text": "Agentes de policía de la ciudad de Nueva York están utilizando tecnología de reconocimiento facial y un teléfono celular desechado para identificar al hombre que mató a Thompson.", + "blocks": [ + { + "id": "b7768413", + "type": "fragment", + "model": { + "text": "Agentes de policía de la ciudad de Nueva York están utilizando tecnología de reconocimiento facial y un teléfono celular desechado para identificar al hombre que mató a Thompson.", + "attributes": [] + } + } + ] + } + }, + { + "id": "4c027bca", + "type": "paragraph", + "model": { + "text": "El ejecutivo, de 50 años de edad, fue atacado a disparos alrededor de las 06:45 hora local (11:45 GMT) en la entrada de un hotel en Manhattan, donde tenía previsto hablar en una conferencia de inversores más tarde ese mismo día.", + "blocks": [ + { + "id": "35186eb8", + "type": "fragment", + "model": { + "text": "El ejecutivo, de 50 años de edad, fue atacado a disparos alrededor de las 06:45 hora local (11:45 GMT) en la entrada de un hotel en Manhattan, donde tenía previsto hablar en una conferencia de inversores más tarde ese mismo día.", + "attributes": [] + } + } + ] + } + }, + { + "id": "b2f51c7d", + "type": "paragraph", + "model": { + "text": "Vistiendo un suéter con capucha, el atacante llegó al lugar caminando unos cinco minutos antes que Thompson. Cuando el ejecutivo llegó, se le acercó y le disparó con una pistola. Después desatascó el arma e hizo un segundo disparo.", + "blocks": [ + { + "id": "891df326", + "type": "fragment", + "model": { + "text": "Vistiendo un suéter con capucha, el atacante llegó al lugar caminando unos cinco minutos antes que Thompson. Cuando el ejecutivo llegó, ", + "attributes": [] + } + }, + { + "id": "2e3d7d9a", + "type": "fragment", + "model": { + "text": "se le acercó y le disparó con una pistola", + "attributes": ["bold"] + } + }, + { + "id": "98c981f7", + "type": "fragment", + "model": { + "text": ". Después desatascó el arma e hizo ", + "attributes": [] + } + }, + { + "id": "5d795bf0", + "type": "fragment", + "model": { + "text": "un segundo disparo", + "attributes": ["bold"] + } + }, + { + "id": "0d82c61b", + "type": "fragment", + "model": { + "text": ".", + "attributes": [] + } + } + ] + } + } + ] + } + }, + "link": null, + "urn": "asset:4b5c66af-7b36-4e25-a49c-ce975df249f5", + "type": "POST", + "options": { + "isBreakingNews": false + }, + "dates": { + "firstPublished": "2024-12-05T12:23:32.000Z", + "lastPublished": "2024-12-05T12:23:32.000Z", + "time": null, + "curated": "2024-12-05T12:23:33.050Z" + }, + "titles": [ + { + "title": null, + "source": "primary" + } + ], + "descriptions": [ + { + "text": null, + "source": "summary" + } + ], + "images": [ + { + "originalUrl": null, + "altText": null, + "copyright": null, + "urlTemplate": null, + "url": null + } + ] + } + ], + "page": { + "index": 1, + "total": 1 + } + } + } + }, + "metadata": { + "atiAnalytics": { + "contentId": "urn:bbc:tipo:topic:c7dkx155e626t", + "contentType": "live-coverage", + "pageIdentifier": "live_coverage.c7dkx155e626t.page", + "pageTitle": "USA Elections Aftermath: Get the latest updates", + "timePublished": "2024-12-05T11:26:00.000Z", + "timeUpdated": "2024-12-05T12:23:32.000Z" + } + } + }, + "contentType": "application/json; charset=utf-8" +} diff --git a/data/pidgin/live/c7p765ynk9qt.json b/data/pidgin/live/c7p765ynk9qt.json index 810d6b69355..19daf4b1214 100644 --- a/data/pidgin/live/c7p765ynk9qt.json +++ b/data/pidgin/live/c7p765ynk9qt.json @@ -15,7 +15,7 @@ "sportDataEvent": { "id": null }, "eavisEvent": { "id": null }, "article": { "id": null }, - "mediaCollections": [{ "id": null }], + "mediaCollections": null, "keyHighlight": { "id": null }, "electionBanner": { "id": null }, "supportingLinks": { "id": null }, @@ -207,15 +207,15 @@ "id": "cb0f3228", "type": "video", "model": { - "locator": "urn:bbc:pips:pid:p01vz9cq", + "locator": "urn:bbc:pips:pid:p0gh4n67", "blocks": [ { "id": "2c039c31", "type": "clipMedia", "model": { - "id": "urn:bbc:pips:pid:p01vz9cq", + "id": "urn:bbc:pips:pid:p0gh4n67", "urns": { - "pipsPid": "urn:bbc:pips:pid:p01vz9cq" + "pipsPid": "urn:bbc:pips:pid:p0gh4n67" }, "images": [ { @@ -233,7 +233,7 @@ "source": "pipsImage" } ], - "assetPath": "p01vz9cq", + "assetPath": "p0gh4n67", "type": "video", "headlines": { "primaryHeadline": "Lionel Messi Skills", @@ -243,8 +243,8 @@ }, "analytics": { "page": { - "name": "programmes.av.p01vz9cq.page", - "contentId": "urn:bbc:pips:pid:p01vz9cq", + "name": "programmes.av.p0gh4n67.page", + "contentId": "urn:bbc:pips:pid:p0gh4n67", "producer": "PROGRAMMES" } }, @@ -274,16 +274,17 @@ "lastPublished": "2024-02-28T10:32:08Z", "firstPublished": null, "video": { - "id": "p01vz9cq", + "id": "p0gh4n67", "title": "Lionel Messi Skills", "holdingImage": { "id": "https://ichef.test.bbci.co.uk/images/ic/$recipe/p01vzbd6.jpg", "altText": "Gary Lineker talks Messi" }, "version": { - "id": "p01vz9cs", + "id": "p0gh4n67", "duration": "PT34S", "kind": "programme", + "simulcast": "true", "guidance": "Contains strong language and some upsetting scenes.", "territories": ["nonuk", "uk"] }, @@ -292,7 +293,7 @@ "isUnavailable": false }, "attributions": null, - "link": { "path": "/programmes/p01vz9cq" }, + "link": { "path": "/programmes/p0gh4n67" }, "section": null, "isSharingAllowed": true } diff --git a/data/serbian/live/media-23179005/lat.json b/data/serbian/live/media-23179005/lat.json index dd9a966fc2a..4768d439f7c 100644 --- a/data/serbian/live/media-23179005/lat.json +++ b/data/serbian/live/media-23179005/lat.json @@ -22,7 +22,7 @@ "article": { "id": null }, - "mediaCollections": [], + "mediaCollections": null, "keyHighlight": { "id": null }, diff --git a/docs/User-Experience/colours.tsx b/docs/User-Experience/colours.tsx index 22a97048384..8cd845c59ec 100644 --- a/docs/User-Experience/colours.tsx +++ b/docs/User-Experience/colours.tsx @@ -55,6 +55,7 @@ export const CROSS_PLATFORM = [ { colorName: 'SERVICE_NEUTRAL_DARK', colorCode: '#0051AD' }, { colorName: 'LIVE_CORE', colorCode: '#009E9E' }, { colorName: 'LIVE_LIGHT', colorCode: '#00CCC7' }, + { colorName: 'LIVE_MEDIUM', colorCode: '#008282' }, { colorName: 'LIVE_DARK', colorCode: '#006666' }, { colorName: 'SUCCESS_CORE', colorCode: '#148A00' }, { colorName: 'SUCCESS_LIGHT', colorCode: '#49CC29' }, diff --git a/src/app/components/LiveHeaderMedia/index.stories.tsx b/src/app/components/LiveHeaderMedia/index.stories.tsx new file mode 100644 index 00000000000..ff5af24036c --- /dev/null +++ b/src/app/components/LiveHeaderMedia/index.stories.tsx @@ -0,0 +1,48 @@ +/* eslint-disable camelcase */ +/** @jsx jsx */ +import { css, jsx, Theme } from '@emotion/react'; +import mundoLiveFixture from '#data/mundo/live/c7dkx155e626t.json'; +import LiveHeaderMedia from '.'; +import { MediaCollection } from '../MediaLoader/types'; +import metadata from './metadata.json'; + +type Props = { + warnings: { + warning_text: string; + warning: { + warning_code: string; + short_description: string; + }[]; + }; +}; + +export const Component = ({ warnings }: Props) => { + const fixtureData = mundoLiveFixture.data.mediaCollections; + fixtureData[0].model.version.warnings = warnings; + + return ( +
css({ background: palette.BLACK })}> + +
+ ); +}; + +const l1Warning = { + warning_text: 'Contains some upsetting scenes.', + warning: [ + { + warning_code: 'L1', + short_description: 'Some upsetting scenes', + }, + ], +}; + +export const ComponentWithGuidance = () => ; + +export default { + title: 'Components/LiveHeaderMedia', + Component, + parameters: { + metadata, + }, +}; diff --git a/src/app/components/LiveHeaderMedia/index.styles.tsx b/src/app/components/LiveHeaderMedia/index.styles.tsx new file mode 100644 index 00000000000..bdb0cd51a84 --- /dev/null +++ b/src/app/components/LiveHeaderMedia/index.styles.tsx @@ -0,0 +1,169 @@ +import NO_JS_CLASSNAME from '#app/lib/noJs.const'; +import pixelsToRem from '#app/utilities/pixelsToRem'; +import { css, Theme } from '@emotion/react'; + +export default { + componentContainer: ({ spacings }: Theme) => + css({ + width: '100%', + marginTop: `${spacings.DOUBLE}rem`, + [`.${NO_JS_CLASSNAME} &`]: { + display: 'none', + }, + }), + nojs: ({ palette, spacings, fontSizes, fontVariants }: Theme) => + css({ + ...fontSizes.pica, + ...fontVariants.sansRegular, + color: palette.WHITE, + div: { + marginTop: `${spacings.DOUBLE}rem`, + }, + strong: { + display: 'block', + marginTop: `${spacings.DOUBLE}rem`, + fontWeight: 'normal', + }, + }), + mediaButton: ({ mq }: Theme) => + css({ + position: 'relative', + padding: 0, + [mq.FORCED_COLOURS]: { + color: 'canvasText', + }, + }), + openButton: () => + css({ + cursor: 'pointer', + backgroundColor: 'unset', + border: 'unset', + textAlign: 'start', + }), + watchLiveCTAText: ({ spacings, palette }: Theme) => + css({ + color: palette.WHITE, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + svg: { + height: `${spacings.DOUBLE}rem`, + width: `${spacings.DOUBLE}rem`, + verticalAlign: 'middle', + fill: 'currentcolor', + color: palette.WHITE, + marginInlineEnd: `${spacings.FULL}rem`, + }, + 'button:hover &, button:focus-visible &': { + textDecoration: 'underline', + }, + }), + title: () => + css({ + display: 'block', + width: '100%', + }), + guidanceMessage: ({ palette, spacings }: Theme) => + css({ + display: 'block', + marginTop: `${spacings.DOUBLE}rem`, + color: palette.GREY_2, + textAlign: 'start', + }), + watchLiveCTA: ({ palette, mq, spacings }: Theme) => + css({ + width: `${pixelsToRem(171)}rem`, + border: 0, + backgroundColor: palette.LIVE_MEDIUM, + padding: `${pixelsToRem(11)}rem`, + marginTop: `${spacings.DOUBLE}rem`, + [mq.GROUP_2_MAX_WIDTH]: { + width: '100%', + }, + [mq.FORCED_COLOURS]: { + color: 'canvasText', + border: `${pixelsToRem(2)}rem solid canvasText`, + }, + }), + liveMediaStreamText: ({ palette }: Theme) => + css({ + color: palette.GREY_4, + }), + liveMediaStreamContainer: ({ mq }: Theme) => + css({ + maxWidth: '60%', + [mq.GROUP_2_MAX_WIDTH]: { + width: '100%', + }, + [mq.GROUP_4_MAX_WIDTH]: { + width: '50%', + }, + }), + closeButton: () => + css({ + display: 'flex', + justifyContent: 'space-between', + cursor: 'pointer', + background: 'none', + width: '100%', + border: 0, + lineHeight: 0, + alignItems: 'center', + }), + closeContainer: ({ spacings, palette, mq }: Theme) => + css({ + verticalAlign: 'center', + svg: { + fill: 'currentcolor', + color: palette.WHITE, + height: `${spacings.DOUBLE}rem`, + width: `${spacings.DOUBLE}rem`, + margin: `${pixelsToRem(13)}rem`, + }, + backgroundColor: palette.BLACK, + border: `${palette.WHITE} solid ${pixelsToRem(1)}rem`, + 'button:hover &, button:focus-visible &': { + backgroundColor: palette.POSTBOX, + outline: `${palette.WHITE} solid ${pixelsToRem(1)}rem`, + }, + [mq.FORCED_COLOURS]: { + color: 'canvasText', + }, + }), + liveMediaSpan: () => + css({ + maxWidth: '100%', + }), + mediaLoader: ({ spacings }: Theme) => + css({ + maxWidth: '100%', + marginTop: `${spacings.DOUBLE}rem`, + }), + mediaDescription: () => + css({ + display: 'block', + width: '100%', + marginTop: 0, + span: { margin: 0 }, + }), + openMediaDescription: ({ palette }: Theme) => + css({ + span: { color: palette.GREY_4 }, + }), + closeMediaDescription: ({ mq, palette }: Theme) => + css({ + textAlign: 'start', + span: { color: palette.WHITE }, + 'button:hover &, button:focus-visible &': { + span: { + textDecoration: 'underline', + [mq.FORCED_COLOURS]: { + textDecoration: 'underline', + }, + }, + }, + }), + + underlineFocus: () => css({ display: 'none' }), + hideComponent: () => css({ display: 'none' }), +}; diff --git a/src/app/components/LiveHeaderMedia/index.test.tsx b/src/app/components/LiveHeaderMedia/index.test.tsx new file mode 100644 index 00000000000..abc3748e38a --- /dev/null +++ b/src/app/components/LiveHeaderMedia/index.test.tsx @@ -0,0 +1,162 @@ +import mundoLiveFixture from '#data/mundo/live/c7dkx155e626t.json'; +import React from 'react'; +import LiveHeaderMedia from '.'; +import { MediaCollection } from '../MediaLoader/types'; +import { + screen, + render, + fireEvent, +} from '../react-testing-library-with-providers'; + +const fixtureData = mundoLiveFixture.data.mediaCollections; + +describe('liveMediaStream', () => { + it('Displays all components on intial render.', () => { + const { container } = render( + , + ); + + const playCloseButton = container.querySelector( + 'button[data-testid="watch-now-close-button"]', + ); + const mediaLoader = container.querySelector('figure'); + + expect(playCloseButton).toBeInTheDocument(); + expect(mediaLoader).toBeInTheDocument(); + }); + + it('Displays a warning message when needed.', () => { + const mediaBlock = fixtureData[0]; + mediaBlock.model.version.warnings = { + warning_text: 'Contains some upsetting scenes.', + warning: [ + { + warning_code: 'D1', + short_description: 'some upsetting scenes', + }, + ], + }; + + const { container } = render( + , + ); + + const playCloseButton = container.querySelector( + 'span[data-testid="warning-message"]', + ); + + expect(playCloseButton?.innerHTML).toContain( + 'Contains some upsetting scenes.', + ); + }); + + it('Plays the media loader when the watch button is clicked.', () => { + window.mediaPlayers = { + p0gh4n67: { + player: { paused: jest.fn().mockReturnValueOnce(true) }, + play: jest.fn(), + pause: jest.fn(), + }, + }; + + render( + , + ); + + const playCloseButton = screen.getByTestId('watch-now-close-button'); + fireEvent.click(playCloseButton); + + expect(window.mediaPlayers.p0gh4n67.play).toHaveBeenCalled(); + }); + + it('Paused the media loader when the close button is clicked.', () => { + window.mediaPlayers = { + p0gh4n67: { + play: jest.fn(), + pause: jest.fn(), + }, + }; + render( + , + ); + + const playCloseButton = screen.getByTestId('watch-now-close-button'); + fireEvent.click(playCloseButton); + fireEvent.click(playCloseButton); + + expect(window.mediaPlayers.p0gh4n67.play).toHaveBeenCalledTimes(1); + expect(window.mediaPlayers.p0gh4n67.pause).toHaveBeenCalledTimes(1); + }); + + it('Displays nothing if no mediaCollection is passed in.', () => { + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); + + it('Displays nothing if an empty array is passed in.', () => { + const { container } = render( + , + ); + + expect(container).toBeEmptyDOMElement(); + }); + + it.each([ + { + title: 'Should NOT autoplay for Level 1 warnings and above.', + playCalls: 0, + warning: [ + { + warning_code: 'D1', + short_description: 'some upsetting scenes', + }, + { + warning_code: 'D2', + short_description: 'upsetting scenes', + }, + { + warning_code: 'L1', + short_description: 'some strong language', + }, + ], + }, + { + title: 'Should autoplay for below L1 warnings.', + playCalls: 1, + warning: [ + { + warning_code: 'D1', + short_description: 'some upsetting scenes', + }, + { + warning_code: 'D2', + short_description: 'upsetting scenes', + }, + ], + }, + ])('$title', ({ warning, playCalls }) => { + const mediaBlock = fixtureData[0]; + mediaBlock.model.version.warnings = { + warning_text: '', + warning, + }; + + window.mediaPlayers = { + p0gh4n67: { + player: { paused: jest.fn().mockReturnValueOnce(true) }, + play: jest.fn(), + pause: jest.fn(), + }, + }; + + render( + , + ); + + const playCloseButton = screen.getByTestId('watch-now-close-button'); + fireEvent.click(playCloseButton); + + expect(window.mediaPlayers.p0gh4n67.play).toHaveBeenCalledTimes(playCalls); + }); +}); diff --git a/src/app/components/LiveHeaderMedia/index.tsx b/src/app/components/LiveHeaderMedia/index.tsx new file mode 100644 index 00000000000..b4a9afe8bd8 --- /dev/null +++ b/src/app/components/LiveHeaderMedia/index.tsx @@ -0,0 +1,179 @@ +/** @jsx jsx */ +/** @jsxFrag */ +import { jsx } from '@emotion/react'; +import React, { memo, useContext, useState } from 'react'; +import Text from '#app/components/Text'; +import { MediaCollection } from '#app/components/MediaLoader/types'; +import MediaLoader from '#app/components/MediaLoader'; +import filterForBlockType from '#app/lib/utilities/blockHandlers'; +import { ServiceContext } from '#app/contexts/ServiceContext'; +import { RequestContext } from '#app/contexts/RequestContext'; +import styles from './index.styles'; +import WARNING_LEVELS from '../MediaLoader/configs/warningLevels'; +import VisuallyHiddenText from '../VisuallyHiddenText'; +import { Close, Play } from '../icons'; + +type WarningItem = { + // eslint-disable-next-line camelcase + warning_code: string; + // eslint-disable-next-line camelcase + short_description: string; +}; + +type Props = { + mediaCollection: MediaCollection[] | null; + clickCallback?: () => void; +}; + +const DEFAULT_WATCH__NOW = 'Watch Live'; +const DEFAULT_CLOSE_VIDEO = 'Close video'; +const DEFAULT_NO_JS_MESSAGE = + 'This video cannot play in your browser. Please enable JavaScript or try a different browser.'; + +const MemoizedMediaPlayer = memo(MediaLoader); + +const LiveHeaderMedia = ({ + mediaCollection, + clickCallback = () => null, +}: Props) => { + const { translations } = useContext(ServiceContext); + const { isLite } = useContext(RequestContext); + const [showMedia, setShowMedia] = useState(false); + + let warningLevel = WARNING_LEVELS.NO_WARNING; + + if (isLite || mediaCollection == null || mediaCollection.length === 0) { + return null; + } + + const { + media: { + watch = DEFAULT_WATCH__NOW, + closeVideo = DEFAULT_CLOSE_VIDEO, + noJs = DEFAULT_NO_JS_MESSAGE, + }, + } = translations; + + const mediaItem = filterForBlockType(mediaCollection, 'liveMedia'); + + const { + model: { + masterbrand: { networkName }, + synopses: { short }, + version: { vpid, warnings }, + }, + } = mediaItem; + + if (warnings) { + const { warning } = warnings; + const highestWarning = warning.reduce( + (maxWarning: WarningItem, currWarning: WarningItem) => { + const maxWarningCode = WARNING_LEVELS[maxWarning.warning_code]; + const currWarningCode = WARNING_LEVELS[currWarning.warning_code]; + if (currWarningCode > maxWarningCode) { + return currWarning; + } + return maxWarning; + }, + ); + + warningLevel = WARNING_LEVELS[highestWarning.warning_code]; + } + + const handleClick = () => { + const mediaPlayer = window.mediaPlayers?.[vpid]; + if (showMedia) { + mediaPlayer?.pause(); + setShowMedia(false); + } else { + if (warningLevel < WARNING_LEVELS.L1) { + mediaPlayer?.play(); + } + setShowMedia(true); + } + + clickCallback(); + }; + + const description = ( + <> + + {showMedia && {closeVideo}, } + + {short},{' '} + + + {networkName} + + , + + {warnings && ( + + {warnings.warning_text} + + )} + + ); + + return ( + <> + +
+ +
+ +
+
+ + ); +}; + +export default LiveHeaderMedia; diff --git a/src/app/components/LiveHeaderMedia/metadata.json b/src/app/components/LiveHeaderMedia/metadata.json new file mode 100644 index 00000000000..fec4872ddaf --- /dev/null +++ b/src/app/components/LiveHeaderMedia/metadata.json @@ -0,0 +1,29 @@ +{ + "alpha": false, + "lastUpdated": { + "day": 10, + "month": "Jan", + "year": 2025 + }, + "uxAccessibilityDoc": { + "done": true, + "reference": { + "url": "https://paper.dropbox.com/doc/WS-Live-page-Screen-reader-UX--Cd_drUzfpS3DH8PtsqpcHWTrAg-8qm1UHDGVMhv5Qj9Q2mbi", + "label": "Screen Reader UX" + } + }, + "acceptanceCriteria": { + "done": true, + "reference": { + "url": "https://paper.dropbox.com/doc/Media-streaming-on-Live-page-header-Accessibility-Acceptance-Criteria--Cd9XeuiOa~qvgofSxoEXe6jRAg-IJpFNBmz2Anh6JxczSMit", + "label": "Accessibility Acceptance Criteria" + } + }, + "swarm": { + "done": true, + "reference": { + "url": "https://paper.dropbox.com/doc/A11Y-Swarm-Live-page-header-Streamed-Media--CeUOiCNKMADIzJAdF1fNyKS8Ag-GUnv4JyaoWuciSkdhuLLz", + "label": "Accessibility Swarm Notes" + } + } +} diff --git a/src/app/components/MediaLoader/configs/index.ts b/src/app/components/MediaLoader/configs/index.ts index c1bedb068a6..03177371623 100644 --- a/src/app/components/MediaLoader/configs/index.ts +++ b/src/app/components/MediaLoader/configs/index.ts @@ -11,6 +11,7 @@ import { MediaBlock, ConfigBuilderProps, } from '../types'; +import liveMedia from './liveMedia'; const BLOCK_TYPES = [ 'aresMedia', @@ -19,6 +20,7 @@ const BLOCK_TYPES = [ 'liveRadio', 'audio', 'legacyMedia', + 'liveMedia', ] as const; const blockTypeMapping: Record< @@ -31,6 +33,7 @@ const blockTypeMapping: Record< liveRadio, audio, legacyMedia, + liveMedia, }; export default (blocks: MediaBlock[]) => { diff --git a/src/app/components/MediaLoader/configs/liveMedia.ts b/src/app/components/MediaLoader/configs/liveMedia.ts new file mode 100644 index 00000000000..ed8608c7c89 --- /dev/null +++ b/src/app/components/MediaLoader/configs/liveMedia.ts @@ -0,0 +1,53 @@ +import filterForBlockType from '#lib/utilities/blockHandlers'; +import moment from 'moment'; +import { ConfigBuilderProps, ConfigBuilderReturnProps } from '../types'; + +export default ({ + blocks, + basePlayerConfig, +}: ConfigBuilderProps): ConfigBuilderReturnProps => { + const { model: liveMediaBlock } = filterForBlockType(blocks, 'liveMedia'); + let warning = null; + + const { + imageUrlTemplate: holdingImageURL, + version: video, + title, + synopses: { short }, + } = liveMediaBlock; + + const { warnings } = video; + + if (warnings) { + warning = warnings.warning_text; + } + + const rawDuration = moment.duration(video?.duration).asSeconds(); + + return { + playerConfig: { + ...basePlayerConfig, + autoplay: false, + statsObject: { + ...basePlayerConfig.statsObject, + episodePID: liveMediaBlock.id, + }, + playlistObject: { + title, + holdingImageURL, + items: [ + { + versionID: video?.vpid, + kind: 'programme', + duration: rawDuration, + live: video.status === 'LIVE', + }, + ], + summary: short, + ...(warning && { warning }), + }, + }, + mediaType: 'video', + showAds: false, + }; +}; diff --git a/src/app/components/MediaLoader/configs/warningLevels.ts b/src/app/components/MediaLoader/configs/warningLevels.ts new file mode 100644 index 00000000000..85441901547 --- /dev/null +++ b/src/app/components/MediaLoader/configs/warningLevels.ts @@ -0,0 +1,31 @@ +const WARNING_LEVELS: Record = { + NO_WARNING: -1, + D1: 0, + D2: 1, + D3: 2, + L1: 3, + L2: 4, + L3: 5, + LA: 6, + RFI: 7, + S1: 8, + S2: 9, + S3: 10, + V1: 11, + V2: 12, + V3: 13, + V4: 14, + W1: 15, + W2: 16, + W3: 17, + W4: 18, + W5: 19, + W6: 20, + W7: 21, + W8: 22, + WD: 23, + WL: 24, + WV: 25, +}; + +export default WARNING_LEVELS; diff --git a/src/app/components/MediaLoader/index.test.tsx b/src/app/components/MediaLoader/index.test.tsx index 5dc8cc0119a..04727110598 100644 --- a/src/app/components/MediaLoader/index.test.tsx +++ b/src/app/components/MediaLoader/index.test.tsx @@ -110,6 +110,34 @@ describe('MediaLoader', () => { expect(mockRequire.mock.calls[0][0]).toStrictEqual(['bump-4']); }); + + it('Adds a media player object to the window with a specified uniqueId', async () => { + const mockRequire = jest.fn(); + const mockBump = { + player: () => ({ + load: jest.fn(), + }), + }; + + window.requirejs = mockRequire; + + await act(async () => { + render( + , + { + id: 'testId', + }, + ); + }); + + const callbackFn = mockRequire.mock.calls[0][1]; + callbackFn(mockBump); + + expect(window.mediaPlayers.testId).not.toBeNull(); + }); }); describe('Placeholder', () => { diff --git a/src/app/components/MediaLoader/index.tsx b/src/app/components/MediaLoader/index.tsx index 872fec834d5..81fc72b757e 100644 --- a/src/app/components/MediaLoader/index.tsx +++ b/src/app/components/MediaLoader/index.tsx @@ -1,6 +1,5 @@ /** @jsx jsx */ /* @jsxFrag React.Fragment */ - import { jsx } from '@emotion/react'; import React, { useContext, useEffect, useRef, useState } from 'react'; import { Helmet } from 'react-helmet'; @@ -96,9 +95,14 @@ const AdvertTagLoader = () => { type MediaContainerProps = { playerConfig: PlayerConfig; showAds: boolean; + uniqueId?: string; }; -const MediaContainer = ({ playerConfig, showAds }: MediaContainerProps) => { +const MediaContainer = ({ + playerConfig, + showAds, + uniqueId, +}: MediaContainerProps) => { const playerElementRef = useRef(null); useEffect(() => { @@ -112,6 +116,15 @@ const MediaContainer = ({ playerConfig, showAds }: MediaContainerProps) => { mediaPlayer.load(); + if (uniqueId != null) { + const { mediaPlayers } = window; + if (mediaPlayers == null) { + window.mediaPlayers = { [uniqueId]: mediaPlayer }; + } else { + mediaPlayers[uniqueId] = mediaPlayer; + } + } + if (showAds) { const adTag = await window.dotcom.ads.getAdTag(); mediaPlayer.loadPlugin( @@ -143,7 +156,7 @@ const MediaContainer = ({ playerConfig, showAds }: MediaContainerProps) => { } catch (error) { logger.error(MEDIA_PLAYER_STATUS, error); } - }, [playerConfig, showAds]); + }, [playerConfig, showAds, uniqueId]); return (
{ +const MediaLoader = ({ blocks, className, embedded, uniqueId }: Props) => { const { lang, translations } = useContext(ServiceContext); const { pageIdentifier } = useContext(EventTrackingContext); const { enabled: adsEnabled } = useToggle('ads'); @@ -274,7 +288,11 @@ const MediaLoader = ({ blocks, className, embedded }: Props) => { onClick={() => setShowPlaceholder(false)} /> ) : ( - + )} )} diff --git a/src/app/components/MediaLoader/types.ts b/src/app/components/MediaLoader/types.ts index 4484a0a87b7..a83d13fda25 100644 --- a/src/app/components/MediaLoader/types.ts +++ b/src/app/components/MediaLoader/types.ts @@ -37,6 +37,7 @@ export type PlayerConfig = { embedRights?: 'allowed'; liveRewind?: boolean; simulcast?: boolean; + warning?: string; }; }; @@ -114,6 +115,8 @@ export type Player = { parameters: { updatedAdTag: string }, ): void; load: () => void; + play: () => void; + pause: () => void; bind: (event: string, callback: () => void) => void; loadPlugin: ( pluginName: { [key: string]: string }, @@ -125,6 +128,7 @@ export type Player = { }; }, ) => void; + player: { paused: () => boolean }; }; export type BumpType = { @@ -239,6 +243,26 @@ export type LegacyMediaBlock = { }; }; +export type MediaCollection = { + type: 'liveMedia'; + model: { + synopses: { + short: string; + }; + masterbrand: { + networkName: string; + }; + version: { + vpid?: string; + serviceID?: string; + duration: string; + status: string; + }; + imageUrlTemplate: string; + title: string; + }; +}; + export type MediaBlock = | AresMediaBlock | ClipMediaBlock @@ -247,7 +271,8 @@ export type MediaBlock = | OnDemandTVBlock | OnDemandAudioBlock | CaptionBlock - | MediaOverrides; + | MediaOverrides + | MediaCollection; export type BuildConfigProps = { id: string; diff --git a/src/app/components/MediaLoader/utils/buildSettings.test.ts b/src/app/components/MediaLoader/utils/buildSettings.test.ts index 9c1d9e856e3..780430f9cfa 100644 --- a/src/app/components/MediaLoader/utils/buildSettings.test.ts +++ b/src/app/components/MediaLoader/utils/buildSettings.test.ts @@ -2,6 +2,7 @@ import { PageTypes, Services } from '#app/models/types/global'; import { data as hindiTvProgramme } from '#data/hindi/bbc_hindi_tv/tv_programmes/w13xttlw.json'; import { AUDIO_PAGE, + LIVE_PAGE, LIVE_RADIO_PAGE, TV_PAGE, } from '#app/routes/utils/pageTypes'; @@ -10,6 +11,7 @@ import afriqueRadio from '#data/afrique/bbc_afrique_radio/p030s6dq.json'; import { service as hausaServiceConfig } from '#app/lib/config/services/hausa'; import { service as hindiServiceConfig } from '#app/lib/config/services/hindi'; import { service as afriqueServiceConfig } from '#app/lib/config/services/afrique'; +import { service as mundoServiceConfig } from '#app/lib/config/services/mundo'; import isLive from '#app/lib/utilities/isLive'; import buildSettings from './buildSettings'; import { @@ -997,4 +999,117 @@ describe('buildSettings', () => { }); }); }); + + describe('liveMedia', () => { + const mundoMediaBaseSettings = { + counterName: 'live_coverage.c7dkx155e626t.page', + lang: 'es', + service: 'mundo' as Services, + statsDestination: 'WS_NEWS_LANGUAGES', + producer: 'MUNDO', + translations: mundoServiceConfig.default.translations, + } as BuildConfigProps; + + it('Should process a live media block into a valid playlist item.', () => { + const mediaBlock = { + type: 'liveMedia', + model: { + urn: 'urn:bbc:pips:pid:p0gh4n63', + title: 'Non-Stop Cartoons!', + type: 'episode', + synopses: { + short: 'Toon in, kick back and relax to 100% cartoons!', + medium: + 'Toon in, kick back and relax. From laugh out loud to mischief and mayhem. 100% cartoons all day long.', + long: 'Toon in, kick back and relax. From laugh out loud to mischief and mayhem. 100% cartoons all day long. Join your favourites Grizzy, Shaun, Taffy, Boy Girl Dog Cat Mouse Cheese, The Deep and those Monster Loving Maniacs.', + }, + mediaType: 'audio_video', + imageUrlTemplate: + 'https://ichef.bbci.co.uk/images/ic/$recipe/p0k31t4d.jpg', + masterbrand: { + id: 'cbbc', + name: 'CBBC', + networkName: 'CBBC', + type: 'tv', + imageUrlTemplate: 'ichef.bbci.co.uk/images/ic/$recipe/p0f8qps2.jpg', + }, + version: { + vpid: 'p0gh4n67', + duration: 'PT24H', + availabilityType: 'webcast', + versionTypes: [ + { + type: 'Original', + name: 'Original version', + }, + ], + schedule: { + start: '2024-12-15T08:00:21Z', + accurateStart: '2024-12-15T08:00:21Z', + end: '2024-12-15T13:00:21Z', + }, + serviceId: null, + authToken: null, + status: 'LIVE', + warnings: null, + }, + leadMedia: true, + }, + }; + + const result = buildSettings({ + ...mundoMediaBaseSettings, + blocks: [mediaBlock] as MediaBlock[], + pageType: LIVE_PAGE, + }); + + expect(result).toStrictEqual({ + mediaType: 'video', + playerConfig: { + appName: 'news-mundo', + appType: 'responsive', + autoplay: false, + counterName: 'live_coverage.c7dkx155e626t.page', + enableToucan: true, + playlistObject: { + holdingImageURL: + 'https://ichef.bbci.co.uk/images/ic/$recipe/p0k31t4d.jpg', + items: [ + { + duration: 86400, + kind: 'programme', + live: true, + versionID: 'p0gh4n67', + }, + ], + summary: 'Toon in, kick back and relax to 100% cartoons!', + title: 'Non-Stop Cartoons!', + }, + product: 'news', + statsObject: { + destination: 'WS_NEWS_LANGUAGES', + episodePID: undefined, + producer: 'MUNDO', + }, + ui: { + controls: { + enabled: true, + }, + fullscreen: { + enabled: true, + }, + locale: { + lang: 'es', + }, + skin: 'classic', + subtitles: { + defaultOn: true, + enabled: true, + }, + }, + }, + showAds: false, + }); + }); + }); }); diff --git a/src/app/components/ThemeProvider/focusIndicator.ts b/src/app/components/ThemeProvider/focusIndicator.ts index b876778ed9e..6583b5cf7e9 100644 --- a/src/app/components/ThemeProvider/focusIndicator.ts +++ b/src/app/components/ThemeProvider/focusIndicator.ts @@ -54,7 +54,8 @@ const focusIndicator = ({ palette }: Theme) => css` } // Overrides focus indicator styles with inverted colours. Used on a dark background page. E.g. Episode lists. - a.focusIndicatorInvert:focus-visible { + a.focusIndicatorInvert:focus-visible, + button.focusIndicatorInvert:focus-visible { outline: ${focusIndicatorThickness} solid ${palette.WHITE}; box-shadow: 0 0 0 ${focusIndicatorThickness} ${palette.BLACK}; outline-offset: ${focusIndicatorThickness}; diff --git a/src/app/components/ThemeProvider/palette.ts b/src/app/components/ThemeProvider/palette.ts index 28b299c6a0e..2f6463ea23b 100644 --- a/src/app/components/ThemeProvider/palette.ts +++ b/src/app/components/ThemeProvider/palette.ts @@ -27,7 +27,9 @@ export const GREY_8 = '#202224'; export const KINGFISHER = '#11708C'; export const LE_TEAL = '#09838B'; export const LIVE_LIGHT = '#00CCC7'; +export const LIVE_MEDIUM = '#008282'; export const LIVE_DARK = '#006666'; +export const LIVE_CORE = '#009E9E'; export const LUNAR = '#F2F2F2'; export const LUNAR_LIGHT = '#F8F8F8'; export const METAL = '#6E6E73'; diff --git a/src/app/components/ThemeProvider/withThemeProvider.tsx b/src/app/components/ThemeProvider/withThemeProvider.tsx index b1722debcaa..9275d80f9c6 100644 --- a/src/app/components/ThemeProvider/withThemeProvider.tsx +++ b/src/app/components/ThemeProvider/withThemeProvider.tsx @@ -37,7 +37,9 @@ import { KINGFISHER, LE_TEAL, LIVE_LIGHT, + LIVE_MEDIUM, LIVE_DARK, + LIVE_CORE, LUNAR, LUNAR_LIGHT, METAL, @@ -243,7 +245,9 @@ const withThemeProvider = ({ KINGFISHER, LE_TEAL, LIVE_LIGHT, + LIVE_MEDIUM, LIVE_DARK, + LIVE_CORE, LUNAR, LUNAR_LIGHT, METAL, diff --git a/src/app/components/icons/index.tsx b/src/app/components/icons/index.tsx index cca09bb311a..985c7ed372b 100644 --- a/src/app/components/icons/index.tsx +++ b/src/app/components/icons/index.tsx @@ -129,3 +129,31 @@ export const Calculator = ({ className }: { className?: string }) => ( ); + +export const Close = ({ className }: { className?: string }) => ( + +); + +export const Play = ({ className }: { className?: string }) => ( + +); diff --git a/src/app/models/types/theming.ts b/src/app/models/types/theming.ts index ffe52fb7739..f8eb883f16c 100644 --- a/src/app/models/types/theming.ts +++ b/src/app/models/types/theming.ts @@ -36,7 +36,9 @@ interface Palette extends BrandPalette { KINGFISHER: string; LE_TEAL: string; LIVE_LIGHT: string; + LIVE_MEDIUM: string; LIVE_DARK: string; + LIVE_CORE: string; LUNAR: string; LUNAR_LIGHT: string; METAL: string; diff --git a/src/app/models/types/translations.ts b/src/app/models/types/translations.ts index edac720b85a..3526009fe5a 100644 --- a/src/app/models/types/translations.ts +++ b/src/app/models/types/translations.ts @@ -177,6 +177,7 @@ export interface Translations { recentEpisodes?: string; podcastExternalLinks?: string; download?: string; + closeVideo?: string; }; socialEmbed: { caption?: { diff --git a/src/global.d.ts b/src/global.d.ts index 1b34f28d0b7..f96b4eca5ef 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -4,6 +4,7 @@ declare global { bumpVersion: string[], callback: (Bump: BumpType) => void, ) => void; + mediaPlayers: Record; dotcom: { ads: { getAdTag: () => Promise; diff --git a/ws-nextjs-app/pages/[service]/live/[id]/Header/index.stories.tsx b/ws-nextjs-app/pages/[service]/live/[id]/Header/index.stories.tsx index ed05f7e3506..9cd2b5d93e5 100644 --- a/ws-nextjs-app/pages/[service]/live/[id]/Header/index.stories.tsx +++ b/ws-nextjs-app/pages/[service]/live/[id]/Header/index.stories.tsx @@ -1,4 +1,6 @@ import React from 'react'; +import mundoLiveFixture from '#data/mundo/live/c7dkx155e626t.json'; +import { MediaCollection } from '#app/components/MediaLoader/types'; import Header from '.'; import metadata from './metadata.json'; @@ -9,6 +11,7 @@ interface ComponentProps { imageUrl?: string; imageUrlTemplate?: string; imageWidth?: number; + mediaCollections?: MediaCollection[] | null; } const Component = ({ @@ -18,6 +21,7 @@ const Component = ({ imageUrl, imageUrlTemplate, imageWidth, + mediaCollections, }: ComponentProps) => { return (
); }; @@ -133,3 +138,17 @@ export const TitleAndDescriptionWithLiveLabelAndImageExtraLongText = () => ( imageWidth={660} /> ); + +export const WithLiveMediaStream = () => ( + +); diff --git a/ws-nextjs-app/pages/[service]/live/[id]/Header/index.tsx b/ws-nextjs-app/pages/[service]/live/[id]/Header/index.tsx index 892153afa5c..3d1bfdeba1f 100644 --- a/ws-nextjs-app/pages/[service]/live/[id]/Header/index.tsx +++ b/ws-nextjs-app/pages/[service]/live/[id]/Header/index.tsx @@ -2,8 +2,11 @@ import { jsx } from '@emotion/react'; import Heading from '#app/components/Heading'; import Text from '#app/components/Text'; +import LiveHeaderMedia from '#app/components/LiveHeaderMedia'; +import { MediaCollection } from '#app/components/MediaLoader/types'; import MaskedImage from '#app/components/MaskedImage'; +import { useState } from 'react'; import LiveLabelHeader from './LiveLabelHeader'; import styles from './styles'; @@ -14,6 +17,7 @@ const Header = ({ imageUrl, imageUrlTemplate, imageWidth, + mediaCollections, }: { showLiveLabel: boolean; title: string; @@ -21,9 +25,15 @@ const Header = ({ imageUrl?: string; imageUrlTemplate?: string; imageWidth?: number; + mediaCollections?: MediaCollection[] | null; }) => { + const [isMediaOpen, setLiveMediaOpen] = useState(false); const isHeaderImage = !!imageUrl && !!imageUrlTemplate && !!imageWidth; + const watchVideoClickHandler = () => { + setLiveMediaOpen(!isMediaOpen); + }; + const Title = ( ) : null}
)} + {mediaCollections && ( + + )}
diff --git a/ws-nextjs-app/pages/[service]/live/[id]/Header/styles.tsx b/ws-nextjs-app/pages/[service]/live/[id]/Header/styles.tsx index 07a3996cba5..86d040e7486 100644 --- a/ws-nextjs-app/pages/[service]/live/[id]/Header/styles.tsx +++ b/ws-nextjs-app/pages/[service]/live/[id]/Header/styles.tsx @@ -42,6 +42,19 @@ export default { width: '100%', }, }), + textContainerMediaOpen: ({ mq, gridWidths, spacings }: Theme) => + css({ + position: 'relative', + padding: `${spacings.DOUBLE}rem ${spacings.FULL}rem 0`, + maxWidth: `${pixelsToRem(gridWidths[1280])}rem`, + margin: '0 auto', + [mq.GROUP_2_MIN_WIDTH]: { + padding: `${spacings.DOUBLE}rem ${spacings.DOUBLE}rem 0`, + }, + [mq.GROUP_4_MIN_WIDTH]: { + paddingTop: `${spacings.TRIPLE}rem`, + }, + }), textContainerWithoutImage: ({ mq, gridWidths, spacings }: Theme) => css({ position: 'relative', diff --git a/ws-nextjs-app/pages/[service]/live/[id]/LivePageLayout.tsx b/ws-nextjs-app/pages/[service]/live/[id]/LivePageLayout.tsx index 89070a1eafb..02c124a4db9 100644 --- a/ws-nextjs-app/pages/[service]/live/[id]/LivePageLayout.tsx +++ b/ws-nextjs-app/pages/[service]/live/[id]/LivePageLayout.tsx @@ -11,6 +11,7 @@ import { RequestContext } from '#app/contexts/RequestContext'; import MetadataContainer from '#app/components/Metadata'; import LinkedDataContainer from '#app/components/LinkedData'; import getLiveBlogPostingSchema from '#app/lib/seoUtils/getLiveBlogPostingSchema'; +import { MediaCollection } from '#app/components/MediaLoader/types'; import Stream from './Stream'; import Header from './Header'; import KeyPoints from './KeyPoints'; @@ -28,7 +29,7 @@ interface LivePromoImage { copyright?: string; } -type ComponentProps = { +export type ComponentProps = { pageData: { title: string; description?: string; @@ -53,6 +54,7 @@ type ComponentProps = { startDateTime?: string; endDateTime?: string; metadata: { atiAnalytics: ATIData }; + mediaCollections: MediaCollection[] | null; }; }; @@ -72,6 +74,7 @@ const LivePage = ({ pageData }: ComponentProps) => { metadata: { atiAnalytics = undefined } = {}, headerImage, promoImage, + mediaCollections, } = pageData; const { @@ -151,6 +154,7 @@ const LivePage = ({ pageData }: ComponentProps) => { imageUrl={imageUrl} imageUrlTemplate={imageUrlTemplate} imageWidth={imageWidth} + mediaCollections={mediaCollections} />
diff --git a/ws-nextjs-app/pages/[service]/live/[id]/live.stories.tsx b/ws-nextjs-app/pages/[service]/live/[id]/live.stories.tsx index 7cd63c75142..7ae18ff9317 100644 --- a/ws-nextjs-app/pages/[service]/live/[id]/live.stories.tsx +++ b/ws-nextjs-app/pages/[service]/live/[id]/live.stories.tsx @@ -1,8 +1,12 @@ import React from 'react'; import PageLayoutWrapper from '#app/components/PageLayoutWrapper'; import liveFixture from '#data/pidgin/live/c7p765ynk9qt.json'; +import liveFixtureWithLiveMedia from '#data/mundo/live/c7dkx155e626t.json'; import postFixture from '#data/pidgin/posts/postFixtureCleaned.json'; -import Live from './LivePageLayout'; +import Live, { ComponentProps } from './LivePageLayout'; + +const mockLiveData = + liveFixtureWithLiveMedia.data as ComponentProps['pageData']; const mockPageData = { ...liveFixture.data, @@ -14,12 +18,13 @@ const mockPageData = { block: 'Its a block', }, metadata: { atiAnalytics: {} }, + mediaCollections: null, }; -const Component = () => ( +const Component = ({ pageData }: ComponentProps) => ( // @ts-expect-error partial data required for storybook - - + + ); @@ -28,4 +33,5 @@ export default { Component, }; -export const Example = Component; +export const Example = () => ; +export const WithLiveStream = () => ;