Skip to content

Commit 80d82b5

Browse files
authored
Merge branch 'main' into CAI-880-fix-chips-tool-calling
2 parents 9f5a632 + 5d2e6cf commit 80d82b5

File tree

17 files changed

+229
-18
lines changed

17 files changed

+229
-18
lines changed

.changeset/ninety-pianos-fail.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"nextjs-website": patch
3+
---
4+
5+
Fix assets src by refactoring MarkdownPart to use server side generated assetsPrefix prop for asset URLs

.changeset/slow-teams-run.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"nextjs-website": minor
3+
---
4+
5+
Add speed control, chapters and VTT content support to webinar types and props

apps/nextjs-website/src/components/atoms/VideoJsPlayer/VideoJsPlayer.tsx

Lines changed: 83 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
/* eslint-disable functional/no-try-statements */
21
'use client';
32

43
import { useEffect, useRef } from 'react';
@@ -13,6 +12,8 @@ import videojs from 'video.js';
1312
import { Box } from '@mui/material';
1413
import { amazonIvsVersion } from '@/config';
1514
import '@/styles/videojs-custom.css';
15+
import { useTranslations } from 'next-intl';
16+
import { Chapter } from '@/lib/types/webinar';
1617

1718
interface PlayerProps {
1819
autoplay: boolean;
@@ -22,16 +23,62 @@ interface PlayerProps {
2223
poster?: string;
2324
reloadToken?: number;
2425
videoOnDemandStartAt?: number;
26+
startAtChapterSlug?: string;
27+
chapters?: readonly Chapter[];
28+
webvttContent?: string;
2529
}
2630

31+
const HOURS_PART_INDEX = 0;
32+
const MINUTES_PART_INDEX = 1;
33+
const SECONDS_PART_INDEX = 2;
34+
35+
/** Convert a WebVTT timestamp (HH:MM:SS.mmm or MM:SS.mmm) to seconds */
36+
const parseVttTime = (time: string): number | undefined => {
37+
const parts = time.split(':');
38+
// eslint-disable-next-line functional/no-let
39+
let result: number;
40+
if (parts.length === 3) {
41+
result =
42+
parseInt(parts[HOURS_PART_INDEX], 10) * 3600 +
43+
parseInt(parts[MINUTES_PART_INDEX], 10) * 60 +
44+
parseFloat(parts[SECONDS_PART_INDEX]);
45+
} else if (parts.length === 2) {
46+
result =
47+
parseInt(parts[HOURS_PART_INDEX], 10) * 60 +
48+
parseFloat(parts[MINUTES_PART_INDEX]);
49+
} else {
50+
result = parseFloat(parts[HOURS_PART_INDEX]);
51+
}
52+
return Number.isFinite(result) ? result : undefined;
53+
};
54+
2755
const TECH_ORDER_AMAZON_IVS = ['AmazonIVS'];
56+
const PLAYBACK_RATES = [0.5, 1, 1.25, 1.5, 2];
2857

2958
const VideoJsPlayer = (props: PlayerProps) => {
59+
const t = useTranslations('webinar');
60+
const resolvedStartAt = (() => {
61+
if (
62+
props.startAtChapterSlug &&
63+
props.chapters &&
64+
props.chapters.length > 0
65+
) {
66+
const chapter = props.chapters.find(
67+
(ch) => ch.slug === props.startAtChapterSlug
68+
);
69+
if (chapter) {
70+
return parseVttTime(chapter.startTime);
71+
}
72+
}
73+
return props.videoOnDemandStartAt;
74+
})();
75+
3076
const videoEl = useRef<HTMLVideoElement>(null);
3177
const playerRef = useRef<
3278
// @ts-expect-error TS2322: Type 'undefined' is not assignable to type 'Player & VideoJSIVSTech & VideoJSQualityPlugin'.
3379
videojs.Player & VideoJSIVSTech & VideoJSQualityPlugin
3480
>(undefined);
81+
const vttBlobUrlRef = useRef<string | null>(null);
3582

3683
useEffect(() => {
3784
registerIVSTech(videojs, {
@@ -49,22 +96,49 @@ const VideoJsPlayer = (props: PlayerProps) => {
4996
autoplay: props.autoplay,
5097
controls: props.controls,
5198
playsinline: props.playsInline,
99+
playbackRates: PLAYBACK_RATES,
100+
inactivityTimeout: 0,
52101
// @ts-expect-error TS2322: Type 'undefined' is not assignable to type 'Player & VideoJSIVSTech & VideoJSQualityPlugin'.
53102
}) as videojs.Player & VideoJSIVSTech & VideoJSQualityPlugin;
54103

55104
player.enableIVSQualityPlugin();
56105
// eslint-disable-next-line functional/immutable-data
57106
playerRef.current = player;
58107

108+
if (props.webvttContent) {
109+
// Create VTT blob and add chapters track
110+
const blob = new Blob([props.webvttContent], { type: 'text/vtt' });
111+
const blobUrl = URL.createObjectURL(blob);
112+
// eslint-disable-next-line functional/immutable-data
113+
vttBlobUrlRef.current = blobUrl;
114+
115+
player.addRemoteTextTrack(
116+
{
117+
kind: 'chapters',
118+
src: blobUrl,
119+
label: t('chapters'),
120+
default: true,
121+
},
122+
false
123+
);
124+
}
125+
59126
return () => {
60127
playerRef.current?.dispose();
61128
// eslint-disable-next-line functional/immutable-data
62129
playerRef.current = undefined;
130+
131+
// Clean up blob URL
132+
if (vttBlobUrlRef.current) {
133+
URL.revokeObjectURL(vttBlobUrlRef.current);
134+
// eslint-disable-next-line functional/immutable-data
135+
vttBlobUrlRef.current = null;
136+
}
63137
};
64138

65139
// NOTE: Autoplay is correctly set on initialization only, it should not be a dependency here
66140
// eslint-disable-next-line react-hooks/exhaustive-deps
67-
}, [props.controls, props.playsInline]);
141+
}, [props.controls, props.playsInline, props.webvttContent]);
68142

69143
useEffect(() => {
70144
if (!playerRef.current) {
@@ -77,7 +151,7 @@ const VideoJsPlayer = (props: PlayerProps) => {
77151
playerRef.current.src(props.src);
78152
playerRef.current.poster(props.poster || '');
79153

80-
if (props.autoplay && !props.videoOnDemandStartAt) {
154+
if (props.autoplay && !resolvedStartAt) {
81155
playerRef.current.play().catch(() => undefined);
82156
}
83157
}, [
@@ -87,17 +161,16 @@ const VideoJsPlayer = (props: PlayerProps) => {
87161
props.poster,
88162
props.reloadToken,
89163
props.src,
90-
props.videoOnDemandStartAt,
164+
resolvedStartAt,
91165
]);
92166

93167
useEffect(() => {
94168
if (!playerRef.current) {
95169
return;
96170
}
97-
const videoOnDemandStartAt =
98-
typeof props.videoOnDemandStartAt === 'number'
99-
? props.videoOnDemandStartAt
100-
: 0;
171+
const videoOnDemandStartAt = Number.isFinite(resolvedStartAt)
172+
? (resolvedStartAt as number)
173+
: 0;
101174

102175
if (videoOnDemandStartAt <= 0) {
103176
return;
@@ -115,6 +188,7 @@ const VideoJsPlayer = (props: PlayerProps) => {
115188
const duration = player.duration();
116189

117190
if (duration > 0) {
191+
// eslint-disable-next-line functional/no-try-statements
118192
try {
119193
player.currentTime(seekTo);
120194
// We don't set hasSought = true here for 'loadedmetadata' etc.
@@ -163,7 +237,7 @@ const VideoJsPlayer = (props: PlayerProps) => {
163237
player.off('durationchange', onDurationChange);
164238
player.off('play', onPlay);
165239
};
166-
}, [props.reloadToken, props.src, props.videoOnDemandStartAt]);
240+
}, [props.reloadToken, props.src, resolvedStartAt]);
167241

168242
return (
169243
<Box sx={{ position: 'relative', paddingBottom: '56.25%' }}>

apps/nextjs-website/src/components/molecules/MarkdownPart/MarkdownPart.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
import { Typography } from '@mui/material';
22
import GitBookContent from '@/components/organisms/GitBookContent/GitBookContent';
3-
import { s3DocsPath, staticContentsUrl } from '@/config';
43

54
export type MarkdownPartProps = {
65
readonly content: string;
7-
readonly dirName: string;
6+
readonly assetsPrefix: string;
87
};
9-
const MarkdownPart = ({ content, dirName }: MarkdownPartProps) => {
8+
const MarkdownPart = ({ content, assetsPrefix }: MarkdownPartProps) => {
109
if (content === null) {
1110
return <Typography />; // empty placeholder while loading
1211
}
@@ -17,7 +16,7 @@ const MarkdownPart = ({ content, dirName }: MarkdownPartProps) => {
1716
config={{
1817
isPageIndex: false,
1918
pagePath: '',
20-
assetsPrefix: `${staticContentsUrl}/${s3DocsPath}/${dirName}`,
19+
assetsPrefix: assetsPrefix,
2120
urlReplaces: {},
2221
gitBookPagesWithTitle: [],
2322
spaceToPrefix: [],

apps/nextjs-website/src/components/molecules/WebinarPlayerSection/WebinarPlayerSection.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@ import {
77
useMediaQuery,
88
useTheme,
99
} from '@mui/material';
10-
import VimeoPlayer from '@/components/atoms/VimeoPlayer/VimeoPlayer';
1110
import { WebinarQuestionsForm } from '@/components/organisms/WebinarQuestionsForm/WebinarQuestionsForm';
1211
import { WebinarState } from '@/helpers/webinar.helpers';
1312
import { useMemo, useState } from 'react';
13+
import { useSearchParams } from 'next/navigation';
1414
import ForumIcon from '@mui/icons-material/Forum';
1515
import ArrowRightIcon from '@mui/icons-material/ArrowRight';
1616
import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
@@ -35,6 +35,8 @@ const WebinarPlayerSection = ({
3535
reloadPlayerToken = 0,
3636
isPlayerVisible = true,
3737
}: WebinarPlayerSectionProps) => {
38+
const searchParams = useSearchParams();
39+
const chapterParam = searchParams.get('chapter');
3840
const t = useTranslations('webinar');
3941
const { palette } = useTheme();
4042
const [isQuestionFormExpanded, setIsQuestionFormExpanded] = useState(false);
@@ -116,6 +118,9 @@ const WebinarPlayerSection = ({
116118
poster={webinar.playerCoverImageUrl}
117119
reloadToken={reloadPlayerToken}
118120
videoOnDemandStartAt={videoOnDemandStartAt}
121+
startAtChapterSlug={chapterParam || undefined}
122+
chapters={webinar.chapters}
123+
webvttContent={webinar.webvttContent}
119124
/>
120125
</Box>
121126
{isQuestionFormAvailable ? (

apps/nextjs-website/src/lib/strapi/__tests__/factories/parts.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,14 @@ export function minimalQuotePart(): StrapiPart {
5353
};
5454
}
5555

56+
export function minimalMarkdownPart(): StrapiPart {
57+
return {
58+
__component: 'parts.markdown',
59+
dirName: 'some-dir',
60+
pathToFile: 'index.md',
61+
};
62+
}
63+
5664
export function minimalCkEditorPart(): StrapiPart {
5765
return {
5866
__component: 'parts.ck-editor',

apps/nextjs-website/src/lib/strapi/__tests__/fixtures/parts.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,12 @@ export const quotePart: StrapiPart = {
6565
},
6666
};
6767

68+
export const markdownPart: StrapiPart = {
69+
__component: 'parts.markdown',
70+
dirName: 'my-guide',
71+
pathToFile: 'README.md',
72+
};
73+
6874
export const ckEditorPart: StrapiPart = {
6975
__component: 'parts.ck-editor',
7076
content: '<p>CKEditor content</p>',

apps/nextjs-website/src/lib/strapi/__tests__/makePartProps.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
embedHtmlPart,
88
quotePart,
99
ckEditorPart,
10+
markdownPart,
1011
} from '@/lib/strapi/__tests__/fixtures/parts';
1112
import {
1213
minimalAlertPart,
@@ -16,8 +17,14 @@ import {
1617
minimalEmbedHtmlPart,
1718
minimalQuotePart,
1819
minimalCkEditorPart,
20+
minimalMarkdownPart,
1921
} from '@/lib/strapi/__tests__/factories/parts';
2022

23+
jest.mock('@/config', () => ({
24+
staticContentsUrl: 'https://static-contents.test.developer.pagopa.it',
25+
s3DocsPath: 'devportal-docs/docs',
26+
}));
27+
2128
describe('makePartProps', () => {
2229
it('should transform alert part', () => {
2330
const result = makePartProps(alertPart);
@@ -107,6 +114,59 @@ describe('makePartProps', () => {
107114
expect(result).toHaveProperty('menuItems');
108115
});
109116

117+
describe('parts.markdown', () => {
118+
const dict = { 'my-guide/README.md': '# Hello' };
119+
120+
it('should resolve content from markdownContentDict and build assetsPrefix with explicit locale', () => {
121+
const result = makePartProps(markdownPart, dict, 'en');
122+
expect(result).toMatchObject({
123+
component: 'markdown',
124+
content: '# Hello',
125+
assetsPrefix:
126+
'https://static-contents.test.developer.pagopa.it/en/devportal-docs/docs/my-guide',
127+
});
128+
});
129+
130+
it('should return empty content when key is missing from markdownContentDict', () => {
131+
const result = makePartProps(markdownPart, {}, 'en');
132+
expect(result).toMatchObject({
133+
component: 'markdown',
134+
content: '',
135+
assetsPrefix:
136+
'https://static-contents.test.developer.pagopa.it/en/devportal-docs/docs/my-guide',
137+
});
138+
});
139+
140+
it('should default locale to "it" when no locale is provided', () => {
141+
const result = makePartProps(markdownPart, dict);
142+
expect(result).toMatchObject({
143+
component: 'markdown',
144+
assetsPrefix:
145+
'https://static-contents.test.developer.pagopa.it/it/devportal-docs/docs/my-guide',
146+
});
147+
});
148+
149+
it('should return empty content and correct assetsPrefix when markdownContentDict is omitted', () => {
150+
const result = makePartProps(markdownPart, undefined, 'en');
151+
expect(result).toMatchObject({
152+
component: 'markdown',
153+
content: '',
154+
assetsPrefix:
155+
'https://static-contents.test.developer.pagopa.it/en/devportal-docs/docs/my-guide',
156+
});
157+
});
158+
159+
it('should handle minimal markdown part (from factory)', () => {
160+
const result = makePartProps(minimalMarkdownPart(), dict, 'it');
161+
expect(result).toHaveProperty('component', 'markdown');
162+
expect(result).toHaveProperty('content', '');
163+
expect(result).toHaveProperty(
164+
'assetsPrefix',
165+
'https://static-contents.test.developer.pagopa.it/it/devportal-docs/docs/some-dir'
166+
);
167+
});
168+
});
169+
110170
it('should return null for unknown part type', () => {
111171
// eslint-disable-next-line @typescript-eslint/no-explicit-any
112172
const result = makePartProps({ __component: 'parts.unknown' } as any);

apps/nextjs-website/src/lib/strapi/fetches/fetchWebinars.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { StrapiWebinars } from '@/lib/strapi/types/webinars';
44

55
export const webinarPopulate = {
66
populate: {
7+
chapters: '*',
78
coverImage: {
89
populate: ['image'],
910
},

apps/nextjs-website/src/lib/strapi/makeProps/makePart.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { Part } from '@/lib/types/part';
22
import { parseCkEditorContent } from '@/helpers/parseCkEditorContent.helpers';
33
import { StrapiPart } from '@/lib/strapi/types/part';
4+
import { s3DocsPath, staticContentsUrl } from '@/config';
45

56
export function makePartProps(
67
strapiPart: StrapiPart,
7-
markdownContentDict?: Record<string, string>
8+
markdownContentDict?: Record<string, string>,
9+
locale?: string
810
): Part | null {
911
switch (strapiPart.__component) {
1012
case 'parts.alert':
@@ -51,15 +53,19 @@ export function makePartProps(
5153
return {
5254
component: 'markdown',
5355
content: '',
54-
dirName: '',
56+
assetsPrefix: `${staticContentsUrl}/${locale || 'it'}/${s3DocsPath}/${
57+
strapiPart.dirName
58+
}`,
5559
};
5660
// eslint-disable-next-line no-case-declarations
5761
const content =
5862
markdownContentDict[`${strapiPart.dirName}/${strapiPart.pathToFile}`];
5963
return {
6064
component: 'markdown',
6165
content: content ? content : '',
62-
dirName: strapiPart.dirName,
66+
assetsPrefix: `${staticContentsUrl}/${locale || 'it'}/${s3DocsPath}/${
67+
strapiPart.dirName
68+
}`,
6369
};
6470
case 'parts.ck-editor':
6571
// eslint-disable-next-line no-case-declarations

0 commit comments

Comments
 (0)