From 12863579d88250cbff984a52a0ad3f9b1024a4aa Mon Sep 17 00:00:00 2001 From: Aman Yadav Date: Mon, 9 Jun 2025 16:23:33 +0530 Subject: [PATCH 01/79] feat: WIP video-player --- .gitignore | 3 + .vscode/settings.json | 2 + cp_command.sh | 16 + example.html | 231 + examples/javascript/index.html | 25 + examples/javascript/package-lock.json | 32 + examples/javascript/package.json | 20 + examples/javascript/src/main.ts | 319 + examples/javascript/src/playlist.ts | 167 + examples/javascript/tsconfig.json | 19 + examples/javascript/vite.config.ts | 19 + examples/react/index.html | 14 + examples/react/package.json | 22 + examples/react/src/App.tsx | 117 + examples/react/src/main.tsx | 12 + examples/react/tsconfig.json | 17 + examples/react/vite.config.ts | 21 + examples/react/yarn.lock | 558 ++ lerna.json | 9 + package.json | 16 + packages/javascript/index.ts | 285 + packages/javascript/interfaces/ABSOptions.ts | 6 + packages/javascript/interfaces/Player.ts | 41 + packages/javascript/interfaces/Playlist.ts | 11 + packages/javascript/interfaces/Poster.ts | 7 + packages/javascript/interfaces/Shoppable.ts | 39 + .../javascript/interfaces/SourceOptions.ts | 64 + packages/javascript/interfaces/TextTrack.ts | 20 + packages/javascript/interfaces/index.ts | 9 + .../javascript/modules/chapters/chapter.css | 51 + .../chapters/chapterMarkerProgressBar.ts | 224 + .../components/SourceMenuButton.js | 90 + .../components/SourceMenuItem.js | 40 + .../modules/http-source-selector/plugin.js | 89 + .../modules/http-source-selector/plugin.scss | 9 + .../modules/playlist/auto-advance.ts | 66 + .../modules/playlist/playlist-manager.ts | 443 ++ .../modules/playlist/playlist-menu-item.ts | 161 + .../modules/playlist/playlist-menu.ts | 132 + .../modules/playlist/playlist-ui.scss | 328 + .../javascript/modules/playlist/playlist.ts | 185 + packages/javascript/modules/playlist/utils.ts | 10 + .../recommendation-overlay.css | 83 + .../recommendations-overlay.ts | 98 + .../seek-thumbnails/mockSeekThumbnailsVTT.ts | 36 + .../seek-thumbnails-manager.ts | 215 + .../seek-thumbnails/seek-thumbnails.css | 22 + .../modules/seek-thumbnails/vtt-parser.ts | 94 + .../modules/shoppable/shoppable-manager.ts | 696 +++ .../modules/subtitles/sample.transcript | 1 + .../modules/subtitles/subtitles.css | 4 + .../javascript/modules/subtitles/subtitles.ts | 264 + packages/javascript/styles/index.scss | 15 + .../javascript/types/videojs-extensions.d.ts | 15 + packages/javascript/utils.ts | 494 ++ packages/package.json | 59 + packages/react-wrapper/IKVideoPlayer.tsx | 91 + packages/react-wrapper/index.ts | 3 + packages/react-wrapper/interfaces.ts | 34 + packages/tsconfig.json | 19 + packages/tsup.config.ts | 34 + packages/yarn.lock | 1289 ++++ yarn.lock | 5313 +++++++++++++++++ 63 files changed, 12828 insertions(+) create mode 100644 .gitignore create mode 100644 .vscode/settings.json create mode 100644 cp_command.sh create mode 100644 example.html create mode 100644 examples/javascript/index.html create mode 100644 examples/javascript/package-lock.json create mode 100644 examples/javascript/package.json create mode 100644 examples/javascript/src/main.ts create mode 100644 examples/javascript/src/playlist.ts create mode 100644 examples/javascript/tsconfig.json create mode 100644 examples/javascript/vite.config.ts create mode 100644 examples/react/index.html create mode 100644 examples/react/package.json create mode 100644 examples/react/src/App.tsx create mode 100644 examples/react/src/main.tsx create mode 100644 examples/react/tsconfig.json create mode 100644 examples/react/vite.config.ts create mode 100644 examples/react/yarn.lock create mode 100644 lerna.json create mode 100644 package.json create mode 100644 packages/javascript/index.ts create mode 100644 packages/javascript/interfaces/ABSOptions.ts create mode 100644 packages/javascript/interfaces/Player.ts create mode 100644 packages/javascript/interfaces/Playlist.ts create mode 100644 packages/javascript/interfaces/Poster.ts create mode 100644 packages/javascript/interfaces/Shoppable.ts create mode 100644 packages/javascript/interfaces/SourceOptions.ts create mode 100644 packages/javascript/interfaces/TextTrack.ts create mode 100644 packages/javascript/interfaces/index.ts create mode 100644 packages/javascript/modules/chapters/chapter.css create mode 100644 packages/javascript/modules/chapters/chapterMarkerProgressBar.ts create mode 100644 packages/javascript/modules/http-source-selector/components/SourceMenuButton.js create mode 100644 packages/javascript/modules/http-source-selector/components/SourceMenuItem.js create mode 100644 packages/javascript/modules/http-source-selector/plugin.js create mode 100644 packages/javascript/modules/http-source-selector/plugin.scss create mode 100644 packages/javascript/modules/playlist/auto-advance.ts create mode 100644 packages/javascript/modules/playlist/playlist-manager.ts create mode 100644 packages/javascript/modules/playlist/playlist-menu-item.ts create mode 100644 packages/javascript/modules/playlist/playlist-menu.ts create mode 100644 packages/javascript/modules/playlist/playlist-ui.scss create mode 100644 packages/javascript/modules/playlist/playlist.ts create mode 100644 packages/javascript/modules/playlist/utils.ts create mode 100644 packages/javascript/modules/recommendations-overlay/recommendation-overlay.css create mode 100644 packages/javascript/modules/recommendations-overlay/recommendations-overlay.ts create mode 100644 packages/javascript/modules/seek-thumbnails/mockSeekThumbnailsVTT.ts create mode 100644 packages/javascript/modules/seek-thumbnails/seek-thumbnails-manager.ts create mode 100644 packages/javascript/modules/seek-thumbnails/seek-thumbnails.css create mode 100644 packages/javascript/modules/seek-thumbnails/vtt-parser.ts create mode 100644 packages/javascript/modules/shoppable/shoppable-manager.ts create mode 100644 packages/javascript/modules/subtitles/sample.transcript create mode 100644 packages/javascript/modules/subtitles/subtitles.css create mode 100644 packages/javascript/modules/subtitles/subtitles.ts create mode 100644 packages/javascript/styles/index.scss create mode 100644 packages/javascript/types/videojs-extensions.d.ts create mode 100644 packages/javascript/utils.ts create mode 100644 packages/package.json create mode 100644 packages/react-wrapper/IKVideoPlayer.tsx create mode 100644 packages/react-wrapper/index.ts create mode 100644 packages/react-wrapper/interfaces.ts create mode 100644 packages/tsconfig.json create mode 100644 packages/tsup.config.ts create mode 100644 packages/yarn.lock create mode 100644 yarn.lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5db3f4e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules +dist +old-code \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..7a73a41 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,2 @@ +{ +} \ No newline at end of file diff --git a/cp_command.sh b/cp_command.sh new file mode 100644 index 0000000..c294ae5 --- /dev/null +++ b/cp_command.sh @@ -0,0 +1,16 @@ + find . -mindepth 1 -name '.*' -prune \ + -o \( -name examples -o -name packages -o -name dist -o -path ./packages/javascript/modules -o -name node_modules -o -name yarn.lock \) -prune \ + -o -type f \ + ! -iname '*.jpg' \ + ! -iname '*.jpeg' \ + ! -iname '*.png' \ + ! -iname '*.gif' \ + ! -iname '*.bmp' \ + ! -iname '*.svg' \ + ! -iname '*.webp' \ + ! -iname 'sample.json' \ + ! -iname 'sample.transcript' \ + ! -iname '*.scss' \ + ! -iname '*.css' \ + ! -iname '*.html' \ + -print0 | xargs -0r tail -n +1 | pbcopy \ No newline at end of file diff --git a/example.html b/example.html new file mode 100644 index 0000000..b879aea --- /dev/null +++ b/example.html @@ -0,0 +1,231 @@ + + + + + Video.js Playlist UI - Default Implementation + + + + + +
+

Video.js Playlist UI - Default Implementation

+

+ You can see the Video.js Playlist UI plugin in action below. Look at the + source of this page to see how to use it with your videos. +

+

+ In the default configuration, the plugin looks for the first element that + has the class "vjs-playlist" and uses that as a container for the list. +

+
+ + + +
+ + +
+ +
+
+ + + + + + + \ No newline at end of file diff --git a/examples/javascript/index.html b/examples/javascript/index.html new file mode 100644 index 0000000..c12f5ae --- /dev/null +++ b/examples/javascript/index.html @@ -0,0 +1,25 @@ + + + + + + ImageKit Video Player Example + + + + + + + + + + + + diff --git a/examples/javascript/package-lock.json b/examples/javascript/package-lock.json new file mode 100644 index 0000000..2ae78c2 --- /dev/null +++ b/examples/javascript/package-lock.json @@ -0,0 +1,32 @@ +{ + "name": "javascript-example", + "version": "1.0.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@types/imagekit": { + "version": "3.1.5", + "resolved": "https://npm.imagekit.io/@types/imagekit/-/imagekit-3.1.5.tgz", + "integrity": "sha512-4wgppXAxXrGCsB5B4so9HyhoHGWMDQdqfvpgC6ZGozpBAKpaAMBZZ0hrLrobvngdRCRPiiOuER35qnIZg4gaDw==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@types/node": { + "version": "22.15.21", + "resolved": "https://npm.imagekit.io/@types/node/-/node-22.15.21.tgz", + "integrity": "sha512-EV/37Td6c+MgKAbkcLG6vqZ2zEYHD7bvSrzqqs2RIhbA6w3x+Dqz8MZM3sP6kGTeLrdoOgKZe+Xja7tUB2DNkQ==", + "dev": true, + "requires": { + "undici-types": "~6.21.0" + } + }, + "undici-types": { + "version": "6.21.0", + "resolved": "https://npm.imagekit.io/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true + } + } +} diff --git a/examples/javascript/package.json b/examples/javascript/package.json new file mode 100644 index 0000000..dddc297 --- /dev/null +++ b/examples/javascript/package.json @@ -0,0 +1,20 @@ +{ + "name": "javascript-example", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "vite", + "build": "vite build", + "serve": "vite preview" + }, + "devDependencies": { + "@types/imagekit": "^3.1.5", + "typescript": "^5.0.0", + "vite": "^4.0.0", + "@types/node": "^20.0.0" + }, + "dependencies": { + "imagekit": "^3.1.5", + "@imagekit/video-player": "workspace:*" + } +} diff --git a/examples/javascript/src/main.ts b/examples/javascript/src/main.ts new file mode 100644 index 0000000..3680b9b --- /dev/null +++ b/examples/javascript/src/main.ts @@ -0,0 +1,319 @@ +import { videoPlayer } from '@imagekit/video-player'; +import '@imagekit/video-player/dist/styles.css'; +// import ImageKit from "imagekit"; + +// const imagekit = new ImageKit({ +// publicKey : "public_92inTOPHEeoguRfWsAbSsWKRW5A=", +// privateKey : "private_CWTsgqd0ejvpfXdEPTPzzE7SlJ8=", +// urlEndpoint : "https://ik.imagekit.io/zuqlyov9d" +// }); + +const player = videoPlayer('video', { + imagekitId: 'YOUR_IMAGEKIT_ID', + logo: { + showLogo: true, + logoImageUrl: 'https://ik.imgkit.net/ikmedia/logo/light_T4buIzohVH.svg', + logoOnclickUrl: 'https://imagekit.io/', + }, + // signerFn: (src: string) => { + // // Promisify the signing process + // return new Promise((resolve, reject) => { + // try { + // const imageURL = imagekit.url({ + // src: src, + // queryParameters: { + // "v": "123" + // }, + // signed: true, + // expireSeconds: 300 + // }); + // resolve(imageURL); + // } + // catch (error) { + // reject(error); + // } + // }) + // } + // playbackRates: [0.5, 1, 1.5, 2] +}); + +const playlistManager = player.playlist([ + { + src: 'https://ik.imagekit.io/zuqlyov9d/SEO-friendly%20file%20names.mp4', + transformation: [ + { width: 400, height: 400 }, + ], + chapters: { url: 'https://ik.imagekit.io/zuqlyov9d/chapters.vtt?updatedAt=1747373131804&ik-s=3757a1645bd9e6a09aed6611f58e0ab742135693' }, + // abs: { protocol: 'hls', sr: [240, 360, 720, 1080] }, + recommendations: [ + { src: 'https://ik.imagekit.io/demo/video2.mp4', info: { title: 'Next Up' } } + ], + shoppable: { products: [ /* … */] }, + info: { title: 'My Video', subtitle: 'Subtitle', description: 'Description' }, + poster: { + src: 'https://ik.imagekit.io/ikmedia/docs_images/examples/Videos/example_video_2.mp4/ik-thumbnail.jpg', + transformation: [ + { width: 400, height: 400 }, + ], + } + }, + { + src: 'https://ik.imagekit.io/zuqlyov9d/SEO-friendly%20file%20names.mp4', + transformation: [ + { width: 400, height: 400 }, + ], + chapters: { url: 'https://ik.imagekit.io/demo/chapters_example.vtt' }, + // abs: { protocol: 'hls', sr: [240, 360, 720, 1080] }, + recommendations: [ + { src: 'https://ik.imagekit.io/demo/video2.mp4', info: { title: 'Next Up' } } + ], + shoppable: { products: [ /* … */] }, + info: { title: 'My Video', subtitle: 'Subtitle', description: 'Description' }, + poster: { + src: 'https://ik.imagekit.io/demo/sample-video.mp4/ik-thumbnail.jpg', + transformation: [ + { width: 400, height: 400 }, + ], + } + }, + { + src: 'https://ik.imagekit.io/zuqlyov9d/SEO-friendly%20file%20names.mp4', + // transformation: [ + // { width: 400, height: 400 }, + // ], + chapters: { url: 'https://ik.imagekit.io/demo/chapters_example.vtt' }, + abs: { protocol: 'hls', sr: [240, 360, 720, 1080] }, + recommendations: [ + { src: 'https://ik.imagekit.io/demo/video2.mp4', info: { title: 'Next Up' } } + ], + shoppable: { products: [ /* … */] }, + info: { title: 'My Video', subtitle: 'Subtitle', description: 'Description' }, + poster: { + src: 'https://ik.imagekit.io/demo/sample-video.mp4/ik-thumbnail.jpg', + transformation: [ + { width: 400, height: 400 }, + ], + } + }, + { + src: 'https://ik.imagekit.io/zuqlyov9d/SEO-friendly%20file%20names.mp4', + // transformation: [ + // { width: 400, height: 400 }, + // ], + chapters: { url: 'https://ik.imagekit.io/demo/chapters_example.vtt' }, + abs: { protocol: 'hls', sr: [240, 360, 720, 1080] }, + recommendations: [ + { src: 'https://ik.imagekit.io/demo/video2.mp4', info: { title: 'Next Up' } } + ], + shoppable: { products: [ /* … */] }, + info: { title: 'My Video', subtitle: 'Subtitle', description: 'Description' }, + poster: { + src: 'https://ik.imagekit.io/demo/sample-video.mp4/ik-thumbnail.jpg', + transformation: [ + { width: 400, height: 400 }, + ], + } + }, + { + src: 'https://ik.imagekit.io/zuqlyov9d/SEO-friendly%20file%20names.mp4', + // transformation: [ + // { width: 400, height: 400 }, + // ], + chapters: { url: 'https://ik.imagekit.io/demo/chapters_example.vtt' }, + abs: { protocol: 'hls', sr: [240, 360, 720, 1080] }, + recommendations: [ + { src: 'https://ik.imagekit.io/demo/video2.mp4', info: { title: 'Next Up' } } + ], + shoppable: { products: [ /* … */] }, + info: { title: 'My Video', subtitle: 'Subtitle', description: 'Description' }, + poster: { + src: 'https://ik.imagekit.io/demo/sample-video.mp4/ik-thumbnail.jpg', + transformation: [ + { width: 400, height: 400 }, + ], + } + }, + { + src: 'https://ik.imagekit.io/zuqlyov9d/SEO-friendly%20file%20names.mp4', + // transformation: [ + // { width: 400, height: 400 }, + // ], + chapters: { url: 'https://ik.imagekit.io/demo/chapters_example.vtt' }, + abs: { protocol: 'hls', sr: [240, 360, 720, 1080] }, + recommendations: [ + { src: 'https://ik.imagekit.io/demo/video2.mp4', info: { title: 'Next Up' } } + ], + shoppable: { products: [ /* … */] }, + info: { title: 'My Video', subtitle: 'Subtitle', description: 'Description' }, + poster: { + src: 'https://ik.imagekit.io/demo/sample-video.mp4/ik-thumbnail.jpg', + transformation: [ + { width: 400, height: 400 }, + ], + } + }, + { + src: 'https://ik.imagekit.io/zuqlyov9d/SEO-friendly%20file%20names.mp4', + // transformation: [ + // { width: 400, height: 400 }, + // ], + chapters: { url: 'https://ik.imagekit.io/demo/chapters_example.vtt' }, + abs: { protocol: 'hls', sr: [240, 360, 720, 1080] }, + recommendations: [ + { src: 'https://ik.imagekit.io/demo/video2.mp4', info: { title: 'Next Up' } } + ], + shoppable: { products: [ /* … */] }, + info: { title: 'My Video', subtitle: 'Subtitle', description: 'Description' }, + poster: { + src: 'https://ik.imagekit.io/demo/sample-video.mp4/ik-thumbnail.jpg', + transformation: [ + { width: 400, height: 400 }, + ], + } + } +], { + autoAdvance: 1, + repeat: true, + presentUpcoming: 10, + widgetProps: { direction: 'vertical', logoImageUrl: '…' } +}) + +Manager.loadFirstItem(); + +// player.on('playlistchange', () => { +// console.log("playlist changed") +// console.log(" player.src()", player.src()) +// }) + +// player.addRemoteTextTrack({ +// src: 'https://ik.imagekit.io/zuqlyov9d/chapters.vtt?updatedAt=1747368893148&ik-s=d4ee6d4ab959139bdd4c75fae974b4bf3719d0de', +// kind: 'chapters', +// label: 'Chapters', +// srclang: 'en', +// }, false); + + +// player.src( +// { +// src: 'https://ik.imagekit.io/zuqlyov9d/SEO-friendly%20file%20names.mp4', +// transformation: [ +// // { rotation: 90, height: 400 }, +// // { width: 400, height: 400 }, +// // { rotations: 90 }, +// // { raw: 'h-400,w-400' }, +// { +// raw: 'rt-90', +// } +// ], +// chapters: { url: 'https://ik.imagekit.io/zuqlyov9d/chapters.vtt' }, +// abs: { protocol: 'hls', sr: [240, 360, 720, 1080] }, +// recommendations: [ +// { +// src: 'https://ik.imagekit.io/ikmedia/docs_images/examples/Videos/example_video_2.mp4', info: { +// title: 'Next Up', +// subtitle: 'Subtitle that is a little long', +// description: 'A very long description that will be truncated if it is too long. A very long description that will be truncated if it is too long. A very long description that will be truncated if it is too long. A very long description that will be truncated if it is too long.', +// }, +// poster: { +// src: "https://ik.imagekit.io/ikmedia/docs_images/examples/Videos/example_video_2.mp4/ik-thumbnail.jpg" +// } +// }, +// { +// src: 'https://ik.imagekit.io/demo/video2.mp4', info: { title: 'Next Up' }, +// poster: { +// src: "https://ik.imagekit.io/ikmedia/docs_images/examples/Videos/example_video_2.mp4/ik-thumbnail.jpg" +// } +// }, +// { +// src: 'https://ik.imagekit.io/demo/video2.mp4', info: { title: 'Next Up' }, +// poster: { +// src: "https://ik.imagekit.io/ikmedia/docs_images/examples/Videos/example_video_2.mp4/ik-thumbnail.jpg" +// } +// }, +// { +// src: 'https://ik.imagekit.io/demo/video2.mp4', info: { title: 'Next Up' }, +// poster: { +// src: "https://ik.imagekit.io/ikmedia/docs_images/examples/Videos/example_video_2.mp4/ik-thumbnail.jpg" +// } +// }, +// { +// src: 'https://ik.imagekit.io/demo/video2.mp4', info: { title: 'Next Up' }, +// poster: { +// src: "https://ik.imagekit.io/ikmedia/docs_images/examples/Videos/example_video_2.mp4/ik-thumbnail.jpg" +// } +// } +// ], +// shoppable: { products: [ /* … */] }, +// info: { title: 'My Video', subtitle: 'Subtitle', description: 'Description' }, +// // poster: { +// // src: 'https://ik.imagekit.io/ikmedia/docs_images/examples/Videos/example_video_2.mp4/ik-thumbnail.jpg', +// // transformation: [ +// // { width: 400, height: 400 }, +// // ], +// // }, +// // textTracks: [ +// // { +// // src: 'http://vjs.zencdn.net/v/oceans.vtt', +// // maxWords: 4, +// // wordHighlight: true, +// // default: true // Indicates whether this track is active by default +// // }, +// // { +// // autoGenerateSubtitles: true, +// // maxWords: 4, +// // wordHighlight: true, +// // default: true // Indicates whether this track is active by default +// // } +// // ] +// }, +// ) + +// player.addRemoteTextTrack({ +// src: 'http://vjs.zencdn.net/v/oceans.vtt', +// kind: 'subtitles', +// srclang: 'en', +// label: 'English', +// default: true, +// }); + + +// player.addRemoteTextTrack({ +// src: 'http://vjs.zencdn.net/v/oceans.vtt', +// kind: 'subtitles', +// srclang: 'en', +// label: 'English', +// default: true, +// }); + + +// player.addRemoteTextTrack({ +// src: 'http://vjs.zencdn.net/v/oceans.vtt', +// kind: 'subtitles', +// srclang: 'en', +// label: 'English', +// default: true, +// }); + + +// player.addRemoteTextTrack({ +// autoGenerateSubtitles: true, +// maxWords: 4, +// wordHighlight: true, +// default: true // Indicates whether this track is active by default +// }) + + +// player.addRemoteTextTrack({ +// src: 'http://vjs.zencdn.net/v/oceans.vtt', +// maxWords: 4, +// wordHighlight: true, +// default: true // Indicates whether this track is active by default +// }) + + +// player.addRemoteTextTrack({ +// src: 'http://vjs.zencdn.net/v/oceans.vtt', +// // maxWords: 4, +// // wordHighlight: true, +// default: true // Indicates whether this track is active by default +// }) \ No newline at end of file diff --git a/examples/javascript/src/playlist.ts b/examples/javascript/src/playlist.ts new file mode 100644 index 0000000..230e648 --- /dev/null +++ b/examples/javascript/src/playlist.ts @@ -0,0 +1,167 @@ +import { videoPlayer } from '@imagekit/video-player'; +import '@imagekit/video-player/dist/styles.css'; + +const player = videoPlayer('video', { + imagekitId: 'YOUR_IMAGEKIT_ID', + seekThumbnails: true, + logo: { + showLogo: true, + logoImageUrl: 'https://ik.imgkit.net/ikmedia/logo/light_T4buIzohVH.svg', + logoOnclickUrl: 'https://imagekit.io/', + }, +}); + +const playlistManager = player.playlist({ + sources: [ + { + src: 'https://ik.imagekit.io/zuqlyov9d/SEO-friendly%20file%20names.mp4', + transformation: [ + { width: 400, height: 400 }, + ], + chapters: true, + info: { title: 'Dog', subtitle: 'Dog wearing cap', description: 'This is a video containing dog wearing cap.' }, + shoppable: { + transformation: [{ height: "300", width: "400" }], + products: [ + { + productId: 1, + productName: "Sunglasses", + highlightTime: { start: 0, end: 2 }, + imageUrl: "https://ik.imagekit.io/a1yisxurxo/women_in_red_2nd_test_L0pnP7Hb3.jpg?updatedAt=1744896751866", + hotspots: [ + { + time: "00:02", + x: "50%", + y: "50%", + tooltipPosition: "left", + clickUrl: "https://imagekit.io/dashboard/media-library/detail/680102eb432c476416cdd342" + } + ], + onHover: { + action: "overlay", + args: "Click to see this product in the video" + }, + onClick: { + action: "seek", + pause: 5, + args: { time: "00:02" } + } + }, + { + productId: 1, + productName: "Sunglasses", + highlightTime: { start: 0, end: 2 }, + imageUrl: "https://ik.imagekit.io/a1yisxurxo/women_in_red_2nd_test_L0pnP7Hb3.jpg?updatedAt=1744896751866", + hotspots: [ + { + time: "00:02", + x: "50%", + y: "50%", + tooltipPosition: "left", + clickUrl: "https://imagekit.io/dashboard/media-library/detail/680102eb432c476416cdd342" + } + ], + onHover: { + action: "overlay", + args: "Click to see this product in the video" + }, + onClick: { + action: "seek", + pause: 5, + args: { time: "00:02" } + } + }, + { + productId: 1, + productName: "Hat", + highlightTime: { start: 0, end: 2 }, + imageUrl: "https://ik.imagekit.io/a1yisxurxo/women_in_red_2nd_test_L0pnP7Hb3.jpg?updatedAt=1744896751866", + hotspots: [ + { + time: "00:02", + x: "50%", + y: "50%", + tooltipPosition: "left", + clickUrl: "https://imagekit.io/dashboard/media-library/detail/680102eb432c476416cdd342" + } + ], + onHover: { + action: "overlay", + args: "Click to see this product in the video" + }, + onClick: { + action: "seek", + pause: 5, + args: { time: "00:04" } + } + }, + { + productId: 1, + productName: "Shorts", + highlightTime: { start: 0, end: 2 }, + imageUrl: "https://ik.imagekit.io/a1yisxurxo/women_in_red_2nd_test_L0pnP7Hb3.jpg?updatedAt=1744896751866", + hotspots: [ + { + time: "00:02", + x: "50%", + y: "50%", + tooltipPosition: "left", + clickUrl: "https://imagekit.io/dashboard/media-library/detail/680102eb432c476416cdd342" + } + ], + onHover: { + action: "overlay", + args: "Click to see this product in the video" + }, + onClick: { + action: "seek", + pause: 5, + args: { time: "00:06" } + } + }, + // …two more products… + ], + toggleIconUrl: "https://ik.imgkit.net/ikmedia/logo/light_T4buIzohVH.svg" + } + }, + // { + // src: 'https://ik.imagekit.io/zuqlyov9d/example_video_2.mp4', + // transformation: [ + // { width: 400, height: 400 }, + // ], + // chapters: true, + // info: { title: 'Human', subtitle: 'Human lying in grass', description: 'This is a video showing human lying on the grass. He is smiling.' }, + // textTracks: [ + // { + // autoGenerateSubtitles: true, + // maxWords: 4, + // wordHighlight: true, + // default: true // Indicates whether this track is active by default + // }] + // }, + // { + // src: 'https://ik.imagekit.io/zuqlyov9d/sample-video.mp4', + // chapters: true, + // info: { title: 'Bird', subtitle: 'Bird on a branch', description: 'This video depicts bird chirping. It is sitting on a tree branch.' }, + // }, + ], options: { + autoAdvance: 1, + repeat: true, + presentUpcoming: 10, + widgetProps: { direction: 'vertical' } + } +}) + +player.on(['loadstart'], () => { + console.log('loadstart fired'); +}); + +player.on(['loadedmetadata'], () => { +console.log('Metadata loaded'); +}); + +player.on(['loadeddata'], () => { +console.log('data loaded fired'); +}); + +playlistManager.loadFirstItem(); \ No newline at end of file diff --git a/examples/javascript/tsconfig.json b/examples/javascript/tsconfig.json new file mode 100644 index 0000000..ee03aa0 --- /dev/null +++ b/examples/javascript/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true + }, + "include": ["src"] +} + \ No newline at end of file diff --git a/examples/javascript/vite.config.ts b/examples/javascript/vite.config.ts new file mode 100644 index 0000000..157229f --- /dev/null +++ b/examples/javascript/vite.config.ts @@ -0,0 +1,19 @@ +import path from 'path'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + root: '.', + server: { + port: 3000 + }, + build: { + outDir: 'dist', + emptyOutDir: true + }, + resolve: { + alias: { + // This creates a direct alias to your package's root + '@imagekit/video-player': path.resolve(__dirname, '../../packages'), + }, + }, +}); diff --git a/examples/react/index.html b/examples/react/index.html new file mode 100644 index 0000000..193f1f5 --- /dev/null +++ b/examples/react/index.html @@ -0,0 +1,14 @@ + + + + + + IK Video Player React Example + + +
+ + + + + diff --git a/examples/react/package.json b/examples/react/package.json new file mode 100644 index 0000000..36eee37 --- /dev/null +++ b/examples/react/package.json @@ -0,0 +1,22 @@ +{ + "name": "my-react-app", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0", + "@imagekit/video-player": "workspace:*" + }, + "devDependencies": { + "@types/react": "^18.0.0", + "@types/react-dom": "^18.0.0", + "typescript": "^5.0.0", + "vite": "^4.0.0", + "@vitejs/plugin-react": "^3.0.0" + } +} \ No newline at end of file diff --git a/examples/react/src/App.tsx b/examples/react/src/App.tsx new file mode 100644 index 0000000..f471207 --- /dev/null +++ b/examples/react/src/App.tsx @@ -0,0 +1,117 @@ +import React, { useRef } from 'react'; +import { + IKVideoPlayer, +} from '@imagekit/video-player/react'; + +import type { + IKVideoPlayerRef +} from '@imagekit/video-player/react'; + +import { + PlayerOptions, + SourceOptions, + PlaylistOptions +} from '@imagekit/video-player' + +export default function App() { + const playerRef = useRef(null); + + // 1) Define your ImageKit PlayerOptions + const ikOptions: PlayerOptions = { + imagekitId: 'YOUR_IMAGEKIT_ID', + seekThumbnails: true, + logo: { + showLogo: true, + logoImageUrl: 'https://ik.imgkit.net/ikmedia/logo/light_T4buIzohVH.svg', + logoOnclickUrl: 'https://imagekit.io/', + }, + }; + + // 2) For a single video source (SourceOptions) + const singleSource: SourceOptions = { + src: 'https://ik.imagekit.io/zuqlyov9d/SEO-friendly%20file%20names.mp4', + transformation: [ + { width: 400, height: 400 }, + ], + chapters: true, + info: { title: 'Dog', subtitle: 'Dog wearing cap', description: 'This is a video containing dog wearing cap.' } + }; + + // 3) (alternative) for a playlist of videos + const playlist: { + sources: SourceOptions[]; + options?: PlaylistOptions; + } = { + sources: [ + { + src: 'https://ik.imagekit.io/zuqlyov9d/SEO-friendly%20file%20names.mp4', + transformation: [ + { width: 400, height: 400 }, + ], + chapters: true, + info: { title: 'Dog', subtitle: 'Dog wearing cap', description: 'This is a video containing dog wearing cap.' } + }, + { + src: 'https://ik.imagekit.io/zuqlyov9d/example_video_2.mp4', + transformation: [ + { width: 400, height: 400 }, + ], + chapters: true, + info: { title: 'Human', subtitle: 'Human lying in grass', description: 'This is a video showing human lying on the grass. He is smiling.' }, + textTracks: [ + { + autoGenerateSubtitles: true, + maxWords: 4, + wordHighlight: true, + default: true // Indicates whether this track is active by default + }] + }, + { + src: 'https://ik.imagekit.io/zuqlyov9d/sample-video.mp4', + chapters: true, + info: { title: 'Bird', subtitle: 'Bird on a branch', description: 'This video depicts bird chirping. It is sitting on a tree branch.' }, + }, + ], options: { + autoAdvance: 1, + repeat: true, + presentUpcoming: 10, + widgetProps: { direction: 'vertical' } + } + }; + + // Handler to demonstrate how to grab the raw player instance + const onLogCurrentTime = () => { + const player = playerRef.current?.getPlayer(); + if (player) { + console.log('Current time:', player.currentTime()); + player.play(); // you can call any Video.js method + } + }; + + return ( +
+

ImageKit Video Player (React) Example

+ + + +
+ +
+
+ ); +} diff --git a/examples/react/src/main.tsx b/examples/react/src/main.tsx new file mode 100644 index 0000000..e957810 --- /dev/null +++ b/examples/react/src/main.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; + +// Optional: import global CSS here +import 'video.js/dist/video-js.css'; + +// Does not work correctly with strict mode +// @todo fix this +ReactDOM.createRoot(document.getElementById('root')!).render( + +); \ No newline at end of file diff --git a/examples/react/tsconfig.json b/examples/react/tsconfig.json new file mode 100644 index 0000000..af3c1ca --- /dev/null +++ b/examples/react/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["DOM", "ESNext"], + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "Node", + "resolveJsonModule": true, + "isolatedModules": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "allowSyntheticDefaultImports": true + }, + "include": ["src"] +} diff --git a/examples/react/vite.config.ts b/examples/react/vite.config.ts new file mode 100644 index 0000000..bc411d2 --- /dev/null +++ b/examples/react/vite.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from 'vite'; +import path from 'path'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + root: '.', + server: { + port: 3001, + }, + build: { + outDir: 'dist', + emptyOutDir: true + }, + resolve: { + alias: { + // This creates a direct alias to your package's root + '@imagekit/video-player': path.resolve(__dirname, '../../packages'), + }, + }, +}); \ No newline at end of file diff --git a/examples/react/yarn.lock b/examples/react/yarn.lock new file mode 100644 index 0000000..fd9199c --- /dev/null +++ b/examples/react/yarn.lock @@ -0,0 +1,558 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@ampproject/remapping@^2.2.0": + version "2.3.0" + resolved "https://npm.imagekit.io/@ampproject/remapping/-/remapping-2.3.0.tgz#ed441b6fa600072520ce18b43d2c8cc8caecc7f4" + integrity sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw== + dependencies: + "@jridgewell/gen-mapping" "^0.3.5" + "@jridgewell/trace-mapping" "^0.3.24" + +"@babel/code-frame@^7.27.1": + version "7.27.1" + resolved "https://npm.imagekit.io/@babel/code-frame/-/code-frame-7.27.1.tgz#200f715e66d52a23b221a9435534a91cc13ad5be" + integrity sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg== + dependencies: + "@babel/helper-validator-identifier" "^7.27.1" + js-tokens "^4.0.0" + picocolors "^1.1.1" + +"@babel/compat-data@^7.27.2": + version "7.27.3" + resolved "https://npm.imagekit.io/@babel/compat-data/-/compat-data-7.27.3.tgz#cc49c2ac222d69b889bf34c795f537c0c6311111" + integrity sha512-V42wFfx1ymFte+ecf6iXghnnP8kWTO+ZLXIyZq+1LAXHHvTZdVxicn4yiVYdYMGaCO3tmqub11AorKkv+iodqw== + +"@babel/core@^7.20.12": + version "7.27.4" + resolved "https://npm.imagekit.io/@babel/core/-/core-7.27.4.tgz#cc1fc55d0ce140a1828d1dd2a2eba285adbfb3ce" + integrity sha512-bXYxrXFubeYdvB0NhD/NBB3Qi6aZeV20GOWVI47t2dkecCEoneR4NPVcb7abpXDEvejgrUfFtG6vG/zxAKmg+g== + dependencies: + "@ampproject/remapping" "^2.2.0" + "@babel/code-frame" "^7.27.1" + "@babel/generator" "^7.27.3" + "@babel/helper-compilation-targets" "^7.27.2" + "@babel/helper-module-transforms" "^7.27.3" + "@babel/helpers" "^7.27.4" + "@babel/parser" "^7.27.4" + "@babel/template" "^7.27.2" + "@babel/traverse" "^7.27.4" + "@babel/types" "^7.27.3" + convert-source-map "^2.0.0" + debug "^4.1.0" + gensync "^1.0.0-beta.2" + json5 "^2.2.3" + semver "^6.3.1" + +"@babel/generator@^7.27.3": + version "7.27.3" + resolved "https://npm.imagekit.io/@babel/generator/-/generator-7.27.3.tgz#ef1c0f7cfe3b5fc8cbb9f6cc69f93441a68edefc" + integrity sha512-xnlJYj5zepml8NXtjkG0WquFUv8RskFqyFcVgTBp5k+NaA/8uw/K+OSVf8AMGw5e9HKP2ETd5xpK5MLZQD6b4Q== + dependencies: + "@babel/parser" "^7.27.3" + "@babel/types" "^7.27.3" + "@jridgewell/gen-mapping" "^0.3.5" + "@jridgewell/trace-mapping" "^0.3.25" + jsesc "^3.0.2" + +"@babel/helper-compilation-targets@^7.27.2": + version "7.27.2" + resolved "https://npm.imagekit.io/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz#46a0f6efab808d51d29ce96858dd10ce8732733d" + integrity sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ== + dependencies: + "@babel/compat-data" "^7.27.2" + "@babel/helper-validator-option" "^7.27.1" + browserslist "^4.24.0" + lru-cache "^5.1.1" + semver "^6.3.1" + +"@babel/helper-module-imports@^7.27.1": + version "7.27.1" + resolved "https://npm.imagekit.io/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz#7ef769a323e2655e126673bb6d2d6913bbead204" + integrity sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w== + dependencies: + "@babel/traverse" "^7.27.1" + "@babel/types" "^7.27.1" + +"@babel/helper-module-transforms@^7.27.3": + version "7.27.3" + resolved "https://npm.imagekit.io/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz#db0bbcfba5802f9ef7870705a7ef8788508ede02" + integrity sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg== + dependencies: + "@babel/helper-module-imports" "^7.27.1" + "@babel/helper-validator-identifier" "^7.27.1" + "@babel/traverse" "^7.27.3" + +"@babel/helper-plugin-utils@^7.27.1": + version "7.27.1" + resolved "https://npm.imagekit.io/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz#ddb2f876534ff8013e6c2b299bf4d39b3c51d44c" + integrity sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw== + +"@babel/helper-string-parser@^7.27.1": + version "7.27.1" + resolved "https://npm.imagekit.io/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz#54da796097ab19ce67ed9f88b47bb2ec49367687" + integrity sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA== + +"@babel/helper-validator-identifier@^7.27.1": + version "7.27.1" + resolved "https://npm.imagekit.io/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz#a7054dcc145a967dd4dc8fee845a57c1316c9df8" + integrity sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow== + +"@babel/helper-validator-option@^7.27.1": + version "7.27.1" + resolved "https://npm.imagekit.io/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz#fa52f5b1e7db1ab049445b421c4471303897702f" + integrity sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg== + +"@babel/helpers@^7.27.4": + version "7.27.4" + resolved "https://npm.imagekit.io/@babel/helpers/-/helpers-7.27.4.tgz#c79050c6a0e41e095bfc96d469c85431e9ed7fe7" + integrity sha512-Y+bO6U+I7ZKaM5G5rDUZiYfUvQPUibYmAFe7EnKdnKBbVXDZxvp+MWOH5gYciY0EPk4EScsuFMQBbEfpdRKSCQ== + dependencies: + "@babel/template" "^7.27.2" + "@babel/types" "^7.27.3" + +"@babel/parser@^7.27.2", "@babel/parser@^7.27.3", "@babel/parser@^7.27.4": + version "7.27.4" + resolved "https://npm.imagekit.io/@babel/parser/-/parser-7.27.4.tgz#f92e89e4f51847be05427285836fc88341c956df" + integrity sha512-BRmLHGwpUqLFR2jzx9orBuX/ABDkj2jLKOXrHDTN2aOKL+jFDDKaRNo9nyYsIl9h/UE/7lMKdDjKQQyxKKDZ7g== + dependencies: + "@babel/types" "^7.27.3" + +"@babel/plugin-transform-react-jsx-self@^7.18.6": + version "7.27.1" + resolved "https://npm.imagekit.io/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz#af678d8506acf52c577cac73ff7fe6615c85fc92" + integrity sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + +"@babel/plugin-transform-react-jsx-source@^7.19.6": + version "7.27.1" + resolved "https://npm.imagekit.io/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz#dcfe2c24094bb757bf73960374e7c55e434f19f0" + integrity sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + +"@babel/template@^7.27.2": + version "7.27.2" + resolved "https://npm.imagekit.io/@babel/template/-/template-7.27.2.tgz#fa78ceed3c4e7b63ebf6cb39e5852fca45f6809d" + integrity sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw== + dependencies: + "@babel/code-frame" "^7.27.1" + "@babel/parser" "^7.27.2" + "@babel/types" "^7.27.1" + +"@babel/traverse@^7.27.1", "@babel/traverse@^7.27.3", "@babel/traverse@^7.27.4": + version "7.27.4" + resolved "https://npm.imagekit.io/@babel/traverse/-/traverse-7.27.4.tgz#b0045ac7023c8472c3d35effd7cc9ebd638da6ea" + integrity sha512-oNcu2QbHqts9BtOWJosOVJapWjBDSxGCpFvikNR5TGDYDQf3JwpIoMzIKrvfoti93cLfPJEG4tH9SPVeyCGgdA== + dependencies: + "@babel/code-frame" "^7.27.1" + "@babel/generator" "^7.27.3" + "@babel/parser" "^7.27.4" + "@babel/template" "^7.27.2" + "@babel/types" "^7.27.3" + debug "^4.3.1" + globals "^11.1.0" + +"@babel/types@^7.27.1", "@babel/types@^7.27.3": + version "7.27.3" + resolved "https://npm.imagekit.io/@babel/types/-/types-7.27.3.tgz#c0257bedf33aad6aad1f406d35c44758321eb3ec" + integrity sha512-Y1GkI4ktrtvmawoSq+4FCVHNryea6uR+qUQy0AGxLSsjCX0nVmkYQMBLHDkXZuo5hGx7eYdnIaslsdBFm7zbUw== + dependencies: + "@babel/helper-string-parser" "^7.27.1" + "@babel/helper-validator-identifier" "^7.27.1" + +"@esbuild/android-arm64@0.18.20": + version "0.18.20" + resolved "https://npm.imagekit.io/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz#984b4f9c8d0377443cc2dfcef266d02244593622" + integrity sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ== + +"@esbuild/android-arm@0.18.20": + version "0.18.20" + resolved "https://npm.imagekit.io/@esbuild/android-arm/-/android-arm-0.18.20.tgz#fedb265bc3a589c84cc11f810804f234947c3682" + integrity sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw== + +"@esbuild/android-x64@0.18.20": + version "0.18.20" + resolved "https://npm.imagekit.io/@esbuild/android-x64/-/android-x64-0.18.20.tgz#35cf419c4cfc8babe8893d296cd990e9e9f756f2" + integrity sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg== + +"@esbuild/darwin-arm64@0.18.20": + version "0.18.20" + resolved "https://npm.imagekit.io/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz#08172cbeccf95fbc383399a7f39cfbddaeb0d7c1" + integrity sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA== + +"@esbuild/darwin-x64@0.18.20": + version "0.18.20" + resolved "https://npm.imagekit.io/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz#d70d5790d8bf475556b67d0f8b7c5bdff053d85d" + integrity sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ== + +"@esbuild/freebsd-arm64@0.18.20": + version "0.18.20" + resolved "https://npm.imagekit.io/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz#98755cd12707f93f210e2494d6a4b51b96977f54" + integrity sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw== + +"@esbuild/freebsd-x64@0.18.20": + version "0.18.20" + resolved "https://npm.imagekit.io/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz#c1eb2bff03915f87c29cece4c1a7fa1f423b066e" + integrity sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ== + +"@esbuild/linux-arm64@0.18.20": + version "0.18.20" + resolved "https://npm.imagekit.io/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz#bad4238bd8f4fc25b5a021280c770ab5fc3a02a0" + integrity sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA== + +"@esbuild/linux-arm@0.18.20": + version "0.18.20" + resolved "https://npm.imagekit.io/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz#3e617c61f33508a27150ee417543c8ab5acc73b0" + integrity sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg== + +"@esbuild/linux-ia32@0.18.20": + version "0.18.20" + resolved "https://npm.imagekit.io/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz#699391cccba9aee6019b7f9892eb99219f1570a7" + integrity sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA== + +"@esbuild/linux-loong64@0.18.20": + version "0.18.20" + resolved "https://npm.imagekit.io/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz#e6fccb7aac178dd2ffb9860465ac89d7f23b977d" + integrity sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg== + +"@esbuild/linux-mips64el@0.18.20": + version "0.18.20" + resolved "https://npm.imagekit.io/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz#eeff3a937de9c2310de30622a957ad1bd9183231" + integrity sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ== + +"@esbuild/linux-ppc64@0.18.20": + version "0.18.20" + resolved "https://npm.imagekit.io/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz#2f7156bde20b01527993e6881435ad79ba9599fb" + integrity sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA== + +"@esbuild/linux-riscv64@0.18.20": + version "0.18.20" + resolved "https://npm.imagekit.io/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz#6628389f210123d8b4743045af8caa7d4ddfc7a6" + integrity sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A== + +"@esbuild/linux-s390x@0.18.20": + version "0.18.20" + resolved "https://npm.imagekit.io/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz#255e81fb289b101026131858ab99fba63dcf0071" + integrity sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ== + +"@esbuild/linux-x64@0.18.20": + version "0.18.20" + resolved "https://npm.imagekit.io/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz#c7690b3417af318a9b6f96df3031a8865176d338" + integrity sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w== + +"@esbuild/netbsd-x64@0.18.20": + version "0.18.20" + resolved "https://npm.imagekit.io/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz#30e8cd8a3dded63975e2df2438ca109601ebe0d1" + integrity sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A== + +"@esbuild/openbsd-x64@0.18.20": + version "0.18.20" + resolved "https://npm.imagekit.io/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz#7812af31b205055874c8082ea9cf9ab0da6217ae" + integrity sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg== + +"@esbuild/sunos-x64@0.18.20": + version "0.18.20" + resolved "https://npm.imagekit.io/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz#d5c275c3b4e73c9b0ecd38d1ca62c020f887ab9d" + integrity sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ== + +"@esbuild/win32-arm64@0.18.20": + version "0.18.20" + resolved "https://npm.imagekit.io/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz#73bc7f5a9f8a77805f357fab97f290d0e4820ac9" + integrity sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg== + +"@esbuild/win32-ia32@0.18.20": + version "0.18.20" + resolved "https://npm.imagekit.io/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz#ec93cbf0ef1085cc12e71e0d661d20569ff42102" + integrity sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g== + +"@esbuild/win32-x64@0.18.20": + version "0.18.20" + resolved "https://npm.imagekit.io/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz#786c5f41f043b07afb1af37683d7c33668858f6d" + integrity sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ== + +"@jridgewell/gen-mapping@^0.3.5": + version "0.3.8" + resolved "https://npm.imagekit.io/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz#4f0e06362e01362f823d348f1872b08f666d8142" + integrity sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA== + dependencies: + "@jridgewell/set-array" "^1.2.1" + "@jridgewell/sourcemap-codec" "^1.4.10" + "@jridgewell/trace-mapping" "^0.3.24" + +"@jridgewell/resolve-uri@^3.1.0": + version "3.1.2" + resolved "https://npm.imagekit.io/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz#7a0ee601f60f99a20c7c7c5ff0c80388c1189bd6" + integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== + +"@jridgewell/set-array@^1.2.1": + version "1.2.1" + resolved "https://npm.imagekit.io/@jridgewell/set-array/-/set-array-1.2.1.tgz#558fb6472ed16a4c850b889530e6b36438c49280" + integrity sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A== + +"@jridgewell/sourcemap-codec@^1.4.10", "@jridgewell/sourcemap-codec@^1.4.13", "@jridgewell/sourcemap-codec@^1.4.14": + version "1.5.0" + resolved "https://npm.imagekit.io/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz#3188bcb273a414b0d215fd22a58540b989b9409a" + integrity sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ== + +"@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25": + version "0.3.25" + resolved "https://npm.imagekit.io/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz#15f190e98895f3fc23276ee14bc76b675c2e50f0" + integrity sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ== + dependencies: + "@jridgewell/resolve-uri" "^3.1.0" + "@jridgewell/sourcemap-codec" "^1.4.14" + +"@types/prop-types@*": + version "15.7.14" + resolved "https://npm.imagekit.io/@types/prop-types/-/prop-types-15.7.14.tgz#1433419d73b2a7ebfc6918dcefd2ec0d5cd698f2" + integrity sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ== + +"@types/react-dom@^18.0.0": + version "18.3.7" + resolved "https://npm.imagekit.io/@types/react-dom/-/react-dom-18.3.7.tgz#b89ddf2cd83b4feafcc4e2ea41afdfb95a0d194f" + integrity sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ== + +"@types/react@^18.0.0": + version "18.3.23" + resolved "https://npm.imagekit.io/@types/react/-/react-18.3.23.tgz#86ae6f6b95a48c418fecdaccc8069e0fbb63696a" + integrity sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w== + dependencies: + "@types/prop-types" "*" + csstype "^3.0.2" + +"@vitejs/plugin-react@^3.0.0": + version "3.1.0" + resolved "https://npm.imagekit.io/@vitejs/plugin-react/-/plugin-react-3.1.0.tgz#d1091f535eab8b83d6e74034d01e27d73c773240" + integrity sha512-AfgcRL8ZBhAlc3BFdigClmTUMISmmzHn7sB2h9U1odvc5U/MjWXsAaz18b/WoppUTDBzxOJwo2VdClfUcItu9g== + dependencies: + "@babel/core" "^7.20.12" + "@babel/plugin-transform-react-jsx-self" "^7.18.6" + "@babel/plugin-transform-react-jsx-source" "^7.19.6" + magic-string "^0.27.0" + react-refresh "^0.14.0" + +browserslist@^4.24.0: + version "4.25.0" + resolved "https://npm.imagekit.io/browserslist/-/browserslist-4.25.0.tgz#986aa9c6d87916885da2b50d8eb577ac8d133b2c" + integrity sha512-PJ8gYKeS5e/whHBh8xrwYK+dAvEj7JXtz6uTucnMRB8OiGTsKccFekoRrjajPBHV8oOY+2tI4uxeceSimKwMFA== + dependencies: + caniuse-lite "^1.0.30001718" + electron-to-chromium "^1.5.160" + node-releases "^2.0.19" + update-browserslist-db "^1.1.3" + +caniuse-lite@^1.0.30001718: + version "1.0.30001720" + resolved "https://npm.imagekit.io/caniuse-lite/-/caniuse-lite-1.0.30001720.tgz#c138cb6026d362be9d8d7b0e4bcd0183a850edfd" + integrity sha512-Ec/2yV2nNPwb4DnTANEV99ZWwm3ZWfdlfkQbWSDDt+PsXEVYwlhPH8tdMaPunYTKKmz7AnHi2oNEi1GcmKCD8g== + +convert-source-map@^2.0.0: + version "2.0.0" + resolved "https://npm.imagekit.io/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a" + integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== + +csstype@^3.0.2: + version "3.1.3" + resolved "https://npm.imagekit.io/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81" + integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw== + +debug@^4.1.0, debug@^4.3.1: + version "4.4.1" + resolved "https://npm.imagekit.io/debug/-/debug-4.4.1.tgz#e5a8bc6cbc4c6cd3e64308b0693a3d4fa550189b" + integrity sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ== + dependencies: + ms "^2.1.3" + +electron-to-chromium@^1.5.160: + version "1.5.162" + resolved "https://npm.imagekit.io/electron-to-chromium/-/electron-to-chromium-1.5.162.tgz#5305c15292a960f36e86f8b330da958f8ae1690d" + integrity sha512-hQA+Zb5QQwoSaXJWEAGEw1zhk//O7qDzib05Z4qTqZfNju/FAkrm5ZInp0JbTp4Z18A6bilopdZWEYrFSsfllA== + +esbuild@^0.18.10: + version "0.18.20" + resolved "https://npm.imagekit.io/esbuild/-/esbuild-0.18.20.tgz#4709f5a34801b43b799ab7d6d82f7284a9b7a7a6" + integrity sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA== + optionalDependencies: + "@esbuild/android-arm" "0.18.20" + "@esbuild/android-arm64" "0.18.20" + "@esbuild/android-x64" "0.18.20" + "@esbuild/darwin-arm64" "0.18.20" + "@esbuild/darwin-x64" "0.18.20" + "@esbuild/freebsd-arm64" "0.18.20" + "@esbuild/freebsd-x64" "0.18.20" + "@esbuild/linux-arm" "0.18.20" + "@esbuild/linux-arm64" "0.18.20" + "@esbuild/linux-ia32" "0.18.20" + "@esbuild/linux-loong64" "0.18.20" + "@esbuild/linux-mips64el" "0.18.20" + "@esbuild/linux-ppc64" "0.18.20" + "@esbuild/linux-riscv64" "0.18.20" + "@esbuild/linux-s390x" "0.18.20" + "@esbuild/linux-x64" "0.18.20" + "@esbuild/netbsd-x64" "0.18.20" + "@esbuild/openbsd-x64" "0.18.20" + "@esbuild/sunos-x64" "0.18.20" + "@esbuild/win32-arm64" "0.18.20" + "@esbuild/win32-ia32" "0.18.20" + "@esbuild/win32-x64" "0.18.20" + +escalade@^3.2.0: + version "3.2.0" + resolved "https://npm.imagekit.io/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" + integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== + +fsevents@~2.3.2: + version "2.3.3" + resolved "https://npm.imagekit.io/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + +gensync@^1.0.0-beta.2: + version "1.0.0-beta.2" + resolved "https://npm.imagekit.io/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" + integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== + +globals@^11.1.0: + version "11.12.0" + resolved "https://npm.imagekit.io/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" + integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== + +"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: + version "4.0.0" + resolved "https://npm.imagekit.io/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" + integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== + +jsesc@^3.0.2: + version "3.1.0" + resolved "https://npm.imagekit.io/jsesc/-/jsesc-3.1.0.tgz#74d335a234f67ed19907fdadfac7ccf9d409825d" + integrity sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA== + +json5@^2.2.3: + version "2.2.3" + resolved "https://npm.imagekit.io/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" + integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== + +loose-envify@^1.1.0: + version "1.4.0" + resolved "https://npm.imagekit.io/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" + integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== + dependencies: + js-tokens "^3.0.0 || ^4.0.0" + +lru-cache@^5.1.1: + version "5.1.1" + resolved "https://npm.imagekit.io/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920" + integrity sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w== + dependencies: + yallist "^3.0.2" + +magic-string@^0.27.0: + version "0.27.0" + resolved "https://npm.imagekit.io/magic-string/-/magic-string-0.27.0.tgz#e4a3413b4bab6d98d2becffd48b4a257effdbbf3" + integrity sha512-8UnnX2PeRAPZuN12svgR9j7M1uWMovg/CEnIwIG0LFkXSJJe4PdfUGiTGl8V9bsBHFUtfVINcSyYxd7q+kx9fA== + dependencies: + "@jridgewell/sourcemap-codec" "^1.4.13" + +ms@^2.1.3: + version "2.1.3" + resolved "https://npm.imagekit.io/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +nanoid@^3.3.11: + version "3.3.11" + resolved "https://npm.imagekit.io/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b" + integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w== + +node-releases@^2.0.19: + version "2.0.19" + resolved "https://npm.imagekit.io/node-releases/-/node-releases-2.0.19.tgz#9e445a52950951ec4d177d843af370b411caf314" + integrity sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw== + +picocolors@^1.1.1: + version "1.1.1" + resolved "https://npm.imagekit.io/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" + integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== + +postcss@^8.4.27: + version "8.5.4" + resolved "https://npm.imagekit.io/postcss/-/postcss-8.5.4.tgz#d61014ac00e11d5f58458ed7247d899bd65f99c0" + integrity sha512-QSa9EBe+uwlGTFmHsPKokv3B/oEMQZxfqW0QqNCyhpa6mB1afzulwn8hihglqAb2pOw+BJgNlmXQ8la2VeHB7w== + dependencies: + nanoid "^3.3.11" + picocolors "^1.1.1" + source-map-js "^1.2.1" + +react-dom@^18.0.0: + version "18.3.1" + resolved "https://npm.imagekit.io/react-dom/-/react-dom-18.3.1.tgz#c2265d79511b57d479b3dd3fdfa51536494c5cb4" + integrity sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw== + dependencies: + loose-envify "^1.1.0" + scheduler "^0.23.2" + +react-refresh@^0.14.0: + version "0.14.2" + resolved "https://npm.imagekit.io/react-refresh/-/react-refresh-0.14.2.tgz#3833da01ce32da470f1f936b9d477da5c7028bf9" + integrity sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA== + +react@^18.0.0: + version "18.3.1" + resolved "https://npm.imagekit.io/react/-/react-18.3.1.tgz#49ab892009c53933625bd16b2533fc754cab2891" + integrity sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ== + dependencies: + loose-envify "^1.1.0" + +rollup@^3.27.1: + version "3.29.5" + resolved "https://npm.imagekit.io/rollup/-/rollup-3.29.5.tgz#8a2e477a758b520fb78daf04bca4c522c1da8a54" + integrity sha512-GVsDdsbJzzy4S/v3dqWPJ7EfvZJfCHiDqe80IyrF59LYuP+e6U1LJoUqeuqRbwAWoMNoXivMNeNAOf5E22VA1w== + optionalDependencies: + fsevents "~2.3.2" + +scheduler@^0.23.2: + version "0.23.2" + resolved "https://npm.imagekit.io/scheduler/-/scheduler-0.23.2.tgz#414ba64a3b282892e944cf2108ecc078d115cdc3" + integrity sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ== + dependencies: + loose-envify "^1.1.0" + +semver@^6.3.1: + version "6.3.1" + resolved "https://npm.imagekit.io/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" + integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== + +source-map-js@^1.2.1: + version "1.2.1" + resolved "https://npm.imagekit.io/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" + integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== + +typescript@^5.0.0: + version "5.8.3" + resolved "https://npm.imagekit.io/typescript/-/typescript-5.8.3.tgz#92f8a3e5e3cf497356f4178c34cd65a7f5e8440e" + integrity sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ== + +update-browserslist-db@^1.1.3: + version "1.1.3" + resolved "https://npm.imagekit.io/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz#348377dd245216f9e7060ff50b15a1b740b75420" + integrity sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw== + dependencies: + escalade "^3.2.0" + picocolors "^1.1.1" + +vite@^4.0.0: + version "4.5.14" + resolved "https://npm.imagekit.io/vite/-/vite-4.5.14.tgz#2e652bc1d898265d987d6543ce866ecd65fa4086" + integrity sha512-+v57oAaoYNnO3hIu5Z/tJRZjq5aHM2zDve9YZ8HngVHbhk66RStobhb1sqPMIPEleV6cNKYK4eGrAbE9Ulbl2g== + dependencies: + esbuild "^0.18.10" + postcss "^8.4.27" + rollup "^3.27.1" + optionalDependencies: + fsevents "~2.3.2" + +yallist@^3.0.2: + version "3.1.1" + resolved "https://npm.imagekit.io/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" + integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== diff --git a/lerna.json b/lerna.json new file mode 100644 index 0000000..76cff5e --- /dev/null +++ b/lerna.json @@ -0,0 +1,9 @@ +{ + "version": "independent", + "npmClient": "yarn", + "packages": [ + "packages", + "examples/*" + ] + } + \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..3b7d3c9 --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "imagekit-video-player", + "private": true, + "version": "1.0.0", + "workspaces": [ + "packages", + "examples/*" + ], + "scripts": { + "build": "lerna run build", + "dev": "lerna run build && lerna run dev --parallel" + }, + "devDependencies": { + "lerna": "^8.1.8" + } +} \ No newline at end of file diff --git a/packages/javascript/index.ts b/packages/javascript/index.ts new file mode 100644 index 0000000..a70cb61 --- /dev/null +++ b/packages/javascript/index.ts @@ -0,0 +1,285 @@ +import { initVttRedirect } from './modules/seek-thumbnails/mockSeekThumbnailsVTT'; + +// ———————————————— +// MOCK SETUP (for local/dev/demo only) +// ———————————————— +initVttRedirect("https://ik.imagekit.io/zuqlyov9d/default.vtt?updatedAt=1747359261941&ik-s=92c20a03ddc26ab8efa179fc0ffa11dc132590e6"); + +import videojs from 'video.js'; +import PluginType from 'video.js/dist/types/plugin'; +import './modules/http-source-selector/plugin'; +import type { PlayerOptions, RemoteTextTrackOptions } from './interfaces'; +import type Player from 'video.js/dist/types/player'; +import type { SourceOptions } from './interfaces'; + +import { PlaylistManager } from './modules/playlist/playlist-manager'; +import { SeekThumbnailsManager } from './modules/seek-thumbnails/seek-thumbnails-manager'; +import { ChapterMarker, parseChaptersFromVTT } from './modules/chapters/chapterMarkerProgressBar'; +import './modules/recommendations-overlay/recommendations-overlay'; +import { overrideAddRemoteTextTrack } from './modules/subtitles/subtitles'; +import { ShoppableManager } from './modules/shoppable/shoppable-manager'; +import { prepareSource, normalizeInput, waitForVideoReady, preparePosterSrc, validateIKPlayerOptions, prepareChaptersVttSrc } from './utils'; + +const defaults: PlayerOptions = { + imagekitId: 'random_id', + floatingWhenNotVisible: null, + hideContextMenu: false, + logo: { showLogo: false, logoImageUrl: '', logoOnclickUrl: '' }, + seekThumbnails: true, + maxTries: 3, + videoTimeoutInMS: 55000, + playedEventPercents: [25, 50, 75, 100], +}; + +const Plugin = videojs.getPlugin('plugin') as typeof PluginType; +class ImageKitVideoPlayerPlugin extends Plugin { + private ikGlobalSettings_: PlayerOptions; + private currentSource_: SourceOptions | SourceOptions[] | null = null; + private originalCurrentSource_: SourceOptions | SourceOptions[] | null = null; + private playlistManger_?: PlaylistManager; + private seekThumbnailsManager_?: SeekThumbnailsManager; + private shoppableManager_?: ShoppableManager; + + + constructor(player: Player, options: PlayerOptions) { + super(player); + + this.ikGlobalSettings_ = videojs.mergeOptions(defaults, options); + try { + validateIKPlayerOptions(this.ikGlobalSettings_); + + overrideAddRemoteTextTrack(this.player); + this.overrideSrc(); + + this.playlistManger_ = new PlaylistManager(this.player, this.ikGlobalSettings_); + + this.player.on('loadstart', async () => { + + // INIT THUMBNAILS + // 1) If a SeekThumbnailsManager already exists, tear it down: + if (this.seekThumbnailsManager_) { + this.seekThumbnailsManager_.destroy(this.player); + this.seekThumbnailsManager_ = undefined; + } + + // 1a) If a ShoppableManager already exists, tear it down: + if (this.shoppableManager_) { + this.shoppableManager_.destroy(); + this.shoppableManager_ = undefined; + } + + const src = Array.isArray(this.currentSource_) + ? this.currentSource_[0] + : this.currentSource_; + + // 2) If seekThumbnails is enabled and currentSource_ is set, initialize a new mgr: + if (this.ikGlobalSettings_.seekThumbnails && src) { + + const mgr = await SeekThumbnailsManager.initSeekThumbnails( + this.player, + src, + this.ikGlobalSettings_ + ); + if (mgr) { + this.seekThumbnailsManager_ = mgr; + } + } + + // CHAPTER MARKERS & RECOMMENDATIONS + await this.initChapterMarkers(); + await this.initRecommendationsOverlay(); + + if (src && src.shoppable) { + this.shoppableManager_ = new ShoppableManager(this.player, src.shoppable); + } + + }); + } + catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + player.error(`ImageKitVideoPlayerPlugin: ${errorMessage}`); + } + } + private srcCallVersion = 0; + + private overrideSrc() { + const nativeSrc = this.player.src.bind(this.player); + + this.player.src = (raw: string | SourceOptions | Array | undefined) => { + // increment the version on each call + const myCallId = ++this.srcCallVersion; + + if (!raw) { + return nativeSrc(raw as any); + } + + // hide the big play button + const bigPlay = this.player.getChild('BigPlayButton'); + bigPlay && bigPlay.hide(); + // show the spinner + const spinner = this.player.getChild('LoadingSpinner'); + this.player.addClass('vjs-waiting'); + spinner?.el()?.setAttribute('aria-hidden', 'false'); + + // normalize the input + const inputs = normalizeInput(raw); + + // set originalCurrentSource_ for later use + this.originalCurrentSource_ = typeof inputs[0] === 'string' ? { src: inputs[0] } : { ...inputs[0] }; + + // prepare all of them in parallel + Promise.all(inputs.map(i => prepareSource(i, this.ikGlobalSettings_))) + .then(async (prepared: SourceOptions[]) => { + // **only** apply if thisCall is still the last one they requested + if (myCallId === this.srcCallVersion) { + const { maxTries, videoTimeoutInMS, delayInMS } = this.ikGlobalSettings_; + + await waitForVideoReady( + prepared[0].src, + maxTries!, + videoTimeoutInMS!, + delayInMS + ); + // store for later modules + this.currentSource_ = prepared.length > 1 ? prepared : prepared[0]; + // Video.js expects either a single object or an array + nativeSrc(prepared.length > 1 ? prepared as any : prepared[0] as any); + // setup subtitles if any from the currentSource_ + const textTracks = Array.isArray(this.currentSource_) + ? this.currentSource_[0].textTracks || [] + : this.currentSource_.textTracks || []; + if (textTracks.length) { + textTracks.forEach(track => this.player.addRemoteTextTrack(track as RemoteTextTrackOptions, false)); + } + // setup poster + const currentSource_ = Array.isArray(this.currentSource_) + ? this.currentSource_[0] + : this.currentSource_; + preparePosterSrc(currentSource_, this.ikGlobalSettings_).then( + poster => { + if (poster) { + this.player.poster(poster); + } + } + ).catch(err => { + this.player.error(err.message); + }); + + } + }) + .catch(err => { + // signing/build error + this.player.error(err.message); + }) + .finally(() => { + // hide the spinner and show the big play button + bigPlay && bigPlay.show(); + this.player.removeClass('vjs-waiting'); + spinner?.el()?.setAttribute('aria-hidden', 'true'); + }); + }; + } + + private async initChapterMarkers() { + if (!this.currentSource_) return; + let src = Array.isArray(this.currentSource_) ? this.currentSource_[0] : this.currentSource_; + if (!src.chapters) return; + + let chapterList: ChapterMarker[] = [] + if (typeof src.chapters === 'object' && 'url' in src.chapters) { + try { + const res = await fetch(src.chapters.url); + if (!res.ok) { + this.player.log.warn(`VTT fetch failed with status: (${res.status}); skipping chapters.`); + } + const data = await res.text(); + chapterList = parseChaptersFromVTT(data); + } catch (e) { + this.player.log.warn(`Failed to fetch chapters VTT: ${e}`); + return; + } + } else if (typeof src.chapters === 'object') { + // chapterList = Object.entries(src.chapters).map(([time, label]) => ({ time: Number(time), label })); + } else if (src.chapters === true) { + // if chapters is true, we assume it is a default vtt file + try { + const chaptersVttSrc = await prepareChaptersVttSrc(src, this.ikGlobalSettings_); + // const res = await fetch(chaptersVttSrc); + // mocking the fetch for now + const res = await fetch('https://ik.imagekit.io/zuqlyov9d/chapters.vtt'); + if (!res.ok) { + this.player.log.warn(`Default VTT fetch failed with status: (${res.status}); skipping chapters.`); + return; + } + // add chapters track + const chaptersTrack = this.player.addRemoteTextTrack({ + kind: 'chapters', + label: 'Chapters', + src: 'https://ik.imagekit.io/zuqlyov9d/chapters.vtt', + default: false, + }, false); + const data = await res.text(); + chapterList = parseChaptersFromVTT(data); + } catch (e) { + this.player.log.warn(`Failed to fetch default chapters VTT: ${e}`); + return; + } + } + + if (chapterList.length) { + + + const existing = this.player.getChild('ChapterMarkersProgressBarControl'); + if (existing) { + existing.dispose(); + } + this.player.addChild('ChapterMarkersProgressBarControl', { chapters: chapterList }); + } + } + + private async initRecommendationsOverlay() { + if (!this.currentSource_) return; + let src = Array.isArray(this.currentSource_) ? this.currentSource_[0] : this.currentSource_; + if (!src.recommendations) return; + + const overlay = this.player.getChild('RecommendationsOverlay'); + if (overlay) overlay.dispose(); + this.player.addChild('RecommendationsOverlay', { recommendations: src.recommendations }); + } + + public getCurrentSource() { + return this.currentSource_; + } + + public getOriginalCurrentSource() { + return this.originalCurrentSource_; + } + + public getPlaylistManager() { + return this.playlistManger_; + } +} + +videojs.registerPlugin('imagekitVideoPlayer', ImageKitVideoPlayerPlugin); +export default ImageKitVideoPlayerPlugin; + +export function videoPlayer( + element: string | HTMLElement, + options: PlayerOptions, + playerOptions: any = {} +) { + const player = videojs(element, { + ...playerOptions, + html5: { nativeTextTracks: false }, + plugins: { + ...(playerOptions.plugins ?? {}), + httpSourceSelector: { default: 'auto' }, + imagekitVideoPlayer: options + }, + }); + // @ts-ignore + player.httpSourceSelector(); + return player; +} + +export * from './interfaces'; \ No newline at end of file diff --git a/packages/javascript/interfaces/ABSOptions.ts b/packages/javascript/interfaces/ABSOptions.ts new file mode 100644 index 0000000..227fc00 --- /dev/null +++ b/packages/javascript/interfaces/ABSOptions.ts @@ -0,0 +1,6 @@ +export interface ABSOptions { + protocol: 'hls' | 'dash'; + /** Supported resolutions, e.g. [240,360,720,1080] */ + sr: number[]; + } + \ No newline at end of file diff --git a/packages/javascript/interfaces/Player.ts b/packages/javascript/interfaces/Player.ts new file mode 100644 index 0000000..ff7e4c5 --- /dev/null +++ b/packages/javascript/interfaces/Player.ts @@ -0,0 +1,41 @@ +import Player from 'video.js/dist/types/player'; +import type { Transformation }from '@imagekit/javascript' + + +export interface PlayerOptions { + /** Your ImageKit ID */ + imagekitId: string; + /** 'left' | 'right' floating thumbnail when scrolled out */ + floatingWhenNotVisible?: 'left' | 'right' | null; + /** Hide right-click context menu */ + hideContextMenu?: boolean; + /** Logo config */ + logo?: { + showLogo: boolean; + logoImageUrl: string; + logoOnclickUrl: string; + }; + /** Enable seek thumbnails */ + seekThumbnails?: boolean; + /** ABS (HLS/DASH) config */ + abs?: { + protocol: 'hls' | 'dash'; + sr: number[]; + }; + /** Global ImageKit transformations */ + transformation?: Array; + /** Retry attempts */ + maxTries?: number; + /** Timeout per try in ms */ + videoTimeoutInMS?: number; + /** Percent-based events */ + playedEventPercents?: number[]; + /** Time-based events (seconds) */ + playedEventTimes?: number[]; + /** Delay per try in ms */ + delayInMS?: number; + /** Signer function for generating signed url */ + signerFn?: (src: string) => Promise; +} + + diff --git a/packages/javascript/interfaces/Playlist.ts b/packages/javascript/interfaces/Playlist.ts new file mode 100644 index 0000000..644ebf7 --- /dev/null +++ b/packages/javascript/interfaces/Playlist.ts @@ -0,0 +1,11 @@ +export interface PlaylistOptions { + /** seconds delay to auto-advance, or false to disable */ + autoAdvance?: number | false; + /** loop playlist when it ends */ + repeat?: boolean; + /** seconds before end to show “next up” thumbnail, or true for default 10s */ + presentUpcoming?: boolean | number; + widgetProps?: { + direction?: 'vertical' | 'horizontal'; + }; +} diff --git a/packages/javascript/interfaces/Poster.ts b/packages/javascript/interfaces/Poster.ts new file mode 100644 index 0000000..de28dd1 --- /dev/null +++ b/packages/javascript/interfaces/Poster.ts @@ -0,0 +1,7 @@ +import type { Transformation }from '@imagekit/javascript' + +export interface PosterOptions { + /** If omitted, Video.js / ImageKit generates a default thumbnail */ + src?: string; + transformation?: Transformation[]; +} diff --git a/packages/javascript/interfaces/Shoppable.ts b/packages/javascript/interfaces/Shoppable.ts new file mode 100644 index 0000000..637ac55 --- /dev/null +++ b/packages/javascript/interfaces/Shoppable.ts @@ -0,0 +1,39 @@ +import type { Transformation }from '@imagekit/javascript' + +export interface Hotspot { + time: string; // e.g. "00:02" + x: string; // e.g. "50%" + y: string; // e.g. "50%" + tooltipPosition?: 'left' | 'right' | 'top' | 'bottom'; + clickUrl: string; +} + +export interface InteractionProps { + action: 'overlay' | 'seek' | 'goto' | 'switch'; + /** in seconds or true/false for pause behavior */ + pause?: number | boolean; + args?: { + time?: string; + url?: string; + }; +} + +export interface ProductProps { + productId: number; + productName: string; + imageUrl: string; + highlightTime?: { start: number; end: number }; + hotspots?: Hotspot[]; + onHover?: InteractionProps; + onClick?: InteractionProps; +} + +export interface ShoppableProps { + autoClose?: number; + products: ProductProps[]; + showPostPlayOverlay?: boolean; + startState?: 'closed' | 'open' | 'openOnPlay'; + toggleIconUrl?: string; + transformation?: Transformation[]; + width?: number; // percentage of player width +} diff --git a/packages/javascript/interfaces/SourceOptions.ts b/packages/javascript/interfaces/SourceOptions.ts new file mode 100644 index 0000000..e73212e --- /dev/null +++ b/packages/javascript/interfaces/SourceOptions.ts @@ -0,0 +1,64 @@ +import type { PosterOptions } from './Poster'; +import type { ABSOptions } from './ABSOptions'; +import type { Transformation } from '@imagekit/javascript' +import type { RemoteTextTrackOptions } from './TextTrack'; +import type { ShoppableProps } from './Shoppable'; + +export interface VideoInfo { + title?: string; + subtitle?: string; + description?: string; +} + +/** + * The options object you can pass to `player.src({...})`. + */ +export interface SourceOptions { + + /** + * The source URL of the video. + */ + src: string; + /** + * Chapters configuration. + * - `true` to auto-generate (AI) chapters + * - `{ url: string }` to load from a VTT file + * - `{ [timeInSec]: title }` to define manually + */ + chapters?: boolean | { url: string } | Record; + + /** + * Display metadata like title/subtitle in playlists or recommendations. + */ + info?: VideoInfo; + + /** + * Poster image config (overrides any global setting). + */ + poster?: PosterOptions; + + /** + * Adaptive-bitrate streaming config (HLS or MPEG-DASH). + */ + abs?: ABSOptions; + + /** + * One-or-more ImageKit transformation steps to apply to this source. + */ + transformation?: Transformation[]; + + /** + * A set of up to four recommendations to show in the overlay when this video ends. + */ + recommendations?: SourceOptions[]; + + /** + * Shoppable video configuration for this source. + */ + shoppable?: ShoppableProps; + + /** + * One-or-more text tracks for captions/subtitles on this source. + */ + textTracks?: RemoteTextTrackOptions[]; +} diff --git a/packages/javascript/interfaces/TextTrack.ts b/packages/javascript/interfaces/TextTrack.ts new file mode 100644 index 0000000..fb8552c --- /dev/null +++ b/packages/javascript/interfaces/TextTrack.ts @@ -0,0 +1,20 @@ +// Core WebVTT options (existing Video.js API) +export interface TextTrackOptions { + kind?: 'subtitles' | 'captions'; + src?: string; + srclang?: string; + label?: string; + default?: boolean; + maxWords?: number; + wordHighlight?: boolean; +} + +export interface AutoGeneratedTextTrackOptions { + autoGenerateSubtitles: true; + default?: boolean; + translate?: Array<{ langCode: string; label?: string; default?: boolean }>; + maxWords?: number; + wordHighlight?: boolean; +}; + +export type RemoteTextTrackOptions = TextTrackOptions | AutoGeneratedTextTrackOptions \ No newline at end of file diff --git a/packages/javascript/interfaces/index.ts b/packages/javascript/interfaces/index.ts new file mode 100644 index 0000000..6351340 --- /dev/null +++ b/packages/javascript/interfaces/index.ts @@ -0,0 +1,9 @@ +export * from './ABSOptions'; +export * from './TextTrack'; +export * from './Poster'; +export * from './Playlist'; +export * from './Shoppable'; +export * from './Player'; +export * from './SourceOptions'; + +export type { Transformation } from '@imagekit/javascript'; diff --git a/packages/javascript/modules/chapters/chapter.css b/packages/javascript/modules/chapters/chapter.css new file mode 100644 index 0000000..61411b3 --- /dev/null +++ b/packages/javascript/modules/chapters/chapter.css @@ -0,0 +1,51 @@ +/* chapters.css */ + +/* 1) The chapter‐segment overlay (invisible except for tooltip & boundary) */ +.vjs-chapter-segment { + position: absolute; + top: 0; /* align to top of .vjs-progress-holder */ + height: 100%; /* fill the progress‐bar’s height */ + /* No background color here; the bar itself shows played/unplayed states */ + cursor: pointer; + pointer-events: auto; /* allow hover/click inside segment */ + z-index: 2; /* above the progress‐bar fill, below controls */ + } + + /* 2) The vertical boundary line between chapters */ + .vjs-chapter-boundary { + position: absolute; + top: 0; + height: 100%; + width: 2px; /* a thin separator */ + background-color: #cccccc; /* light‐grey line—tweak as needed */ + pointer-events: none; /* let the segment beneath receive mouse events */ + z-index: 3; /* sit slightly above the segment */ + } + + /* 3) Tooltip inside each chapter segment */ + .vjs-chapter-segment-tooltip { + position: absolute; + /* Instead of bottom: 100%; use something like: */ + bottom: calc(100% + 20px); + left: 0; /* will be shifted horizontally in JS */ + transform: translateX(-50%); + display: none; + background: rgba(0, 0, 0, 0.85); + color: #fff; + font-size: 12px; + line-height: 1.2; + padding: 4px 8px; + border-radius: 4px; + white-space: nowrap; + pointer-events: none; + /* no arrow (::after) needed */ + z-index: 2; /* the stacking order doesn’t matter here */ + opacity: 0; + transition: opacity 0.1s ease; + } + /* Show + fade‐in on hover */ + .vjs-chapter-segment:hover > .vjs-chapter-segment-tooltip { + display: block; + opacity: 1; + } + \ No newline at end of file diff --git a/packages/javascript/modules/chapters/chapterMarkerProgressBar.ts b/packages/javascript/modules/chapters/chapterMarkerProgressBar.ts new file mode 100644 index 0000000..fb04b7e --- /dev/null +++ b/packages/javascript/modules/chapters/chapterMarkerProgressBar.ts @@ -0,0 +1,224 @@ +import videojs from 'video.js'; +import type Player from 'video.js/dist/types/player'; + +const Component = videojs.getComponent('Component'); + +export interface ChapterMarker { + startTime: number; + endTime: number; + label: string; +} + +interface ChapterMarkersProgressBarControlOptions { + chapters: ChapterMarker[]; + children?: any[]; + className?: string; +} + +/** + * The class for chapter markers + */ +class ChapterMarkersProgressBarControl extends Component { + /** + * Constructor for class + * + * @param {Player|Object} player The player + * @param {options|Object} options player options + */ + constructor(player: Player, options: ChapterMarkersProgressBarControlOptions) { + + super(player, options); + + if (options.chapters === undefined) { + options.chapters = []; + } + + player.ready(() => { + ChapterMarkersProgressBarControl.addMarkers(options.chapters, player); + }); + } + + /** + * addMarkers + * + * @param {chapters|Object} chapters chapters array + * @param {videoDuration|int} videoDuration video duration + */ + // static addMarkers(chapters: ChapterMarker[], player: Player) { + // const videoDuration = player.duration(); + // if (!videoDuration || videoDuration === 0) { + // return; + // } + // const iMax = chapters.length; + // var playerContainer = player.el(); + // const playheadWell = playerContainer.getElementsByClassName('vjs-progress-holder')[0]; + + // // Loop over each cue point, dynamically create a div for each + // // then place in div progress bar + // for (let i = 0; i < iMax; i++) { + + // if(chapters[i].time < 0 || chapters[i].time > videoDuration) + // { + // continue; + // } + + // const elem = document.createElement('div'); + + // elem.className = 'vjs-viostream-marker'; + // elem.id = 'cp' + i; + + // const percentage = (chapters[i].time / videoDuration) * 100; + // elem.style.left = percentage + '%'; + + // const spanToolTip = document.createElement('span'); + + // spanToolTip.className = 'tooltiptext'; + // spanToolTip.innerHTML = chapters[i].label; + // elem.appendChild(spanToolTip); + + // playheadWell.appendChild(elem); + // } + // } + + /** + * For each chapter: + * 1) Create a transparent segment div (.vjs-chapter-segment) spanning [startTime,endTime]. + * 2) Inside it, create a hidden tooltip div that will follow the mouse. + * 3) Also, create a vertical boundary line (.vjs-chapter-boundary) at startTime (except the first chapter). + */ + static addMarkers(chapters: ChapterMarker[], player: Player) { + const duration = player.duration(); + if (!duration || duration <= 0) return; + + const playheadWell = player.el().getElementsByClassName('vjs-progress-holder')[0]; + if (!playheadWell) return; + + chapters.forEach((chapter, idx) => { + // 1) Validate times + if ( + chapter.startTime < 0 || + chapter.endTime > duration || + chapter.endTime <= chapter.startTime + ) { + return; + } + + // 2) Compute left% and width% for this chapter + const leftPct = (chapter.startTime / duration) * 100; + const widthPct = ((chapter.endTime - chapter.startTime) / duration) * 100; + + // 3) Create the “segment” div + const segment = document.createElement('div'); + segment.className = 'vjs-chapter-segment'; + segment.style.left = leftPct + '%'; + segment.style.width = widthPct + '%'; + + // 4) Create a tooltip div inside the segment (hidden by default) + const tooltip = document.createElement('div'); + tooltip.className = 'vjs-chapter-segment-tooltip'; + tooltip.innerText = chapter.label; + segment.appendChild(tooltip); + + // 5) Attach mouse events so the tooltip follows the cursor + segment.addEventListener('mousemove', (e: MouseEvent) => { + // e.offsetX is x-coordinate within this segment + const relativeX = e.offsetX; + // Move tooltip so its center is under the cursor + tooltip.style.left = relativeX + 'px'; + }); + segment.addEventListener('mouseenter', () => { + tooltip.style.display = 'block'; + }); + segment.addEventListener('mouseleave', () => { + tooltip.style.display = 'none'; + }); + + // 6) Append the segment into the progress bar + playheadWell.appendChild(segment); + + // 7) If this is NOT the first chapter, draw a boundary line at startTime + if (idx > 0) { + const boundary = document.createElement('div'); + boundary.className = 'vjs-chapter-boundary'; + boundary.style.left = leftPct + '%'; + playheadWell.appendChild(boundary); + } + }); +} + + + + /** On dispose(), remove all .vjs-chapter-segment elements */ + dispose() { + const playheadWell = this.player().el().querySelector('.vjs-progress-holder') + if (playheadWell) { + playheadWell.querySelectorAll('.vjs-chapter-segment').forEach((el) => el.remove()); + playheadWell.querySelectorAll('.vjs-chapter-boundary').forEach((el) => el.remove()); } + super.dispose() + } +} + +videojs.registerComponent('ChapterMarkersProgressBarControl', ChapterMarkersProgressBarControl); + +export default ChapterMarkersProgressBarControl; + +/** + * Parses a WebVTT string into an array of chapter marker objects. + * + * @param vttData - Raw VTT file string content + * @returns An array of { time, label } objects + */ + +/** + * Parses a WebVTT string into an array of chapter objects, + * each of which contains startTime, endTime, and label. + */ +export function parseChaptersFromVTT(vttData: string): ChapterMarker[] { + const chapters: ChapterMarker[] = [] + + // 1) Normalize newlines and split into cue blocks + const rawBlocks = vttData.replace(/\r\n|\r|\n/g, '\n').split(/\n{2,}/) + + for (const block of rawBlocks) { + const lines = block.trim().split('\n') + // A valid cue block has a line containing "-->" + const timeLine = lines.find((l) => /-->/.test(l)) + if (!timeLine) continue + + // 2) Extract start and end timestamps (like "00:00:05.000 --> 00:00:12.000") + const [startRaw, endRaw] = timeLine.split('-->').map((s) => s.trim()) + const startTime = timestampToSeconds(startRaw) + const endTime = timestampToSeconds(endRaw) + + // 3) The next line(s) after the timeLine are the chapter label + // (If you had multiple lines of text, you could join them.) + const labelLine = lines[1] || '' + const label = labelLine.trim() + + // Only add if label is non-empty and times are valid + if ( + label && + !isNaN(startTime) && + !isNaN(endTime) && + endTime > startTime + ) { + chapters.push({ startTime, endTime, label }) + } + } + + return chapters +} + +/** Convert "HH:MM:SS.mmm" → seconds */ +function timestampToSeconds(timestamp: string): number { + // e.g. "00:00:05.000" + const [hh, mm, ssMs] = timestamp.split(':') + if (!hh || !mm || !ssMs) return NaN + + const [ss, ms] = ssMs.split('.') + const hours = parseInt(hh, 10) + const minutes = parseInt(mm, 10) + const seconds = parseInt(ss, 10) + const millis = parseInt(ms || '0', 10) + return hours * 3600 + minutes * 60 + seconds + millis / 1000 +} \ No newline at end of file diff --git a/packages/javascript/modules/http-source-selector/components/SourceMenuButton.js b/packages/javascript/modules/http-source-selector/components/SourceMenuButton.js new file mode 100644 index 0000000..56e01ff --- /dev/null +++ b/packages/javascript/modules/http-source-selector/components/SourceMenuButton.js @@ -0,0 +1,90 @@ +import videojs from 'video.js'; +import SourceMenuItem from './SourceMenuItem'; + +const MenuButton = videojs.getComponent('MenuButton'); + +class SourceMenuButton extends MenuButton { + constructor(player, options) { + super(player, options); + + const qualityLevels = this.player().qualityLevels(); + + // Handle options: default bias + if (options && options.default) { + if (options.default === 'low') { + qualityLevels.forEach((level, i) => { + level.enabled = (i === 0); + }); + } else if (options.default === 'high') { + qualityLevels.forEach((level, i) => { + level.enabled = (i === qualityLevels.length - 1); + }); + } + } + + // Bind update to qualityLevels changes + this.player().qualityLevels().on(['change', 'addqualitylevel'], videojs.bind(this, this.update)); + } + + createEl() { + return videojs.dom.createEl('div', { + className: 'vjs-http-source-selector vjs-menu-button vjs-menu-button-popup vjs-control vjs-button' + }); + } + + buildCSSClass() { + return super.buildCSSClass() + ' vjs-icon-cog'; + } + + update() { + return super.update(); + } + + createItems() { + const menuItems = []; + const levels = this.player().qualityLevels(); + const labels = []; + + for (let i = 0; i < levels.length; i++) { + const index = levels.length - (i + 1); + const selected = (index === levels.selectedIndex); + + // Display height or bitrate + let label = `${index}`; + let sortVal = index; + if (levels[index].height) { + label = `${levels[index].height}p`; + sortVal = parseInt(levels[index].height, 10); + } else if (levels[index].bitrate) { + label = `${Math.floor(levels[index].bitrate / 1e3)} kbps`; + sortVal = parseInt(levels[index].bitrate, 10); + } + + if (labels.includes(label)) { + continue; + } + labels.push(label); + + menuItems.push(new SourceMenuItem(this.player_, { label, index, selected, sortVal })); + } + + if (levels.length > 1) { + menuItems.push(new SourceMenuItem(this.player_, { + label: 'Auto', + index: levels.length, + selected: false, + sortVal: 99999 + })); + } + + // Sort menu items descending by sortVal (Auto at top due to 99999) + menuItems.sort((a, b) => b.options_.sortVal - a.options_.sortVal); + + return menuItems; + } +} + +// Register component with Video.js +videojs.registerComponent('SourceMenuButton', SourceMenuButton); + +export default SourceMenuButton; \ No newline at end of file diff --git a/packages/javascript/modules/http-source-selector/components/SourceMenuItem.js b/packages/javascript/modules/http-source-selector/components/SourceMenuItem.js new file mode 100644 index 0000000..a6a9901 --- /dev/null +++ b/packages/javascript/modules/http-source-selector/components/SourceMenuItem.js @@ -0,0 +1,40 @@ +import videojs from 'video.js'; +const MenuItem = videojs.getComponent('MenuItem'); +const Component = videojs.getComponent('Component'); + +class SourceMenuItem extends MenuItem +{ + constructor(player, options) { + options.selectable = true; + options.multiSelectable = false; + + super(player, options); + } + + handleClick() { + var selected = this.options_; + // console.log("Changing quality to:", selected.label); + super.handleClick(); + + var levels = this.player().qualityLevels(); + for(var i = 0; i < levels.length; i++) { + if (selected.index == levels.length) { + // If this is the Auto option, enable all renditions for adaptive selection + levels[i].enabled = true; + } else if (selected.index == i) { + levels[i].enabled = true; + } else { + levels[i].enabled = false; + } + } + } + + update() { + var selectedIndex = this.player().qualityLevels().selectedIndex; + this.selected(this.options_.index == selectedIndex); + } +} + + +Component.registerComponent('SourceMenuItem', SourceMenuItem); +export default SourceMenuItem; \ No newline at end of file diff --git a/packages/javascript/modules/http-source-selector/plugin.js b/packages/javascript/modules/http-source-selector/plugin.js new file mode 100644 index 0000000..10b567f --- /dev/null +++ b/packages/javascript/modules/http-source-selector/plugin.js @@ -0,0 +1,89 @@ +import videojs from 'video.js'; + +import SourceMenuButton from './components/SourceMenuButton'; +import SourceMenuItem from './components/SourceMenuItem'; + +// Default options for the plugin. +const defaults = {}; + +// Cross-compatibility for Video.js 5 and 6. +const registerPlugin = videojs.registerPlugin || videojs.plugin; +// const dom = videojs.dom || videojs; + +/** +* Function to invoke when the player is ready. +* +* This is a great place for your plugin to initialize itself. When this +* function is called, the player will have its DOM and child components +* in place. +* +* @function onPlayerReady +* @param {Player} player +* A Video.js player object. +* +* @param {Object} [options={}] +* A plain object containing options for the plugin. +*/ +const onPlayerReady = (player, options) => +{ + player.addClass('vjs-http-source-selector'); + // console.log("videojs-http-source-selector initialized!"); + + // console.log("player.techName_:"+player.techName_); + //This plugin only supports level selection for HLS playback + if(player.techName_ != 'Html5') + { + return false; + } + + /** + * + * We have to wait for the manifest to load before we can scan renditions for resolutions/bitrates to populate selections + * + **/ + player.on(['loadedmetadata'], function(e) + { + var qualityLevels = player.qualityLevels(); + // videojs.log('loadmetadata event'); + // hack for plugin idempodency... prevents duplicate menubuttons from being inserted into the player if multiple player.httpSourceSelector() functions called. + if(player.videojs_http_source_selector_initialized == 'undefined' || player.videojs_http_source_selector_initialized == true) + { + // console.log("player.videojs_http_source_selector_initialized == true"); + } + else + { + // console.log("player.videojs_http_source_selector_initialized == false") + player.videojs_http_source_selector_initialized = true; + var controlBar = player.controlBar, + fullscreenToggle = controlBar.getChild('fullscreenToggle').el(); + controlBar.el().insertBefore(controlBar.addChild('SourceMenuButton').el(), fullscreenToggle); + } + }); +}; + + /** + * A video.js plugin. + * + * In the plugin function, the value of `this` is a video.js `Player` + * instance. You cannot rely on the player being in a "ready" state here, + * depending on how the plugin is invoked. This may or may not be important + * to you; if not, remove the wait for "ready"! + * + * @function httpSourceSelector + * @param {Object} [options={}] + * An object of options left to the plugin author to define. + */ + const httpSourceSelector = function(options) { + this.ready(() => { + onPlayerReady(this, videojs.mergeOptions(defaults, options)); + //this.getChild('controlBar').addChild('SourceMenuButton', {}); + }); + + videojs.registerComponent('SourceMenuButton', SourceMenuButton); + videojs.registerComponent('SourceMenuItem', SourceMenuItem); + }; + + // Register the plugin with video.js. + registerPlugin('httpSourceSelector', httpSourceSelector); + + export default httpSourceSelector; \ No newline at end of file diff --git a/packages/javascript/modules/http-source-selector/plugin.scss b/packages/javascript/modules/http-source-selector/plugin.scss new file mode 100644 index 0000000..173f634 --- /dev/null +++ b/packages/javascript/modules/http-source-selector/plugin.scss @@ -0,0 +1,9 @@ +// Sass for videojs-http-source-selector + +.video-js { + + // This class is added to the video.js element by the plugin by default. + &.vjs-http-source-selector { + display: block; + } + } \ No newline at end of file diff --git a/packages/javascript/modules/playlist/auto-advance.ts b/packages/javascript/modules/playlist/auto-advance.ts new file mode 100644 index 0000000..c8ff28e --- /dev/null +++ b/packages/javascript/modules/playlist/auto-advance.ts @@ -0,0 +1,66 @@ +import type Player from 'video.js/dist/types/player'; + +/** + * Calls advanceCallback when `ended` fires, after a delay. + * Delay = false → no auto-advance + * Delay = 0 → immediate + * Delay > 0 → seconds to wait + */ +export class AutoAdvance { + private player_: Player; + private advanceCallback_: () => void; + private delay_: number | null = null; + private timeoutId_: number | null = null; + + constructor(player: Player, advanceCallback: () => void) { + this.player_ = player; + this.advanceCallback_ = advanceCallback; + } + + setDelay(seconds: number | false): void { + this.fullReset(); + + if (seconds === false) { + // no auto-advance + return; + } + + if (typeof seconds !== 'number' || seconds < 0 || !isFinite(seconds)) { + return; + } + + this.delay_ = seconds; + this.player_.on('ended', this.startTimeout_); + } + + getDelay(): number | null { + return this.delay_; + } + + private startTimeout_ = (): void => { + this.clearTimeout_(); + if (this.delay_ == null) { return; } + + // if user manually restarts, cancel + this.player_.one('play', this.clearTimeout_); + + this.timeoutId_ = window.setTimeout(() => { + this.advanceCallback_(); + this.clearTimeout_(); + }, this.delay_ * 1000); + }; + + private clearTimeout_ = (): void => { + if (this.timeoutId_ != null) { + clearTimeout(this.timeoutId_); + this.timeoutId_ = null; + this.player_.off('play', this.clearTimeout_); + } + }; + + fullReset(): void { + this.clearTimeout_(); + this.player_.off('ended', this.startTimeout_); + this.delay_ = null; + } +} \ No newline at end of file diff --git a/packages/javascript/modules/playlist/playlist-manager.ts b/packages/javascript/modules/playlist/playlist-manager.ts new file mode 100644 index 0000000..4e9589c --- /dev/null +++ b/packages/javascript/modules/playlist/playlist-manager.ts @@ -0,0 +1,443 @@ +import videojs from 'video.js'; +import type Player from 'video.js/dist/types/player'; + +import { Playlist } from './playlist'; +import { AutoAdvance } from './auto-advance'; +import { PlaylistMenu } from './playlist-menu'; +import type { SourceOptions, PlaylistOptions, PlayerOptions } from '../../interfaces'; +import { isIndexInBounds } from './utils'; + +// detect pointer-events support +const supportsCssPointerEvents = (() => { + const el = document.createElement('x'); + el.style.cssText = 'pointer-events:auto'; + return el.style.pointerEvents === 'auto'; +})(); + +// Exported for testing purposes +export const log = videojs.log.createLogger('videojs-playlist'); + +export class PlaylistManager { + private player_: Player; + private playlist_: Playlist; + private autoAdvance_: AutoAdvance; + private playlistMenu?: PlaylistMenu; + private playerOptions_: PlayerOptions; + + constructor(player: Player, playerOptions: PlayerOptions) { + this.player_ = player; + this.playerOptions_ = playerOptions; + this.playlist_ = new Playlist({ + onError: msg => player.error(msg), + onWarn: msg => player.log.warn(msg) + }); + this.autoAdvance_ = new AutoAdvance(this.player_, this.playNext_); + + + /** + * @method playlist + * @param {Array} sources - Array of sources to load + * @param {Object} opts - Options for the playlist + * @returns {PlaylistManager} - The playlist manager instance + * @description Loads a playlist and sets up related functionality. + */ + // Add playlist method to player + (player as any).playlist = ({ + sources, + options: opts + }: { + sources?: SourceOptions[], + options?: PlaylistOptions + }): PlaylistManager => { + + // Wrap the player's outer element in a container + const playerEl = this.player_.el(); + const wrapper = document.createElement('div'); + wrapper.className = 'player-container'; + playerEl.parentNode?.insertBefore(wrapper, playerEl); + wrapper.appendChild(playerEl); + + // Load the playlist if provided + if (sources && Array.isArray(sources)) { + this.loadPlaylist(Playlist.from(sources, { + onError: msg => player.error(msg), + onWarn: msg => player.log.warn(msg) + })); + } + this.configure(opts || {}); + this.initMenu_(opts || {}); + return this; + }; + + // 2) Create the (empty) menu on startup + // this.initMenu_({}); + } + + /** @private Initialize or re-initialize the UI component */ + private initMenu_(opts: PlaylistOptions) { + // tear down old + this.playlistMenu?.dispose(); + + // UI defaults + const defaults = { + className: 'vjs-playlist', + playOnSelect: true, + supportsCssPointerEvents + }; + + // merge in widgetProps + const uiOpts = videojs.mergeOptions(defaults, opts.widgetProps || {}) as any; + + // determine container: + // 1) user-provided el, if valid + // 2) first empty .className in DOM + // 3) fallback to player.el() + let container: HTMLElement; + if (uiOpts.el && videojs.dom.isEl(uiOpts.el)) { + container = uiOpts.el; + } else { + const found = document.querySelector(`.${uiOpts.className}`) as HTMLElement; + if (found && found.childElementCount === 0) { + container = found; + } else { + // container = this.player_.el(); + // Fallback: create a new div next to the player + const wrapper = this.player_.el().parentElement!; + container = document.createElement('div'); + container.className = uiOpts.className; + wrapper.appendChild(container); + } + } + uiOpts.el = container; + + // instantiate & expose + const menu = new PlaylistMenu(this.player_, this.playlist_, {}, this.playerOptions_); + + // **NEW**: actually attach it + // .el() is the Component API for "get my root element" + container.appendChild(menu.el()); + + this.playlistMenu = menu; + (this.player_ as any).playlistMenu = menu; + } + + /** Configure looping & auto-advance */ + public configure(opts: PlaylistOptions = {}) { + // repeat + if (opts.repeat) { + this.playlist_.enableRepeat(); + } else { + this.playlist_.disableRepeat(); + } + // autoAdvance + if (opts.autoAdvance === false) { + this.autoAdvance_.setDelay(false); + } else if (typeof opts.autoAdvance === 'number') { + this.autoAdvance_.setDelay(opts.autoAdvance); + } + } + + /** Load a new playlist array */ + public setPlaylistItems(sources: SourceOptions[]) { + this.playlist_.setItems(sources); + this.loadFirstItem(); + } + + /** Get the current playlist array */ + public getItems(): SourceOptions[] { + return this.playlist_.getItems(); + } + + /** Advance to next item */ + public playNext(): void { + const next = this.playlist_.getNextIndex(); + if (next < 0) { return; } + this.playAtIndex(next); + } + + /** Play a specific index */ + public playAtIndex(index: number): void { + this.playlist_.setCurrentIndex(index); + + // 1) Kick off your async overrideSrc(...) logic: + this.player_.src(this.playlist_.getCurrentItem()); + + // 2) Wait for Video.js to actually begin loading that new source: + this.player_.one('loadedmetadata', () => { + this.player_.play(); + }); + } + + /** + * Loads a playlist and sets up related functionality. + * + * @param {Playlist} playlist - The playlist to load. + */ + public loadPlaylist(playlist: Playlist) { + // Clean up any existing playlist + this.unloadPlaylist(); + + this.playlist_ = playlist; + this.autoAdvance_ = new AutoAdvance(this.player_, this.playNext_); + + this.setupEventForwarding_(); + + // Begin handling non-playlist source changes. + this.player_.on('loadstart', this.handleSourceChange_); + } + + /** + * Unloads the current playlist and associated functionality. + */ + public unloadPlaylist() { + if (this.playlist_) { + this.playlist_.reset(); + this.cleanupEventForwarding_(); + } + + if (this.autoAdvance_) { + this.autoAdvance_.fullReset(); + } + + // Stop handling non-playlist source changes + this.player_.off('loadstart', this.handleSourceChange_); + } + + /** +* Retrieves the currently loaded playlist object +* +* @return {Playlist|null} The current Playlist instance, or null if one is not loaded. +*/ + public getPlaylist(): Playlist | null { + return this.playlist_; + } + + /* + * Gets or sets the autoAdvance configuration for the playlist + Gets or sets the autoAdvance configuration for the playlist. + +A positive integer delay value sets the delay in seconds that the player will wait before playing the next video. + +A value of false cancels auto advance. (To move to the next video use playNext). + +A value of 0 causes the next video to play immediately after the previous one finishes. + */ + autoAdvanceDelay(delayInSeconds?: number | false): number | null | void { + if (delayInSeconds === undefined) { + return this.autoAdvance_.getDelay(); + } + + this.autoAdvance_.setDelay(delayInSeconds); + return; + } + + /** + * Loads a specific playlist item by index. + * + * @param {number} index - The index of the item to load. + * @return {boolean} True if the item was loaded successfully, false otherwise. + */ + loadPlaylistItem(index: number): boolean { + const items = this.playlist_.getItems(); + + if (!isIndexInBounds(items, index)) { + log.error('Index is out of bounds.'); + return false; + } + + this.loadItem_(items[index]); + this.playlist_.setCurrentIndex(index); + + return true; + } + + /** + * Loads the first item in the playlist. + * + * @return {boolean} True if the first item was loaded successfully, false otherwise. + */ + loadFirstItem(): boolean { + return this.loadPlaylistItem(0); + } + + /** + * Loads the last item in the playlist. + * + * @return {boolean} True if the last item was loaded successfully, false otherwise. + */ + loadLastItem(): boolean { + const lastIndex = this.playlist_.getLastIndex(); + + return this.loadPlaylistItem(lastIndex); + } + + /** + * Loads the next item in the playlist. + * @return {boolean} True if the next item was loaded successfully, false otherwise. + */ + loadNextItem(): boolean { + const nextIndex = this.playlist_.getNextIndex(); + + if (nextIndex === -1) { + return false; + } + + return this.loadPlaylistItem(nextIndex); + } + + /** + * Loads the previous item in the playlist. + * + * @return {boolean} True if the previous item was loaded successfully, false otherwise. + */ + loadPreviousItem() { + const previousIndex = this.playlist_.getPreviousIndex(); + + if (previousIndex === -1) { + return false; + } + + return this.loadPlaylistItem(previousIndex); + } + + /** + * Loads a specific playlist item. + * @param {SourceOptions} item - The playlist item to load. + * @private + */ + private loadItem_(item: SourceOptions) { + this.player_.trigger('beforeplaylistitem', item); + + // Remove any textTracks from a previous item + this.clearExistingItemTextTracks_(); + + // Set the source for the player + this.player_.src(item); + + this.player_.ready(() => { + this.addItemTextTracks_(item); + this.player_.trigger('playlistitem', item); + }); + } + + /** + * Sets up event forwarding from the playlist to the player. + * + * @private + */ + private setupEventForwarding_() { + const playlistEvents = ['playlistchange', 'playlistadd', 'playlistremove', 'playlistsorted']; + + playlistEvents.forEach((eventType) => this.playlist_.on(eventType, this.handlePlaylistEvent_)); + } + + /** + * Cleans up event forwarding from the playlist to the player. + * + * @private + */ + private cleanupEventForwarding_() { + const playlistEvents = ['playlistchange', 'playlistadd', 'playlistremove', 'playlistsorted']; + + playlistEvents.forEach((eventType) => this.playlist_.off(eventType, this.handlePlaylistEvent_)); + } + + /** + * Handles playlist events and forwards them to the player. + * + * @param {Event} event - The playlist event to handle. + * @private + */ + private handlePlaylistEvent_ = (event: Event) => { + this.player_.trigger(event); + }; + + /** + * Plays the next item in the playlist + * + * @private + */ + playNext_ = () => { + const loadedNext = this.loadNextItem(); + + if (loadedNext) { + // 2) Wait for loadstart or loadedmetadata, then play: + this.player_.one('loadstart', () => { + this.player_.play(); + }); + + } + }; + + /** + * Clears text tracks of the currently loaded item. + * + * @private + */ + private clearExistingItemTextTracks_() { + // @todo: this should be available in videojs + // @ts-ignore + const textTracks = this.player_.remoteTextTracks(); + // @ts-ignore + let i = textTracks && textTracks.length || 0; + + // This uses a `while` loop rather than `forEach` because the + // `TextTrackList` object is a live DOM list (not an array). + while (i--) { + // @ts-ignore + this.player_.removeRemoteTextTrack(textTracks[i]); + } + } + + /** + * Adds text tracks for a playlist item. + * + * @param {Object} item - The playlist item. + * @private + */ + private addItemTextTracks_(item: SourceOptions) { + if (item.textTracks) { + item.textTracks.forEach((track) => this.player_.addRemoteTextTrack(track)); + } + } + + /** + * Handles changes to the player's source. + * + * @private + */ + private handleSourceChange_ = () => { + //@ts-ignore + const currentSrc = this.player_.imagekitVideoPlayer().getOriginalCurrentSource(); + + if (!this.isSourceInPlaylist_(currentSrc)) { + this.handleNonPlaylistSource_(); + } + }; + + /** + * Checks if the current source is in the playlist. + * + * @param {SourceOptions} src - The source URL to check. + * @return {boolean} True if the source is in the playlist, false otherwise. + * @private + */ + private isSourceInPlaylist_(src: SourceOptions): boolean { + const itemList = this.playlist_.getItems(); + return itemList.some((item) => { + // do a deep comparison of the source object + return JSON.stringify(item) === JSON.stringify(src); + } + ); + } + + /** + * Handles playback when the current source is not in the playlist. + * + * @private + */ + private handleNonPlaylistSource_() { + this.autoAdvance_.fullReset(); + this.playlist_.setCurrentIndex(null); + } +} \ No newline at end of file diff --git a/packages/javascript/modules/playlist/playlist-menu-item.ts b/packages/javascript/modules/playlist/playlist-menu-item.ts new file mode 100644 index 0000000..4cb7eda --- /dev/null +++ b/packages/javascript/modules/playlist/playlist-menu-item.ts @@ -0,0 +1,161 @@ +import videojs from 'video.js'; +import type Player from 'video.js/dist/types/player'; +import type { PlayerOptions, SourceOptions } from '../../interfaces'; +import { Playlist } from './playlist'; +import { preparePosterSrc } from '../../utils'; + + +const Component = videojs.getComponent('Component'); + +function createThumbnail(poster?: string) { + const picture = document.createElement('picture'); + picture.className = 'vjs-playlist-thumbnail'; + + if (!poster) { + picture.classList.add('vjs-playlist-thumbnail-placeholder'); + return picture; + } + + const img = document.createElement('img'); + img.loading = 'lazy'; + img.src = poster; + img.alt = ''; + picture.appendChild(img); + + return picture; +} + +interface PlaylistMenuItemOptions { + item: SourceOptions; + showDescription?: boolean; + playOnSelect?: boolean; + children?: any[]; + className?: string; + playerOptions?: PlayerOptions; +} + +export class PlaylistMenuItem extends Component { + private item: SourceOptions; + private spinnerEl!: HTMLElement; + private thumbnail!: HTMLElement; + private imgEl?: HTMLImageElement; + private playOnSelect: boolean; + private playlist: Playlist + private playerOptions: PlayerOptions; + + constructor(player: Player, playlist: Playlist, options: PlaylistMenuItemOptions, playerOptions: PlayerOptions) { + super(player, { ...options, playerOptions: playerOptions }); + this.item = options.item; + this.playOnSelect = !!options.playOnSelect; + this.playlist = playlist + this.playerOptions = playerOptions; + + this.emitTapEvents(); + this.on(['click', 'tap'], this.switchPlaylistItem_); + this.on('keydown', this.handleKeyDown_); + } + + private handleKeyDown_(event: KeyboardEvent): void { + if (event.which === 13 || event.which === 32) { + this.switchPlaylistItem_(); + } + } + + private switchPlaylistItem_(): void { + const list = this.playlist.getItems(); + const idx = list.findIndex(src => src === this.item); + if (idx > -1) { + (this.player_ as any).imagekitVideoPlayer().getPlaylistManager().loadPlaylistItem(idx); + if (this.playOnSelect) { + this.player_.play(); + } + } + } + + createEl(): HTMLElement { + const li = document.createElement('li'); + li.className = 'vjs-playlist-item'; + li.tabIndex = 0; + + // Thumbnail + // console.log("createEl playlistMenuItems", this) + // this.thumbnail = createThumbnail(this.options_.item.poster?.src); + // li.appendChild(this.thumbnail); + // 1) create thumbnail container + this.thumbnail = document.createElement('div'); + this.thumbnail.className = 'vjs-playlist-thumbnail'; + li.appendChild(this.thumbnail); + + // 2) spinner + this.spinnerEl = document.createElement('div'); + this.spinnerEl.className = 'vjs-playlist-thumbnail-spinner'; + // you can style this in your SCSS to show a CSS spinner + this.thumbnail.appendChild(this.spinnerEl); + + // 3) kick off async poster load + preparePosterSrc(this.options_.item, this.options_.playerOptions) + .then(url => { + // remove spinner + this.spinnerEl.remove(); + + // create & insert image + this.imgEl = document.createElement('img'); + this.imgEl.loading = 'lazy'; + this.imgEl.src = url; + this.imgEl.alt = this.options_.item.info?.title || ''; + this.thumbnail.appendChild(this.imgEl); + }) + .catch(err => { + // poster generation failed: remove spinner and show placeholder + this.spinnerEl.remove(); + this.thumbnail.classList.add('vjs-playlist-thumbnail-placeholder'); + }); + + // Now playing + const nowPlayingEl = document.createElement('span'); + const nowPlayingText = this.localize('Now Playing'); + + nowPlayingEl.className = 'vjs-playlist-now-playing-text'; + nowPlayingEl.appendChild(document.createTextNode(nowPlayingText)); + nowPlayingEl.setAttribute('title', nowPlayingText); + this.thumbnail.appendChild(nowPlayingEl); + + // Title container contains title and "up next" + const titleContainerEl = document.createElement('div'); + + titleContainerEl.className = 'vjs-playlist-title-container'; + this.thumbnail.appendChild(titleContainerEl); + + + // Up next + const upNextEl = document.createElement('span'); + const upNextText = this.localize('Up Next'); + + upNextEl.className = 'vjs-up-next-text'; + upNextEl.appendChild(document.createTextNode(upNextText)); + upNextEl.setAttribute('title', upNextText); + titleContainerEl.appendChild(upNextEl); + + // Title and description + const title = this.options_.item.info?.title || this.localize('Untitled Video'); + const titleEl = document.createElement('cite'); + titleEl.className = 'vjs-playlist-name'; + titleEl.textContent = title; + titleEl.title = title; + li.appendChild(titleEl); + + + + if (this.options_.showDescription && this.options_.item.info?.description) { + const descEl = document.createElement('div'); + descEl.className = 'vjs-playlist-description'; + descEl.textContent = this.options_.item.info.description; + descEl.title = this.options_.item.info.description; + titleContainerEl.appendChild(descEl); + } + + return li; + } +} + +videojs.registerComponent('PlaylistMenuItem', PlaylistMenuItem); \ No newline at end of file diff --git a/packages/javascript/modules/playlist/playlist-menu.ts b/packages/javascript/modules/playlist/playlist-menu.ts new file mode 100644 index 0000000..720460b --- /dev/null +++ b/packages/javascript/modules/playlist/playlist-menu.ts @@ -0,0 +1,132 @@ +// playlist-menu.ts +import videojs from 'video.js'; +import type Player from 'video.js/dist/types/player'; +import { PlaylistMenuItem } from './playlist-menu-item'; +import { Playlist } from './playlist'; +import { PlayerOptions } from '../../interfaces'; + +const Component = videojs.getComponent('Component'); + +interface PlaylistMenuOptions { + horizontal?: boolean; + showDescription?: boolean; + playOnSelect?: boolean; + supportsCssPointerEvents?: boolean; + className?: string; +} + +const addSelectedClass = (el: any) => el.addClass('vjs-selected'); +const removeSelectedClass = (el: any) => el.removeClass('vjs-selected'); +const upNext = (el: any) => el.addClass('vjs-up-next'); +const notUpNext = (el: any) => el.removeClass('vjs-up-next'); + +export class PlaylistMenu extends Component { + private items: PlaylistMenuItem[] = []; + private playlist: Playlist; + private playerOptions: PlayerOptions; + + constructor( + player: Player, + playlist: Playlist, + options: PlaylistMenuOptions, + playerOptions: PlayerOptions + ) { + // Pass `options` into super so Video.js creates `el_` for you: + super(player, options); + + this.playlist = playlist; + this.playerOptions = playerOptions; + + // 1) Add orientation/pointer‐events classes to the element Video.js already created: + if (options.horizontal) { + this.addClass('vjs-playlist-horizontal'); + } else { + this.addClass('vjs-playlist-vertical'); + } + if (options.supportsCssPointerEvents) { + this.addClass('vjs-csspointerevents'); + } + if (!videojs.browser.TOUCH_ENABLED) { + this.addClass('vjs-mouse'); + } + + // 2) Listen for ad events + player.on('adstart', () => this.addClass('vjs-ad-playing')); + player.on('adend', () => this.removeClass('vjs-ad-playing')); + + // 3) Listen for playlist events + player.on(['playlistchange', 'playlistsorted'], () => this.update()); + player.on('playlistadd', () => this.update()); + player.on('playlistremove', () => this.update()); + player.on('loadstart', () => this.update()); + + this.on('dispose', () => this.empty_()); + + // 4) Initial render + this.update(); + } + + /** Video.js will call this once to build a `
` for us. */ + createEl(): HTMLElement { + const cls = this.options_.className || 'vjs-playlist-menu'; + return videojs.dom.createEl('div', { className: cls }); + } + + /** Render or re-render the playlist UI */ + private update(): void { + // Guard: if el_ doesn’t exist yet, skip + if (!this.el_) return; + + this.empty_(); + + const items = this.playlist.getItems(); + const listEl = document.createElement('ol'); + listEl.className = 'vjs-playlist-item-list'; + this.el_.appendChild(listEl); + + this.items = items.map((item, index) => { + const menuItem = new PlaylistMenuItem( + this.player_, + this.playlist, + { + item, + showDescription: this.options_.showDescription, + playOnSelect: this.options_.playOnSelect, + }, + this.playerOptions + ); + listEl.appendChild(menuItem.el_); + return menuItem; + }); + + // Highlight current and up-next + const current = this.playlist.getCurrentIndex?.() ?? 0; + this.items.forEach((mi, i) => { + if (i === current) { + addSelectedClass(mi); + videojs.dom.addClass( + mi.el_.querySelector('.vjs-playlist-thumbnail')!, + 'vjs-playlist-now-playing' + ); + } else { + removeSelectedClass(mi); + } + if (i === current + 1) { + upNext(mi); + } else { + notUpNext(mi); + } + }); + } + + /** Remove all menu items */ + private empty_(): void { + if (!this.el_) return; + this.items.forEach(i => i.dispose()); + this.items = []; + this.el_.innerHTML = ''; + } +} + +videojs.registerComponent('PlaylistMenu', PlaylistMenu); +export default PlaylistMenu; \ No newline at end of file diff --git a/packages/javascript/modules/playlist/playlist-ui.scss b/packages/javascript/modules/playlist/playlist-ui.scss new file mode 100644 index 0000000..11cf062 --- /dev/null +++ b/packages/javascript/modules/playlist/playlist-ui.scss @@ -0,0 +1,328 @@ +// The default color for the playlist menu background, almost black +$background-color: #1a1a1a; + +// The color used to emphasize the currently playing video and for effects +$highlight-color: #141a21; + +// The primary foreground color +$main-color: #fff; + +// Background color for thumbnail placeholders +$placeholder-background-color: #303030; + +// Rules common to mouse and touch devices +.vjs-playlist { + padding: 0; + background-color: $background-color; + color: $main-color; + list-style-type: none; + + img { + display: block; + } + + .vjs-playlist-item-list { + position: relative; + margin: 0; + padding: 0; + list-style: none; + } + + .vjs-playlist-item { + position: relative; + cursor: pointer; + overflow: hidden; + } + + .vjs-playlist-thumbnail-placeholder { + background: $placeholder-background-color; + } + + .vjs-playlist-now-playing-text { + display: none; + position: absolute; + top: 0; + left: 0; + + // This keeps this element aligned vertically with vjs-playlist-name. + padding-left: 2px; + margin: .8rem; + } + + .vjs-playlist-duration { + position: absolute; + top: .5rem; + left: .5rem; + padding: 2px 5px 3px; + margin-left: 2px; + background-color: rgba(26, 26, 26, 0.8); + } + + .vjs-playlist-title-container { + position: absolute; + bottom: 0; + box-sizing: border-box; + width: 100%; + padding: .5rem .8rem; + text-shadow: 1px 1px 2px black, + -1px 1px 2px black, + 1px -1px 2px black, + -1px -1px 2px black; + } + + .vjs-playlist-name { + display: block; + max-height: 2.5em; + + // So drop shadow doesn't get cut off as overflow + padding: 0 0 4px 2px; + font-style: normal; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + + // line-height: normal was causing overflow cutoff issues on Android, since normal + // lets different user agents pick a value. Here we set a consistent precise value. + line-height: 20px; + } + + .vjs-playlist-description{ + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + display: block; + font-size: 14px; + padding: 0 0 0 2px; + } + + .vjs-up-next-text { + display: none; + padding: .1rem 2px; + font-size: .8em; + text-transform: uppercase; + } + + .vjs-up-next { + + .vjs-up-next-text { + display: block; + } + } + + // Selected item rules + .vjs-selected { + background-color: $highlight-color; + + img { + opacity: .2; + } + + .vjs-playlist-duration { + display: none; + } + + .vjs-playlist-now-playing-text { + display: block; + } + + .vjs-playlist-title-container { + text-shadow: none; + } + } +} + +// Vertical/default playlist orientation +.vjs-playlist-vertical { + overflow-x: hidden; + overflow-y: auto; + + img { + width: 100%; + min-height: 54px; + } + + .vjs-playlist-item { + margin-bottom: 5px; + } + + .vjs-playlist-thumbnail { + display: block; + width: 100%; + } + + .vjs-playlist-thumbnail-placeholder { + height: 100px; + } +} + +// Horizontal playlist orientation +.vjs-playlist-horizontal { + overflow-x: auto; + overflow-y: hidden; + + img { + min-width: 100px; + height: 100%; + } + + .vjs-playlist-item-list { + height: 100%; + white-space: nowrap; + } + + .vjs-playlist-item { + display: inline-block; + height: 100%; + margin-right: 5px; + } + + .vjs-playlist-thumbnail { + display: block; + height: 100%; + } + + .vjs-playlist-thumbnail-placeholder { + height: 100%; + width: 180px; + } +} + +// prevent clicks and scrolling from affecting the playlist during ads +.vjs-playlist.vjs-ad-playing.vjs-csspointerevents { + pointer-events: none; + overflow: auto; +} + +// darken the playlist menu display to indicate it's not interactive during ads +.vjs-playlist.vjs-ad-playing .vjs-playlist-ad-overlay { + display: block; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); +} + +// Parametric rules. These are specialized for touch and mouse-based devices +@mixin playlist-base($base-font-size: 14px) { + font-size: $base-font-size; + + .vjs-playlist-description { + height: $base-font-size * 2; + line-height: ceil($base-font-size * 1.5); + } +} + +// Touch-device playlist dimensions +.vjs-playlist { + @include playlist-base(); +} + +// Mouse-only playlist dimensions +.vjs-mouse.vjs-playlist { + @include playlist-base(15px); +} + +// Larger font size for larger player +@media (min-width: 600px) { + .vjs-mouse.vjs-playlist { + @include playlist-base(17px); + } + + .vjs-playlist .vjs-playlist-name { + line-height: 22px; + } +} + +// Don't show now playing / up next when there isn't room +@media (max-width: 520px) { + + // These styles exist both with and without .vjs-mouse + .vjs-playlist .vjs-selected .vjs-playlist-now-playing-text, + .vjs-playlist .vjs-up-next .vjs-up-next-text { + display: none; + } + + .vjs-mouse.vjs-playlist .vjs-selected .vjs-playlist-now-playing-text, + .vjs-mouse.vjs-playlist .vjs-up-next .vjs-up-next-text { + display: none; + } +} + +// If now playing / up next are shown, make sure there is room. +// Only affects thumbnails with very wide aspect ratio, which get +// stretched vertically. We could avoid the stretching by making this a +// CSS background image and using background-size: cover; but then it'd +// get clipped, so not sure that's better. +@media (min-width: 521px) { + .vjs-playlist img { + min-height: 85px; + } +} + +// Don't show duration when there isn't room +@media (max-width: 750px) { + .vjs-playlist .vjs-playlist-duration { + display: none; + } +} + +// body { +// font-family: Arial, sans-serif; +// } + +.info { + background-color: #eee; + border: thin solid #333; + border-radius: 3px; + margin: 0 0 20px; + padding: 0 5px; +} + +.player-container { + background: #1a1a1a; + overflow: auto; + margin: 0 0 20px; +} + +.video-js { + float: left; +} + +.vjs-playlist, +.my-custom-class, +#my-custom-element { + float: left; + width: 300px; +} + +.vjs-playlist.vjs-playlist-horizontal { + float: none; + height: 120px; + width: 600px; +} + +.vjs-playlist-thumbnail-spinner { + /* center the spinner */ + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + + /* simple CSS spinner */ + &:before { + content: ""; + width: 24px; + height: 24px; + border: 3px solid #fff; + border-top-color: transparent; + border-radius: 50%; + animation: vjs-playlist-spin 0.75s linear infinite; + } +} + +@keyframes vjs-playlist-spin { + to { transform: rotate(360deg); } +} \ No newline at end of file diff --git a/packages/javascript/modules/playlist/playlist.ts b/packages/javascript/modules/playlist/playlist.ts new file mode 100644 index 0000000..2c901a6 --- /dev/null +++ b/packages/javascript/modules/playlist/playlist.ts @@ -0,0 +1,185 @@ +// src/modules/playlist.ts +import videojs from 'video.js'; +import type EventTarget from 'video.js/dist/types/event-target'; +import { isIndexInBounds, randomize } from './utils'; +import type { SourceOptions } from '../../interfaces'; + +/** + * A playlist of SourceOptions, with standard operations and events. + */ +export class Playlist extends (videojs.EventTarget as typeof EventTarget) { + private items_: SourceOptions[]; + private currentIndex_: number | null; + private repeat_: boolean; + private onError_: (msg: string) => void; + private onWarn_: (msg: string) => void; + + /** + * Factory: create & populate in one call. + */ + static from( + items: SourceOptions[], + options: { onError?: (msg: string) => void; onWarn?: (msg: string) => void } + ) { + const p = new Playlist(options); + p.setItems(items); + return p; + } + + constructor(options: { onError?: (msg: string) => void; onWarn?: (msg: string) => void } = {}) { + super(); + this.items_ = []; + this.currentIndex_ = null; + this.repeat_ = false; + this.onError_ = options.onError || (() => {}); + this.onWarn_ = options.onWarn || (() => {}); + } + + /** Replace entire list (only valid items kept). */ + setItems(items: SourceOptions[]): SourceOptions[] { + if (!Array.isArray(items)) { + this.onError_('Playlist must be an array of source definitions.'); + return [...this.items_]; + } + const valid = items.filter(src => src && typeof src.src === 'string'); + if (!valid.length) { + this.onError_('No valid playlist items provided.'); + return [...this.items_]; + } + this.items_ = valid; + this.trigger('playlistchange'); + return [...this.items_]; + } + + /** Shallow clone of current list. */ + getItems(): SourceOptions[] { + return [...this.items_]; + } + + /** Remove all items. */ + reset(): void { + this.items_ = []; + this.currentIndex_ = null; + this.trigger('playlistchange'); + } + + /** Enable or disable looping. */ + enableRepeat(): void { this.repeat_ = true; } + disableRepeat(): void { this.repeat_ = false; } + isRepeatEnabled(): boolean { return this.repeat_; } + + /** Change which index is “current.” */ + setCurrentIndex(i: number| null): void { + if (i && !isIndexInBounds(this.items_, i)) { + this.onError_('Index out of bounds.'); + return; + } + this.currentIndex_ = i; + } + + getCurrentIndex(): number { + return this.currentIndex_ === null ? -1 : this.currentIndex_; + } + + getCurrentItem(): SourceOptions | undefined { + return this.items_[this.currentIndex_!]; + } + + getLastIndex(): number { + return this.items_.length ? this.items_.length - 1 : -1; + } + + getNextIndex(): number { + if (this.currentIndex_ === null) { return -1; } + const nxt = (this.currentIndex_ + 1) % this.items_.length; + return this.repeat_ || nxt !== 0 ? nxt : -1; + } + + getPreviousIndex(): number { + if (this.currentIndex_ === null) { return -1; } + const prev = (this.currentIndex_ - 1 + this.items_.length) % this.items_.length; + return this.repeat_ || prev !== this.items_.length - 1 ? prev : -1; + } + + /** Insert one or many new SourceOptions at `index`. */ + add(items: SourceOptions | SourceOptions[], index?: number): SourceOptions[] { + const arr = Array.isArray(items) ? items : [items]; + const valid = arr.filter(src => src && typeof src.src === 'string'); + if (!valid.length) { + this.onError_('No valid items to add.'); + return []; + } + const idx = (typeof index !== 'number' || index < 0 || index > this.items_.length) + ? this.items_.length + : index; + this.items_.splice(idx, 0, ...valid); + if (this.currentIndex_ !== null && idx <= this.currentIndex_) { + this.currentIndex_! += valid.length; + } + this.trigger({ type: 'playlistadd', count: valid.length, index: idx }); + return valid; + } + + /** Remove `count` items starting at `index`. */ + remove(index: number, count = 1): SourceOptions[] { + if (!isIndexInBounds(this.items_, index) || count < 0) { + this.onError_('Invalid removal parameters.'); + return []; + } + const actual = Math.min(count, this.items_.length - index); + const removed = this.items_.splice(index, actual); + // adjust currentIndex_ if necessary + if (this.currentIndex_ !== null) { + if (this.currentIndex_ < index) { + // no change + } else if (this.currentIndex_ >= index + actual) { + this.currentIndex_ -= actual; + } else { + this.currentIndex_ = null; + } + } + this.trigger({ type: 'playlistremove', count: actual, index }); + return removed; + } + + /** Sort in-place, preserving the current item if possible. */ + sort(compare: (a: SourceOptions, b: SourceOptions) => number): void { + if (!this.items_.length || typeof compare !== 'function') { return; } + const current = this.getCurrentItem(); + this.items_.sort(compare); + this.currentIndex_ = current == null + ? null + : this.items_.findIndex(i => i === current); + this.trigger('playlistsorted'); + } + + /** Reverse list order, adjusting current index. */ + reverse(): void { + if (!this.items_.length) { return; } + this.items_.reverse(); + if (this.currentIndex_ !== null) { + this.currentIndex_ = this.items_.length - 1 - this.currentIndex_; + } + this.trigger('playlistsorted'); + } + + /** + * Shuffle either the whole list, or the 'rest' after the current index. + */ + shuffle({ rest = true } = {}): void { + const start = rest && this.currentIndex_ !== null ? this.currentIndex_ + 1 : 0; + const tail = this.items_.slice(start); + if (tail.length <= 1) { return; } + const current = this.getCurrentItem(); + randomize(tail); + if (rest && this.currentIndex_ !== null) { + this.items_.splice(start, tail.length, ...tail); + } else { + this.items_ = tail; + } + this.currentIndex_ = current == null + ? null + : this.items_.findIndex(i => i === current); + this.trigger('playlistsorted'); + } +} \ No newline at end of file diff --git a/packages/javascript/modules/playlist/utils.ts b/packages/javascript/modules/playlist/utils.ts new file mode 100644 index 0000000..69dae1f --- /dev/null +++ b/packages/javascript/modules/playlist/utils.ts @@ -0,0 +1,10 @@ +export function isIndexInBounds(items: any[], index: number): boolean { + return index >= 0 && index < items.length; + } + + export function randomize(arr: T[]): void { + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [arr[i], arr[j]] = [arr[j], arr[i]]; + } + } \ No newline at end of file diff --git a/packages/javascript/modules/recommendations-overlay/recommendation-overlay.css b/packages/javascript/modules/recommendations-overlay/recommendation-overlay.css new file mode 100644 index 0000000..a6ec358 --- /dev/null +++ b/packages/javascript/modules/recommendations-overlay/recommendation-overlay.css @@ -0,0 +1,83 @@ +/* Overlay backdrop */ +.vjs-recommendations-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.75); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + z-index: 1000; /* above controls */ + padding: 1em; + box-sizing: border-box; + } + /* Close button */ + .vjs-rec-close { + position: absolute; + top: 1em; + right: 1em; + background: transparent; + border: none; + color: #fff; + font-size: 1.5em; + cursor: pointer; + } + /* Primary (large) recommendation */ + .vjs-rec-primary { + display: flex; + width: 60%; + max-width: 800px; + margin-bottom: 1.5em; + background: #222; + color: #fff; + text-decoration: none; + overflow: hidden; + border-radius: 4px; + } + .vjs-rec-primary .vjs-rec-thumb { + width: 40%; + background-size: cover; + background-position: center; + aspect-ratio: 16/9; + } + .vjs-rec-primary .vjs-rec-info { + padding: 1em; + } + .vjs-rec-primary .vjs-rec-info h3 { + margin: 0 0 0.5em; + font-size: 1.25em; + } + .vjs-rec-primary .vjs-rec-info p { + margin: 0; + } + /* Smaller recommendations row */ + .vjs-rec-list { + display: flex; + gap: 1em; + width: 80%; + max-width: 900px; + justify-content: center; + } + .vjs-rec-item { + flex: 1; + max-width: 200px; + background: #222; + color: #fff; + text-decoration: none; + border-radius: 4px; + overflow: hidden; + } + .vjs-rec-item .vjs-rec-thumb { + width: 100%; + background-size: cover; + background-position: center; + aspect-ratio: 16/9; + } + .vjs-rec-item .vjs-rec-label { + padding: 0.5em; + font-size: 0.9em; + text-align: center; + } \ No newline at end of file diff --git a/packages/javascript/modules/recommendations-overlay/recommendations-overlay.ts b/packages/javascript/modules/recommendations-overlay/recommendations-overlay.ts new file mode 100644 index 0000000..88307b7 --- /dev/null +++ b/packages/javascript/modules/recommendations-overlay/recommendations-overlay.ts @@ -0,0 +1,98 @@ +import videojs from 'video.js'; +import type Player from 'video.js/dist/types/player'; +import { SourceOptions } from '../../interfaces'; + +const Component = videojs.getComponent('Component'); + +interface RecommendationsOverlayOptions { + recommendations: SourceOptions[]; + children?: any[]; + className?: string; +} + +export class RecommendationsOverlay extends Component { + private recommendations: SourceOptions[]; + private overlayEl!: HTMLDivElement; + private primaryEl!: HTMLDivElement; + private listEl!: HTMLDivElement; + private closeBtn!: HTMLButtonElement; + + constructor(player: Player, options: RecommendationsOverlayOptions) { + super(player, options); + this.recommendations = options.recommendations || []; + + this.hide(); // start hidden + + // Build elements + this.overlayEl = this.createEl('div', { className: 'vjs-recommendations-overlay' }) as HTMLDivElement; + this.primaryEl = this.createEl('div', { className: 'vjs-rec-primary' }) as HTMLDivElement; + this.listEl = this.createEl('div', { className: 'vjs-rec-list' }) as HTMLDivElement; + this.closeBtn = this.createEl('button', { className: 'vjs-rec-close' }) as HTMLButtonElement; + this.closeBtn.innerHTML = '✕'; // × + + // Assemble + this.overlayEl.appendChild(this.closeBtn); + this.overlayEl.appendChild(this.primaryEl); + this.overlayEl.appendChild(this.listEl); + this.el().appendChild(this.overlayEl); + + // Listeners + player.on('ended', this.onEnded); + this.closeBtn.addEventListener('click', () => this.hide()); + } + + private onEnded = () => { + this.renderRecommendations(); + this.show(); + }; + + private renderRecommendations() { + const [first, ...rest] = this.recommendations; + + // — Primary card — + this.primaryEl.innerHTML = ''; + if (first) { + const thumb = this.createEl('div', { className: 'vjs-rec-thumb' }) as HTMLDivElement; + thumb.style.backgroundImage = `url('${first.poster?.src || ''}')`; + + const info = this.createEl('div', { className: 'vjs-rec-info' }) as HTMLDivElement; + info.innerHTML = ` +

${first.info?.title || ''}

+

${first.info?.subtitle || ''}

+ `; + + this.primaryEl.appendChild(thumb); + this.primaryEl.appendChild(info); + + // click to select + this.primaryEl.onclick = () => this.onClickHandler(first); + } + + // — Smaller cards — + this.listEl.innerHTML = ''; + rest.forEach(rec => { + const card = this.createEl('div', { className: 'vjs-rec-item' }) as HTMLDivElement; + const thumb = this.createEl('div', { className: 'vjs-rec-thumb' }) as HTMLDivElement; + thumb.style.backgroundImage = `url('${rec.poster?.src || ''}')`; + + const label = this.createEl('div', { className: 'vjs-rec-label' }) as HTMLDivElement; + label.textContent = rec.info?.title || ''; + + card.appendChild(thumb); + card.appendChild(label); + + card.onclick = () => this.onClickHandler(rec); + this.listEl.appendChild(card); + }); + } + + private onClickHandler(source: SourceOptions) { + // swap source & play + this.player().src(source); + this.hide(); + } +} + +// register component +videojs.registerComponent('RecommendationsOverlay', RecommendationsOverlay); +export default RecommendationsOverlay; \ No newline at end of file diff --git a/packages/javascript/modules/seek-thumbnails/mockSeekThumbnailsVTT.ts b/packages/javascript/modules/seek-thumbnails/mockSeekThumbnailsVTT.ts new file mode 100644 index 0000000..a3bce55 --- /dev/null +++ b/packages/javascript/modules/seek-thumbnails/mockSeekThumbnailsVTT.ts @@ -0,0 +1,36 @@ +/** + * Frontend VTT fetch interceptor: rewrites the VTT fetch URL to a default VTT you provide. + * + * Usage: + * // call early in your app or demo entrypoint + * import { initVttRedirect } from './mockSeekThumbnailsVTT'; + * initVttRedirect('https://my.cdn.com/default-thumbnails.vtt'); + */ +export function initVttRedirect(defaultVttUrl: string) { + const originalFetch = window.fetch.bind(window); + + window.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + let requestUrl: string; + if (typeof input === 'string') { + requestUrl = input; + } else if (input instanceof URL) { + requestUrl = input.toString(); + } else if (input instanceof Request) { + requestUrl = input.url; + } else { + // fallback + return originalFetch(input, init); + } + + // match the VTT endpoint (with or without query) + const vttPattern = /https:\/\/ik\.imagekit\.io\/zuqlyov9d\/example_video_2\.mp4\/ik-seek-thumbnail-track\.vtt(\?.*)?$/; + if (vttPattern.test(requestUrl)) { + // redirect to the provided default VTT URL + return originalFetch(defaultVttUrl, init); + } + + // otherwise, proceed normally + return originalFetch(input, init); + }; + } + \ No newline at end of file diff --git a/packages/javascript/modules/seek-thumbnails/seek-thumbnails-manager.ts b/packages/javascript/modules/seek-thumbnails/seek-thumbnails-manager.ts new file mode 100644 index 0000000..5d018d0 --- /dev/null +++ b/packages/javascript/modules/seek-thumbnails/seek-thumbnails-manager.ts @@ -0,0 +1,215 @@ +import videojs from 'video.js'; +import type Player from 'video.js/dist/types/player'; +import type { PlayerOptions, SourceOptions } from '../../interfaces'; +import { prepareSeekThumbnailVttSrc } from '../../utils'; + +const log = videojs.log.createLogger('videojs-seek-thumbnail'); + +/** + * Cue parsed from WebVTT. + */ +type WebVTTCue = { + startTime: number; + endTime: number; + settings: Record; + text: string; +}; + +export class SeekThumbnailsManager { + private thumbnails_: { startTime: number; endTime: number; url: URL }[] = []; + private container_: HTMLDivElement | null = null; + private mouseMoveHandler: ((e: MouseEvent) => void) | null = null; + private mouseLeaveHandler: (() => void) | null = null; + + /** + * Build a new manager, fetch VTT, parse cues, create a container, and attach hover handlers. + * Returns the newly created instance so the caller can store it. + */ + static async initSeekThumbnails( + player: Player, + source: SourceOptions, + playerOptions: PlayerOptions + ): Promise { + try { + // 1) Build the VTT URL + const manifestUrl = await prepareSeekThumbnailVttSrc(source, playerOptions); + + log.debug('Fetching VTT →', manifestUrl); + const resp = await fetch(manifestUrl); + if (!resp.ok) { + log.warn(`VTT fetch failed (${resp.status}); skipping seek thumbnails.`); + return null; + } + const vttText = await resp.text(); + + // 2) Parse cues + const cues = parseWebVTT(vttText); + if (!cues.length) { + log.warn('No cues in VTT; skipping thumbnails.'); + return null; + } + + // 3) Instantiate a new manager + const mgr = new SeekThumbnailsManager(); + mgr.thumbnails_ = cues.map((c) => ({ + startTime: c.startTime, + endTime: c.endTime, + url: new URL(c.text), + })); + + // 4) Remove any old container (just in case the caller forgot) + const oldContainer = player.el().querySelector('.thumbnail-preview'); + if (oldContainer) { + oldContainer.remove(); + } + + // 5) Create fresh container + mgr.container_ = document.createElement('div'); + mgr.container_.className = 'thumbnail-preview'; + player.el().appendChild(mgr.container_); + + // 6) Wire up hover handlers (store references so we can remove them later) + const progress = player.controlBar.progressControl; + + // Make named functions and store on `mgr` + mgr.mouseMoveHandler = (e: MouseEvent) => onMouseMove(e, player, mgr); + mgr.mouseLeaveHandler = () => { + if (mgr.container_) mgr.container_.style.display = 'none'; + }; + + // Attach them + progress.on('mousemove', mgr.mouseMoveHandler); + progress.on('mouseleave', mgr.mouseLeaveHandler); + + log.debug('SeekThumbnailsManager initialized'); + return mgr; + } catch (err) { + log.warn('Error initializing seek thumbnails:', err); + return null; + } + } + + /** + * Remove this manager’s container + unbind only our handlers. + * After calling this, the caller should drop references to the instance. + */ + public destroy(player: Player): void { + // 1) Remove container if it exists + if (this.container_) { + this.container_.remove(); + this.container_ = null; + } + + // 2) Unbind the handlers we attached + const progress = player.controlBar.progressControl; + if (this.mouseMoveHandler) { + progress.off('mousemove', this.mouseMoveHandler); + this.mouseMoveHandler = null; + } + if (this.mouseLeaveHandler) { + progress.off('mouseleave', this.mouseLeaveHandler); + this.mouseLeaveHandler = null; + } + + // 3) Clear out thumbnail data + this.thumbnails_ = []; + } +} + +/** + * Handle hover over the progress bar. + * Looks up the nearest thumbnail for the hovered time and renders it. + */ +function onMouseMove(e: MouseEvent, player: Player, mgr: SeekThumbnailsManager) { + if (!mgr['container_']) return; + + const barRect = player.controlBar.progressControl.el().getBoundingClientRect(); + const pct = (e.clientX - barRect.left) / barRect.width; + const time = pct * player.duration(); + const url = nearestThumbnail(mgr['thumbnails_'], time); + if (!url) return; + + const container = mgr['container_']!; + container.innerHTML = ''; + const thumbEl = createThumbnailElement(document, url); + thumbEl.className = 'thumbnail'; + container.style.left = `${e.pageX - player.el().getBoundingClientRect().left}px`; + container.style.display = 'block'; + container.appendChild(thumbEl); +} + +/** Find the cue whose startTime is closest to t */ +function nearestThumbnail( + list: { startTime: number; endTime: number; url: URL }[], + t: number +): URL | null { + if (!list.length) return null; + let best = list[0], + bestDiff = Math.abs(best.startTime - t); + for (const item of list) { + const d = Math.abs(item.startTime - t); + if (d < bestDiff) { + best = item; + bestDiff = d; + } + } + return best.url; +} + +/** Render one thumbnail DIV (reads sprite coords from URL.hash) */ +function createThumbnailElement(doc: Document, url: URL): HTMLDivElement { + const div = doc.createElement('div'); + Object.assign(div.style, { + position: 'absolute', + pointerEvents: 'none', + backgroundImage: `url(${url.toString()})`, + backgroundRepeat: 'no-repeat', + backgroundSize: 'auto', + transform: 'translateX(-50%) translateY(-100%)', + backgroundPosition: 'center center', + width: '100px', + height: '100px', + display: 'block', + }); + + const m = url.hash.match(/xywh=(\d+),(\d+),(\d+),(\d+)/); + if (m) { + const [, x, y, w, h] = m; + div.style.width = `${w}px`; + div.style.height = `${h}px`; + div.style.backgroundPosition = `-${x}px -${y}px`; + div.style.bottom = `${parseFloat(h) / 2}px`; + } + return div; +} + +/** Parse a WebVTT text blob into cue objects */ +function parseWebVTT(input: string): WebVTTCue[] { + const raw = input + .replace(/\r\n|\r|\n/g, '\n') + .replace(/\n\n+/g, '\n\n') + .split('\n\n'); + const cueChunks = raw.filter((chunk) => + /\d{2}:\d{2}:\d{2}\.\d+ --> \d{2}:\d{2}:\d{2}\.\d+/.test(chunk) + ); + return cueChunks.map(parseCue); +} + +/** Parse one cue block */ +function parseCue(chunk: string): WebVTTCue { + const lines = chunk.split('\n'); + const idx = lines.findIndex((l) => / --> /.test(l)); + const [startRaw, endRaw] = lines[idx].split('-->').map((s) => s.trim()); + return { + startTime: parseTimestamp(startRaw), + endTime: parseTimestamp(endRaw), + settings: {}, // unused for now + text: lines.slice(idx + 1).join('\n'), + }; +} + +/** Convert "hh:mm:ss.mmm" → seconds */ +function parseTimestamp(ts: string): number { + const [h, m, s] = ts.split(':'); + return Number(h) * 3600 + Number(m) * 60 + parseFloat(s); +} \ No newline at end of file diff --git a/packages/javascript/modules/seek-thumbnails/seek-thumbnails.css b/packages/javascript/modules/seek-thumbnails/seek-thumbnails.css new file mode 100644 index 0000000..9c5b9a9 --- /dev/null +++ b/packages/javascript/modules/seek-thumbnails/seek-thumbnails.css @@ -0,0 +1,22 @@ +.thumbnail-preview { + position: absolute; + bottom: 55px; + pointer-events: none; + display: none; + z-index: 0; + } + + .thumbnail { + position: absolute; + height: 100%; + background-size: auto; + background-repeat: no-repeat; + pointer-events: none; + display: none; + transform: translateX(-50%) translateY(-100%); + } + + .video-js .thumbnail-preview .thumbnail { + border: 2px solid #fff; + border-radius:5px; + } \ No newline at end of file diff --git a/packages/javascript/modules/seek-thumbnails/vtt-parser.ts b/packages/javascript/modules/seek-thumbnails/vtt-parser.ts new file mode 100644 index 0000000..b91aefe --- /dev/null +++ b/packages/javascript/modules/seek-thumbnails/vtt-parser.ts @@ -0,0 +1,94 @@ +/* +https://w3c.github.io/webvtt/#webvtt-timestamp + + + --> : : : + + + + +12 +00:00:00.000 --> 00:00:01.000 align:start line:10% position:25% size:50% +seek-thumbnail-sprite.jpg#xywh=0,0,150,84 +*/ +export type WebVTTCue = { + startTime: number; + endTime: number; + settings: Record; + text: string; + }; + + const CUE_TIME_LINE_REGEXP = + /(?:^|\n) *(?:\d+:[0-5]\d:[0-5]\d(?:(?:\.|,)\d+)?|[0-5]\d:[0-5]\d(?:(?:\.|,)\d+)?) +--> +(?:\d+:[0-5]\d:[0-5]\d(?:(?:\.|,)\d+)?|[0-5]\d:[0-5]\d(?:(?:\.|,)\d+)?)/; + + const TIMESTAMP_REGEXP = + /(?:\d+:[0-5]\d:[0-5]\d(?:(?:\.|,)\d+)?|[0-5]\d:[0-5]\d(?:(?:\.|,)\d+)?)/g; + + export function parseWebVTT(input: string): WebVTTCue[] { + const continuousChunks = input + .replace(/\r\n|\r|\n/g, "\n") // Normalize line endings to \n + .replace(/\n\n+/g, "\n\n") // Remove extra blank lines + .split("\n\n"); // Split into chunks by double newlines + + const cueChunks = continuousChunks.filter((chunk) => + CUE_TIME_LINE_REGEXP.test(chunk) + ); + + return cueChunks.map(parseCue); + } + + function parseCue(data: string): WebVTTCue { + const cueLines = data.split("\n"); + + const indexOfLineWithTimestamp = cueLines + .findIndex((line) => CUE_TIME_LINE_REGEXP.test(line)); + + const timestampLine = cueLines[indexOfLineWithTimestamp]; + const [startTime, endTime] = timestampLine.split("-->") + .map((s) => s.match(TIMESTAMP_REGEXP)) + .map((t) => { + if (!t?.[0]) throw Error("Error"); + else { + return parseTimestamp(t[0]); + } + }); + + const settings = parseCueSettings( + timestampLine.replace(CUE_TIME_LINE_REGEXP, ""), + ); + + const textLines: string[] = []; + for (let i = indexOfLineWithTimestamp + 1; i < cueLines.length; i++) { + textLines.push(cueLines[i]); + } + const text = textLines.join("\n"); + + return { + startTime, + endTime, + settings, + text, + }; + } + + function parseTimestamp(timestamp: string): number { + const match = timestamp.match(TIMESTAMP_REGEXP); + if (!match?.[0]) throw Error("Error while parsing timestamp" + timestamp); + const [seconds, minutes, hours] = match[0].split(":").reverse(); + return ( + (hours ? parseInt(hours, 10) * 3600 : 0) + + parseInt(minutes, 10) * 60 + + parseFloat(seconds.replace(",", ".")) + ); + } + + function parseCueSettings(settingsString: string): Record { + return settingsString + .split(" ") + .filter((part) => part.includes(":")) + .reduce((settings, part) => { + const [key, value] = part.split(":"); + if (key && value) settings[key] = value; + return settings; + }, {} as Record); + } \ No newline at end of file diff --git a/packages/javascript/modules/shoppable/shoppable-manager.ts b/packages/javascript/modules/shoppable/shoppable-manager.ts new file mode 100644 index 0000000..54eecf7 --- /dev/null +++ b/packages/javascript/modules/shoppable/shoppable-manager.ts @@ -0,0 +1,696 @@ +// src/modules/shoppable/shoppable-manager.ts +import videojs from 'video.js'; +import type Player from 'video.js/dist/types/player'; +import type { + ShoppableProps, + ProductProps, + Hotspot, + InteractionProps +} from '../../interfaces/Shoppable'; + +// We’ll dynamically create a “products bar” at the bottom/right +// of the player. Each product in the bar is clickable/hoverable, and +// we’ll also overlay timed hotspots on top of the video. When the +// playhead is between highlightTime.start–highlightTime.end, we +// add a “highlight” style to the corresponding product in the bar. + +export class ShoppableManager { + private player_: Player; + private shoppable_: ShoppableProps; + private barContainer_: HTMLDivElement | null = null; + private hotspotContainers_: HTMLDivElement[] = []; + private postPlayOverlay_: HTMLDivElement | null = null; + private tickHandler_: (() => void) | null = null; + private endedHandler_: (() => void) | null = null; + + constructor(player: Player, shoppable: ShoppableProps) { + this.player_ = player; + this.shoppable_ = shoppable; + + // 1) Wait until Video.js has built its DOM (control bar, tech, etc.) + this.player_.one('play', () => { + // Now that the control bar exists and metadata is ready, build the bar: + // this.buildToggleButton(); + this.buildProductBar(); + this.buildHotspots(); + + // Start listening to timeupdate for highlights/hotspots: + this.tickHandler_ = this.onTimeUpdate.bind(this); + this.player_.on('timeupdate', this.tickHandler_); + + // Build post-play overlay if requested: + if (this.shoppable_.showPostPlayOverlay) { + this.buildPostPlayOverlay(); + this.endedHandler_ = this.onEnded.bind(this); + this.player_.on('ended', this.endedHandler_); + } + + // Handle startState (“openOnPlay” or “open”): + if (this.shoppable_.startState === 'openOnPlay') { + this.player_.one('play', () => this.openBar()); + } else if (this.shoppable_.startState === 'open') { + this.openBar(); + } + }); + } + /** + * Create the “products bar” that sits alongside (or below) the player. + * We insert a small
with a fixed width (percentage) and a + * scrollable row of thumbnail images. Each image has hover/click behavior. + */ + // private buildProductBar() { + // const widthPct = this.shoppable_.width ?? 20; // default = 20% + // const toggleIcon = this.shoppable_.toggleIconUrl || ''; + + // // Defer measurement until after the browser’s next paint: + // // requestAnimationFrame(() => { + // // Now the control bar has been laid out: + // const controlBarEl = this.player_.controlBar.el(); + // const controlBarHeight = controlBarEl.getBoundingClientRect().height; + + // console.log('Control bar height:', controlBarHeight); + + // // Create your “products” div and pin it above the control bar: + + // // 1) Create container DIV + // const bar = document.createElement('div'); + // bar.className = 'vjs-shoppable-products-bar vjs-hidden'; // initially hidden + // Object.assign(bar.style, { + // position: 'absolute', + // top: '0', + // right: '0', + // bottom: `${controlBarHeight}px`, // use the freshly‐measured height + // width: `${widthPct}%`, // e.g. “20%” of the player width + // background: 'rgba(0,0,0,0.6)', + // display: 'flex', + // flexDirection: 'column', + // overflowY: 'auto', + // zIndex: '999' + // }); + + // this.barContainer_ = bar; + + // // 2) Add a toggle icon (to minimize/maximize the bar) + // if (toggleIcon) { + // const iconBtn = document.createElement('img'); + // iconBtn.src = toggleIcon; + // iconBtn.className = 'vjs-shoppable-toggle-icon'; + // Object.assign(iconBtn.style, { + // width: '24px', + // height: '24px', + // cursor: 'pointer', + // margin: '8px' + // }); + // iconBtn.addEventListener('click', () => this.toggleBar()); + // bar.appendChild(iconBtn); + // } + + // // 3) Create a horizontally scrollable container of product thumbnails + // const scrollContainer = document.createElement('div'); + // scrollContainer.className = 'vjs-shoppable-products-scroll'; + // Object.assign(scrollContainer.style, { + // display: 'flex', + // flexDirection: 'column', + // overflowX: 'hidden', + // padding: '8px' + // }); + + // // 4) For each product in shoppable_.products, create a thumbnail + // this.shoppable_.products.forEach((prod, idx) => { + // const thumbWrapper = document.createElement('div'); + // thumbWrapper.className = 'vjs-shoppable-thumb-wrapper'; + // Object.assign(thumbWrapper.style, { + // position: 'relative', + // marginRight: '8px', + // cursor: 'pointer' + // }); + + // // a) create tag for product.imageUrl + // const img = document.createElement('img'); + // img.src = prod.imageUrl; + // img.alt = prod.productName; + // img.className = 'vjs-shoppable-thumb-img'; + // Object.assign(img.style, { + // width: '200px', + // height: '200px', + // objectFit: 'cover', + // border: '2px solid transparent' + // }); + // thumbWrapper.appendChild(img); + + // // b) listen to click / hover on the thumbnail + // thumbWrapper.addEventListener('mouseenter', () => { + // this.player_.trigger('productHover', { product: prod }); + // if (prod.onHover) { + // this.handleInteraction(prod.onHover, prod); + // } + // }); + // thumbWrapper.addEventListener('mouseleave', () => { + // // (you could trigger a “productHoverEnd” if you need) + // }); + // thumbWrapper.addEventListener('click', () => { + // this.player_.trigger('productClick', { product: prod }); + // if (prod.onClick) { + // this.handleInteraction(prod.onClick, prod); + // } + // }); + + // // c) store a reference so we can highlight it later + // thumbWrapper.setAttribute('data-shoppable-idx', String(idx)); + // scrollContainer.appendChild(thumbWrapper); + // }); + + // bar.appendChild(scrollContainer); + // // 5) Finally, attach it to the player container (player.el()) + // this.player_.el().appendChild(bar); + // // }); + + // } + + private buildProductBar() { + const widthPct = this.shoppable_.width ?? 20; // e.g. 20% + // 1) Outer wrapper + const bar = document.createElement('div'); + bar.className = 'cld-spbl-bar'; + bar.setAttribute('size', 'lg'); + + // 2) The sliding inner panel + const inner = document.createElement('div'); + inner.className = 'cld-spbl-bar-inner'; + // We’ll later toggle “shoppable-panel-visible” on player.el() to slide this in/out + bar.appendChild(inner); + + // 5) Build the toggle button + const toggle = document.createElement('a'); + toggle.className = 'cld-spbl-toggle base-color-bg'; + toggle.setAttribute('tabindex', '0'); + toggle.setAttribute('role', 'button'); + toggle.setAttribute('aria-disabled', 'false'); + + // Placeholder spans (for accessibility/icons): + const placeholder1 = document.createElement('span'); + placeholder1.className = 'vjs-icon-placeholder'; + placeholder1.setAttribute('aria-hidden', 'true'); + + const placeholder2 = document.createElement('span'); + placeholder2.className = 'vjs-control-text'; + placeholder2.setAttribute('aria-live', 'polite'); + + const customIcon = document.createElement('span'); + customIcon.className = 'cld-spbl-toggle-icon cld-spbl-toggle-custom-icon vjs-icon-close'; + customIcon.style.backgroundImage = `url(${this.shoppable_.toggleIconUrl})`; + + toggle.appendChild(placeholder1); + toggle.appendChild(placeholder2); + toggle.appendChild(customIcon); + + toggle.addEventListener('click', () => this.toggleBar()); + inner.appendChild(toggle); + + // 6) The actual product‐panel container + const panel = document.createElement('div'); + panel.className = 'cld-spbl-panel base-color-bg'; + inner.appendChild(panel); + + // 7) For each product, append + this.shoppable_.products.forEach((prod, idx) => { + const item = document.createElement('a'); + item.className = 'cld-spbl-item base-color-bg accent-color-text'; + item.setAttribute('tabindex', '0'); + item.setAttribute('href', '#'); + item.setAttribute('role', 'button'); + item.setAttribute('data-product-id', prod.productId); + item.setAttribute('data-product-name', prod.productName); + + // If you want Cloudinary’s “data-hover-action” / “data-click-action” attributes: + if (prod.onHover) { + item.setAttribute('data-hover-action', prod.onHover.action); + } + if (prod.onClick) { + item.setAttribute('data-click-action', prod.onClick.action); + if (prod.onClick.args?.time) { + item.setAttribute('data-seek', prod.onClick.args.time); + if (prod.onClick.pause !== undefined) { + item.setAttribute('data-pause', String(prod.onClick.pause)); + } + } + if (prod.onClick.args?.url) { + item.setAttribute('data-goto-url', prod.onClick.args.url); + if (prod.onClick.pause !== undefined) { + item.setAttribute('data-pause', String(prod.onClick.pause)); + } + } + } + + // Accessibility placeholder spans: + const ph1 = document.createElement('span'); + ph1.className = 'vjs-icon-placeholder'; + ph1.setAttribute('aria-hidden', 'true'); + + const ph2 = document.createElement('span'); + ph2.className = 'vjs-control-text'; + ph2.setAttribute('aria-live', 'polite'); + + item.appendChild(ph1); + item.appendChild(ph2); + + // If you want the “hover overlay label” (Cloudinary does this only if + // they have data-hover-action="overlay"): + if (prod.onHover?.action === 'overlay') { + const overlaySpan = document.createElement('span'); + overlaySpan.className = 'cld-spbl-overlay text-color-semi-bg base-color-text'; + overlaySpan.setAttribute('title', `Click to see this product in the video`); + const overlayText = document.createElement('span'); + overlayText.className = 'cld-spbl-overlay-text base-color-text'; + overlayText.innerText = `Click to see this product in the video`; + overlaySpan.appendChild(overlayText); + item.appendChild(overlaySpan); + } + + // The thumbnail image + const img = document.createElement('img'); + img.className = 'cld-spbl-img'; + img.setAttribute('tabindex', '0'); + img.setAttribute('role', 'button'); + img.src = prod.imageUrl; + img.alt = prod.productName; + item.appendChild(img); + + // The “info” (title) below each thumbnail + const info = document.createElement('div'); + info.className = 'cld-spbl-item-info base-color-semi-bg text-color-text'; + const title = document.createElement('span'); + title.className = 'cld-spbl-item-title'; + title.innerText = prod.productName; + info.appendChild(title); + item.appendChild(info); + + // Hover & click event wiring: + item.addEventListener('mouseenter', () => { + this.player_.trigger('productHover', { product: prod }); + if (prod.onHover) { + this.handleInteraction(prod.onHover, prod); + } + }); + item.addEventListener('click', (evt) => { + evt.preventDefault(); + this.player_.trigger('productClick', { product: prod }); + if (prod.onClick) { + this.handleInteraction(prod.onClick, prod); + } + }); + + panel.appendChild(item); + }); + + // 8) Append the bar into the player’s root element: + this.player_.el().appendChild(bar); + this.barContainer_ = bar; + + // Ensure we start “hidden”: + this.player_.el().classList.add('shoppable-panel-hidden'); + console.log('Shoppable bar built with width:', widthPct, '%'); + } + + + private buildToggleButton() { + const toggleIcon = this.shoppable_.toggleIconUrl || 'https://ik.imagekit.io/zuqlyov9d/svgviewer-output%20(1).svg?updatedAt=1749190341946'; + const iconBtn = document.createElement('div'); + iconBtn.className = 'vjs-shoppable-toggle-icon'; + Object.assign(iconBtn.style, { + backgroundImage: `url(${toggleIcon})`, + position: 'absolute', + top: 0, + right: 0, + bottom: 0, + left: 0, + backgroundPosition: "center", + backgroundRepeat: "no-repeat", + backgroundSize: "75%", + background: 'white', + color: '#fff', + border: 'none', + padding: '8px', + cursor: 'pointer', + zIndex: '1000', + width: '24px', + height: '24px', + }); + iconBtn.addEventListener('click', () => this.toggleBar()); + this.player_.el().appendChild(iconBtn); + } + + /** Show/hide the entire product bar */ + private toggleBar() { + if (!this.barContainer_) return; + if (this.barContainer_.classList.contains('vjs-hidden')) { + this.openBar(); + } else { + this.closeBar(); + } + } + + // private openBar() { + // if (!this.barContainer_) return; + // this.barContainer_.classList.remove('vjs-hidden'); + // this.player_.trigger('productBarMax'); + // } + + // private closeBar() { + // if (!this.barContainer_) return; + // this.barContainer_.classList.add('vjs-hidden'); + // this.player_.trigger('productBarMin'); + // } + + private openBar() { + if (!this.barContainer_) return; + this.barContainer_.classList.remove('vjs-hidden'); + // Add the “visible” state so .cld-spbl-bar-inner slides left + this.player_.el().classList.add('shoppable-panel-visible'); + this.player_.el().classList.remove('shoppable-panel-hidden'); + this.player_.trigger('productBarMax'); + } + + private closeBar() { + if (!this.barContainer_) return; + // Slide it back out: + this.player_.el().classList.add('shoppable-panel-hidden'); + this.player_.el().classList.remove('shoppable-panel-visible'); + this.player_.trigger('productBarMin'); + // Optionally hide altogether after the transition: + setTimeout(() => this.barContainer_?.classList.add('vjs-hidden'), 300); + } + + + /** + * Build all “hotspot” elements in the video at their (x%, y%) positions, + * but keep them hidden initially. When the playhead is between highlightTime.start–end, + * we show the corresponding hotspot div. + */ + private buildHotspots() { + this.shoppable_.products.forEach((prod, idx) => { + if (!prod.hotspots) return; + prod.hotspots.forEach((hs: Hotspot) => { + const hotspotDiv = document.createElement('div'); + hotspotDiv.className = `vjs-shoppable-hotspot vjs-hidden`; + Object.assign(hotspotDiv.style, { + position: 'absolute', + left: hs.x, + top: hs.y, + width: '24px', + height: '24px', + borderRadius: '50%', + background: 'rgba(255,255,255,0.8)', + cursor: 'pointer', + transform: 'translate(-50%, -50%)', + zIndex: '998' + }); + + // Tooltip (hidden by default) + const tooltip = document.createElement('div'); + tooltip.className = 'vjs-shoppable-hotspot-tooltip vjs-hidden'; + tooltip.innerText = prod.productName; + Object.assign(tooltip.style, { + position: 'absolute', + whiteSpace: 'nowrap', + background: 'rgba(0,0,0,0.7)', + color: '#fff', + fontSize: '12px', + padding: '4px 6px', + borderRadius: '4px', + transform: 'translate(-50%, -100%)', + top: '-8px', + left: '50%' + }); + hotspotDiv.appendChild(tooltip); + + // Show tooltip on hover + hotspotDiv.addEventListener('mouseenter', () => { + tooltip.classList.remove('vjs-hidden'); + this.player_.trigger('productHover', { product: prod }); + if (prod.onHover) { + this.handleInteraction(prod.onHover, prod); + } + }); + hotspotDiv.addEventListener('mouseleave', () => { + tooltip.classList.add('vjs-hidden'); + }); + + // On click: maybe seek / goto / switch? + hotspotDiv.addEventListener('click', () => { + this.player_.trigger('productClick', { product: prod }); + if (prod.onClick) { + this.handleInteraction(prod.onClick, prod); + } + }); + + // Store a reference, but append to .vjs-tech (so it sits on top of the video) + this.player_.el().querySelector('.vjs-tech')?.appendChild(hotspotDiv); + // Keep it hidden until its time + this.hotspotContainers_.push(hotspotDiv); + }); + }); + } + + /** + * Called on every `timeupdate`. We look at currentTime, then: + * • Highlight the correct product thumbnail if currentTime ∈ [start, end] + * • Show or hide any matching hotspot(s) whose `time` matches + */ + private onTimeUpdate() { + const t = this.player_.currentTime(); + this.shoppable_.products.forEach((prod, idx) => { + // 1) Handle highlight in the products bar + const thumbWrapper = this.barContainer_?.querySelector( + `[data-shoppable-idx="${idx}"] img` + ) as HTMLImageElement | null; + + if (prod.highlightTime) { + const { start, end } = prod.highlightTime; + if (t >= start && t <= end) { + if (thumbWrapper) { + thumbWrapper.style.border = '2px solid #FFD700'; + } + } else { + if (thumbWrapper) { + thumbWrapper.style.border = '2px solid transparent'; + } + } + } + + // 2) Handle timed “hotspots” (hover markers on video) + // Each product can have multiple `prod.hotspots`, but each hotspot + // only appears at a specific `time: "00:02"` (we convert to seconds). + // If currentTime ≈ that time, unhide that hotspot; else hide it. + if (prod.hotspots) { + prod.hotspots.forEach((hs: Hotspot, hotspotIndex: number) => { + // Convert “00:02” to seconds once: + const hsTimeInSec = ShoppableManager.toSeconds(hs.time); + const EPS = 0.25; // show hotspot for a 250ms window + // Find the correct DOM node in this.hotspotContainers_. + // We appended in the same order as prod.hotspots above, + // so index = sum of previous product.hotspots.length + hotspotIndex. + const globalIdx = + this.shoppable_.products + .slice(0, idx) + .reduce((sum, p) => sum + (p.hotspots?.length || 0), 0) + hotspotIndex; + + const hotspotDiv = this.hotspotContainers_[globalIdx]; + if (!hotspotDiv) return; + + if (Math.abs(t - hsTimeInSec) < EPS) { + hotspotDiv.classList.remove('vjs-hidden'); + } else { + hotspotDiv.classList.add('vjs-hidden'); + } + }); + } + }); + } + + /** When video ends, show a small post‐play overlay carousel */ + private onEnded() { + if (!this.postPlayOverlay_) return; + this.postPlayOverlay_.classList.remove('vjs-hidden'); + this.player_.trigger('productHoverPost', {}); + // (If you want, each post‐play thumbnail can fire productClickPost, etc.) + } + + /** Build the HTML for the post‐play overlay */ + private buildPostPlayOverlay() { + if (!this.shoppable_.showPostPlayOverlay) return; + const overlay = document.createElement('div'); + overlay.className = 'vjs-shoppable-postplay-overlay vjs-hidden'; + Object.assign(overlay.style, { + position: 'absolute', + top: '0', + left: '0', + right: '0', + bottom: '0', + background: 'rgba(0,0,0,0.7)', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + zIndex: '1000' + }); + + // Inside, we’ll show a row of product images + const carousel = document.createElement('div'); + carousel.className = 'vjs-shoppable-postplay-carousel'; + Object.assign(carousel.style, { + display: 'flex', + flexDirection: 'row', + gap: '16px' + }); + + this.shoppable_.products.forEach(prod => { + const card = document.createElement('div'); + card.className = 'vjs-shoppable-postplay-card'; + Object.assign(card.style, { + width: '120px', + cursor: 'pointer' + }); + + const img = document.createElement('img'); + img.src = prod.imageUrl; + img.alt = prod.productName; + Object.assign(img.style, { + width: '100%', + height: 'auto', + objectFit: 'cover', + borderRadius: '8px' + }); + + const label = document.createElement('div'); + label.innerText = prod.productName; + Object.assign(label.style, { + color: '#fff', + textAlign: 'center', + marginTop: '4px', + fontSize: '14px' + }); + + card.appendChild(img); + card.appendChild(label); + card.addEventListener('click', () => { + this.player_.trigger('productClickPost', { product: prod }); + if (prod.onClick) { + this.handleInteraction(prod.onClick, prod); + } + }); + + carousel.appendChild(card); + }); + + // A small “close” button + const closeBtn = document.createElement('button'); + closeBtn.className = 'vjs-shoppable-postplay-close'; + closeBtn.innerHTML = '✕'; // × + Object.assign(closeBtn.style, { + position: 'absolute', + top: '16px', + right: '16px', + background: 'transparent', + border: 'none', + color: '#fff', + fontSize: '24px', + cursor: 'pointer' + }); + closeBtn.addEventListener('click', () => { + overlay.classList.add('vjs-hidden'); + }); + + overlay.appendChild(closeBtn); + overlay.appendChild(carousel); + this.postPlayOverlay_ = overlay; + this.player_.el().appendChild(overlay); + } + + /** + * Convert “mm:ss” or “hh:mm:ss” → seconds + */ + private static toSeconds(time: string): number { + const parts = time.split(':').map((p) => parseFloat(p)); + if (parts.length === 3) { + return parts[0] * 3600 + parts[1] * 60 + parts[2]; + } else if (parts.length === 2) { + return parts[0] * 60 + parts[1]; + } else { + return parseFloat(time); + } + } + + /** + * Handle “seek” / “goto” / “switch” / “overlay” actions. Whenever a spot is clicked/hovered, + * we examine `action` and `args` from the interface and carry out the required behavior. + */ + private handleInteraction(interaction: InteractionProps, product: ProductProps) { + const { action, pause, args } = interaction; + + switch (action) { + case 'overlay': + // Just show a small alert or pause + // You could also show a custom overlay inside the player + if (typeof pause === 'number') { + this.player_.pause(); + setTimeout(() => this.player_.play(), pause * 1000); + } + break; + + case 'seek': + // Pause if requested, then seek the player to args.time + if (args?.time) { + const seekTo = ShoppableManager.toSeconds(args.time); + this.player_.currentTime(seekTo); + if (pause === true || typeof pause === 'number') { + this.player_.pause(); + } + } + break; + + case 'goto': + // Pause if requested, then navigate browser to args.url + if (args?.url) { + if (pause) { + this.player_.pause(); + } + window.open(args.url, '_blank'); + } + break; + + case 'switch': + // Swap out the thumbnail in the bar or hotspot to args.url + if (args?.url) { + // Find all elements where prod.productId matches: + // We gave each thumbWrapper a data-shoppable-idx, so: + this.barContainer_?.querySelectorAll(`div[data-shoppable-idx] img`) + .forEach((imgEl: Element) => { + const idx = Number((imgEl.parentElement as HTMLElement)?.getAttribute('data-shoppable-idx')); + if (this.shoppable_.products[idx].productId === product.productId) { + (imgEl as HTMLImageElement).src = args.url; + } + }); + } + break; + + default: + break; + } + } + + /** Tear down everything (called when source changes or plugin is destroyed) */ + public destroy() { + if (this.tickHandler_) { + this.player_.off('timeupdate', this.tickHandler_); + this.tickHandler_ = null; + } + if (this.endedHandler_) { + this.player_.off('ended', this.endedHandler_); + this.endedHandler_ = null; + } + // Remove DOM elements + this.barContainer_?.remove(); + this.hotspotContainers_.forEach((div) => div.remove()); + this.postPlayOverlay_?.remove(); + } +} \ No newline at end of file diff --git a/packages/javascript/modules/subtitles/sample.transcript b/packages/javascript/modules/subtitles/sample.transcript new file mode 100644 index 0000000..73f6d29 --- /dev/null +++ b/packages/javascript/modules/subtitles/sample.transcript @@ -0,0 +1 @@ +[{"transcript":" We're going to have a previous EVO Champion in all of our matches here.","confidence":0.5750714285714286,"words":[{"word":"We're","start_time":0.08,"end_time":0.201},{"word":"going","start_time":0.261,"end_time":0.503},{"word":"to","start_time":0.523,"end_time":0.603},{"word":"have","start_time":0.623,"end_time":0.965},{"word":"a","start_time":0.985,"end_time":1.046},{"word":"previous","start_time":1.388,"end_time":1.81},{"word":"EVO","start_time":1.87,"end_time":2.051},{"word":"Champion","start_time":2.112,"end_time":2.554},{"word":"in","start_time":2.594,"end_time":2.675},{"word":"all","start_time":2.816,"end_time":2.936},{"word":"of","start_time":2.956,"end_time":2.997},{"word":"our","start_time":3.077,"end_time":3.157},{"word":"matches","start_time":3.178,"end_time":3.499},{"word":"here.","start_time":3.519,"end_time":3.62}],"alternatives":[],"language":"en"},{"transcript":" This is actually a very similar situation, right?","confidence":0.6392499999999999,"words":[{"word":"This","start_time":3.903,"end_time":4.004},{"word":"is","start_time":4.044,"end_time":4.125},{"word":"actually","start_time":4.186,"end_time":4.429},{"word":"a","start_time":4.45,"end_time":4.47},{"word":"very","start_time":4.51,"end_time":4.632},{"word":"similar","start_time":4.672,"end_time":4.875},{"word":"situation,","start_time":4.895,"end_time":5.118},{"word":"right?","start_time":5.138,"end_time":5.24}],"alternatives":[],"language":"en"},{"transcript":" Previous EVO Champion versus a player from another country","confidence":0.6956666666666665,"words":[{"word":"Previous","start_time":5.601,"end_time":5.924},{"word":"EVO","start_time":6.004,"end_time":6.185},{"word":"Champion","start_time":6.225,"end_time":6.668},{"word":"versus","start_time":6.809,"end_time":7.212},{"word":"a","start_time":7.232,"end_time":7.252},{"word":"player","start_time":7.453,"end_time":7.735},{"word":"from","start_time":7.776,"end_time":7.916},{"word":"another","start_time":7.977,"end_time":8.198},{"word":"country","start_time":8.239,"end_time":8.46}],"alternatives":[],"language":"en"},{"transcript":" that has been proven to be one of the best players in the entire world","confidence":0.7933999999999999,"words":[{"word":"that","start_time":8.661,"end_time":8.782},{"word":"has","start_time":8.802,"end_time":8.943},{"word":"been","start_time":9.244,"end_time":9.405},{"word":"proven","start_time":9.727,"end_time":10.149},{"word":"to","start_time":10.21,"end_time":10.29},{"word":"be","start_time":10.31,"end_time":10.431},{"word":"one","start_time":10.511,"end_time":10.592},{"word":"of","start_time":10.612,"end_time":10.652},{"word":"the","start_time":10.672,"end_time":10.733},{"word":"best","start_time":10.773,"end_time":10.914},{"word":"players","start_time":10.954,"end_time":11.235},{"word":"in","start_time":11.296,"end_time":11.356},{"word":"the","start_time":11.376,"end_time":11.457},{"word":"entire","start_time":11.497,"end_time":11.879},{"word":"world","start_time":11.939,"end_time":12.06}],"alternatives":[],"language":"en"},{"transcript":" time and time again.","confidence":0.70325,"words":[{"word":"time","start_time":12.783,"end_time":12.989},{"word":"and","start_time":13.01,"end_time":13.072},{"word":"time","start_time":13.113,"end_time":13.236},{"word":"again.","start_time":13.257,"end_time":13.36}],"alternatives":[],"language":"en"},{"transcript":" So this is a great one to see.","confidence":0.698625,"words":[{"word":"So","start_time":13.601,"end_time":13.742},{"word":"this","start_time":13.763,"end_time":13.944},{"word":"is","start_time":14.147,"end_time":14.227},{"word":"a","start_time":14.248,"end_time":14.268},{"word":"great","start_time":14.732,"end_time":15.076},{"word":"one","start_time":15.137,"end_time":15.238},{"word":"to","start_time":15.278,"end_time":15.339},{"word":"see.","start_time":15.359,"end_time":15.46}],"alternatives":[],"language":"en"},{"transcript":" And Sonic Fox, you know, kind of, I think a lot of people were unsure how they were going to be playing throughout today.","confidence":0.6255416666666668,"words":[{"word":"And","start_time":16.071,"end_time":16.192},{"word":"Sonic","start_time":16.733,"end_time":17.014},{"word":"Fox,","start_time":17.034,"end_time":17.235},{"word":"you","start_time":17.255,"end_time":17.335},{"word":"know,","start_time":17.355,"end_time":17.476},{"word":"kind","start_time":17.958,"end_time":18.198},{"word":"of,","start_time":18.259,"end_time":18.299},{"word":"I","start_time":18.319,"end_time":18.339},{"word":"think","start_time":18.359,"end_time":18.961},{"word":"a","start_time":19.001,"end_time":19.021},{"word":"lot","start_time":19.041,"end_time":19.142},{"word":"of","start_time":19.162,"end_time":19.222},{"word":"people","start_time":19.242,"end_time":19.402},{"word":"were","start_time":19.422,"end_time":19.563},{"word":"unsure","start_time":19.643,"end_time":20.025},{"word":"how","start_time":20.105,"end_time":20.245},{"word":"they","start_time":20.285,"end_time":20.386},{"word":"were","start_time":20.406,"end_time":20.526},{"word":"going","start_time":20.546,"end_time":20.687},{"word":"to","start_time":20.707,"end_time":20.747},{"word":"be","start_time":20.787,"end_time":20.867},{"word":"playing","start_time":20.928,"end_time":21.269},{"word":"throughout","start_time":21.289,"end_time":21.53},{"word":"today.","start_time":21.55,"end_time":21.65}],"alternatives":[],"language":"en"},{"transcript":" There are a lot of games to focus on at this EVO, including Skullgirls, of course, which is, Sonic Fox is like tried and true, but obviously one match away from winner's top eight, so they're doing pretty well.","confidence":0.7377692307692306,"words":[{"word":"There","start_time":22.311,"end_time":22.411},{"word":"are","start_time":22.431,"end_time":22.491},{"word":"a","start_time":22.511,"end_time":22.531},{"word":"lot","start_time":22.591,"end_time":22.752},{"word":"of","start_time":22.772,"end_time":22.832},{"word":"games","start_time":22.912,"end_time":23.313},{"word":"to","start_time":23.453,"end_time":23.693},{"word":"focus","start_time":23.834,"end_time":24.174},{"word":"on","start_time":24.295,"end_time":24.395},{"word":"at","start_time":24.435,"end_time":24.495},{"word":"this","start_time":24.535,"end_time":24.675},{"word":"EVO,","start_time":24.816,"end_time":25.116},{"word":"including","start_time":25.196,"end_time":25.577},{"word":"Skullgirls,","start_time":25.617,"end_time":26.038},{"word":"of","start_time":26.078,"end_time":26.138},{"word":"course,","start_time":26.178,"end_time":26.399},{"word":"which","start_time":26.439,"end_time":26.579},{"word":"is,","start_time":26.759,"end_time":26.879},{"word":"Sonic","start_time":27.24,"end_time":27.481},{"word":"Fox","start_time":27.501,"end_time":27.661},{"word":"is","start_time":27.701,"end_time":27.781},{"word":"like","start_time":27.801,"end_time":27.921},{"word":"tried","start_time":27.982,"end_time":28.282},{"word":"and","start_time":28.302,"end_time":28.382},{"word":"true,","start_time":28.422,"end_time":28.683},{"word":"but","start_time":29.104,"end_time":29.344},{"word":"obviously","start_time":29.845,"end_time":30.226},{"word":"one","start_time":30.286,"end_time":30.366},{"word":"match","start_time":30.386,"end_time":30.546},{"word":"away","start_time":30.566,"end_time":30.687},{"word":"from","start_time":30.707,"end_time":30.787},{"word":"winner's","start_time":30.827,"end_time":31.007},{"word":"top","start_time":31.027,"end_time":31.228},{"word":"eight,","start_time":31.288,"end_time":31.448},{"word":"so","start_time":31.508,"end_time":31.729},{"word":"they're","start_time":31.789,"end_time":32.009},{"word":"doing","start_time":32.029,"end_time":32.169},{"word":"pretty","start_time":32.19,"end_time":32.31},{"word":"well.","start_time":32.33,"end_time":32.45}],"alternatives":[],"language":"en"},{"transcript":" Well, I would say.","confidence":0.61975,"words":[{"word":"Well,","start_time":32.583,"end_time":32.829},{"word":"I","start_time":32.912,"end_time":32.973},{"word":"would","start_time":32.994,"end_time":33.138},{"word":"say.","start_time":33.158,"end_time":33.22}],"alternatives":[],"language":"en"},{"transcript":" Yeah, SonicFox, it's interesting because when","confidence":0.6903333333333332,"words":[{"word":"Yeah,","start_time":33.843,"end_time":34.085},{"word":"SonicFox,","start_time":34.125,"end_time":34.649},{"word":"it's","start_time":35.152,"end_time":35.293},{"word":"interesting","start_time":35.354,"end_time":35.756},{"word":"because","start_time":35.797,"end_time":36.079},{"word":"when","start_time":36.099,"end_time":36.179}],"alternatives":[],"language":"en"}] \ No newline at end of file diff --git a/packages/javascript/modules/subtitles/subtitles.css b/packages/javascript/modules/subtitles/subtitles.css new file mode 100644 index 0000000..e3f107c --- /dev/null +++ b/packages/javascript/modules/subtitles/subtitles.css @@ -0,0 +1,4 @@ +/* highlight style – user can override via CSS variables */ +.vjs-text-track-display span.vjs-word-highlight { + background: var(--vjs-word-highlight-color, yellow); +} \ No newline at end of file diff --git a/packages/javascript/modules/subtitles/subtitles.ts b/packages/javascript/modules/subtitles/subtitles.ts new file mode 100644 index 0000000..c0b6d3e --- /dev/null +++ b/packages/javascript/modules/subtitles/subtitles.ts @@ -0,0 +1,264 @@ +// modules/subtitles/subtitles.ts +import videojs from 'video.js'; +import type Player from 'video.js/dist/types/player'; +import type HTMLTrackElement from 'video.js/dist/types/tracks/html-track-element.d.ts'; +import type { AutoGeneratedTextTrackOptions, RemoteTextTrackOptions, SourceOptions, TextTrackOptions } from '../../interfaces'; +// import { Parser } from 'vtt.js'; // Import Parser from vtt.js + + +declare module 'video.js' { + interface HtmlTrackElement { + // Video.js exposes trackEl.track as a TextTrack + track: TextTrack; + } +} + + +// declare global { +// interface Window { +// WebVTT: { +// Parser: typeof Parser; // you can import Parser’s type from vtt.js if available +// StringDecoder: () => any; +// }; +// } +// } + + +// Word‐level transcript entry +interface WordTiming { + word: string; + start_time: number; + end_time: number; +} + +// Full transcript JSON entry +interface TranscriptEntry { + transcript: string; + confidence: number; + words: WordTiming[]; + language?: string; +} + +// Exported for testing purposes +export const log = videojs.log.createLogger('videojs-playlist'); + +// Setup subtitles: fetch or parse, build VTTCues, optional highlighting +async function setupSubtitles(params: { + player: Player; + opts: RemoteTextTrackOptions; + currentSource?: SourceOptions; +}): Promise { + let { player, opts, currentSource } = params; + const { maxWords = 4, wordHighlight } = opts; + + // artifical delay to simulate loading + // await new Promise(resolve => setTimeout(resolve, 10000)); + + // 1️. Fetch the transcript JSON + // const raw = await fetch(currentSource.src!).then(r => { + // if (!r.ok) throw new Error('Failed to fetch transcript'); + // return r.text(); + // }); + const raw = [{ "transcript": " We're going to have a previous EVO Champion in all of our matches here.", "confidence": 0.5750714285714286, "words": [{ "word": "We're", "start_time": 0.08, "end_time": 0.201 }, { "word": "going", "start_time": 0.261, "end_time": 0.503 }, { "word": "to", "start_time": 0.523, "end_time": 0.603 }, { "word": "have", "start_time": 0.623, "end_time": 0.965 }, { "word": "a", "start_time": 0.985, "end_time": 1.046 }, { "word": "previous", "start_time": 1.388, "end_time": 1.81 }, { "word": "EVO", "start_time": 1.87, "end_time": 2.051 }, { "word": "Champion", "start_time": 2.112, "end_time": 2.554 }, { "word": "in", "start_time": 2.594, "end_time": 2.675 }, { "word": "all", "start_time": 2.816, "end_time": 2.936 }, { "word": "of", "start_time": 2.956, "end_time": 2.997 }, { "word": "our", "start_time": 3.077, "end_time": 3.157 }, { "word": "matches", "start_time": 3.178, "end_time": 3.499 }, { "word": "here.", "start_time": 3.519, "end_time": 3.62 }], "alternatives": [], "language": "en" }, { "transcript": " This is actually a very similar situation, right?", "confidence": 0.6392499999999999, "words": [{ "word": "This", "start_time": 3.903, "end_time": 4.004 }, { "word": "is", "start_time": 4.044, "end_time": 4.125 }, { "word": "actually", "start_time": 4.186, "end_time": 4.429 }, { "word": "a", "start_time": 4.45, "end_time": 4.47 }, { "word": "very", "start_time": 4.51, "end_time": 4.632 }, { "word": "similar", "start_time": 4.672, "end_time": 4.875 }, { "word": "situation,", "start_time": 4.895, "end_time": 5.118 }, { "word": "right?", "start_time": 5.138, "end_time": 5.24 }], "alternatives": [], "language": "en" }, { "transcript": " Previous EVO Champion versus a player from another country", "confidence": 0.6956666666666665, "words": [{ "word": "Previous", "start_time": 5.601, "end_time": 5.924 }, { "word": "EVO", "start_time": 6.004, "end_time": 6.185 }, { "word": "Champion", "start_time": 6.225, "end_time": 6.668 }, { "word": "versus", "start_time": 6.809, "end_time": 7.212 }, { "word": "a", "start_time": 7.232, "end_time": 7.252 }, { "word": "player", "start_time": 7.453, "end_time": 7.735 }, { "word": "from", "start_time": 7.776, "end_time": 7.916 }, { "word": "another", "start_time": 7.977, "end_time": 8.198 }, { "word": "country", "start_time": 8.239, "end_time": 8.46 }], "alternatives": [], "language": "en" }, { "transcript": " that has been proven to be one of the best players in the entire world", "confidence": 0.7933999999999999, "words": [{ "word": "that", "start_time": 8.661, "end_time": 8.782 }, { "word": "has", "start_time": 8.802, "end_time": 8.943 }, { "word": "been", "start_time": 9.244, "end_time": 9.405 }, { "word": "proven", "start_time": 9.727, "end_time": 10.149 }, { "word": "to", "start_time": 10.21, "end_time": 10.29 }, { "word": "be", "start_time": 10.31, "end_time": 10.431 }, { "word": "one", "start_time": 10.511, "end_time": 10.592 }, { "word": "of", "start_time": 10.612, "end_time": 10.652 }, { "word": "the", "start_time": 10.672, "end_time": 10.733 }, { "word": "best", "start_time": 10.773, "end_time": 10.914 }, { "word": "players", "start_time": 10.954, "end_time": 11.235 }, { "word": "in", "start_time": 11.296, "end_time": 11.356 }, { "word": "the", "start_time": 11.376, "end_time": 11.457 }, { "word": "entire", "start_time": 11.497, "end_time": 11.879 }, { "word": "world", "start_time": 11.939, "end_time": 12.06 }], "alternatives": [], "language": "en" }, { "transcript": " time and time again.", "confidence": 0.70325, "words": [{ "word": "time", "start_time": 12.783, "end_time": 12.989 }, { "word": "and", "start_time": 13.01, "end_time": 13.072 }, { "word": "time", "start_time": 13.113, "end_time": 13.236 }, { "word": "again.", "start_time": 13.257, "end_time": 13.36 }], "alternatives": [], "language": "en" }, { "transcript": " So this is a great one to see.", "confidence": 0.698625, "words": [{ "word": "So", "start_time": 13.601, "end_time": 13.742 }, { "word": "this", "start_time": 13.763, "end_time": 13.944 }, { "word": "is", "start_time": 14.147, "end_time": 14.227 }, { "word": "a", "start_time": 14.248, "end_time": 14.268 }, { "word": "great", "start_time": 14.732, "end_time": 15.076 }, { "word": "one", "start_time": 15.137, "end_time": 15.238 }, { "word": "to", "start_time": 15.278, "end_time": 15.339 }, { "word": "see.", "start_time": 15.359, "end_time": 15.46 }], "alternatives": [], "language": "en" }, { "transcript": " And Sonic Fox, you know, kind of, I think a lot of people were unsure how they were going to be playing throughout today.", "confidence": 0.6255416666666668, "words": [{ "word": "And", "start_time": 16.071, "end_time": 16.192 }, { "word": "Sonic", "start_time": 16.733, "end_time": 17.014 }, { "word": "Fox,", "start_time": 17.034, "end_time": 17.235 }, { "word": "you", "start_time": 17.255, "end_time": 17.335 }, { "word": "know,", "start_time": 17.355, "end_time": 17.476 }, { "word": "kind", "start_time": 17.958, "end_time": 18.198 }, { "word": "of,", "start_time": 18.259, "end_time": 18.299 }, { "word": "I", "start_time": 18.319, "end_time": 18.339 }, { "word": "think", "start_time": 18.359, "end_time": 18.961 }, { "word": "a", "start_time": 19.001, "end_time": 19.021 }, { "word": "lot", "start_time": 19.041, "end_time": 19.142 }, { "word": "of", "start_time": 19.162, "end_time": 19.222 }, { "word": "people", "start_time": 19.242, "end_time": 19.402 }, { "word": "were", "start_time": 19.422, "end_time": 19.563 }, { "word": "unsure", "start_time": 19.643, "end_time": 20.025 }, { "word": "how", "start_time": 20.105, "end_time": 20.245 }, { "word": "they", "start_time": 20.285, "end_time": 20.386 }, { "word": "were", "start_time": 20.406, "end_time": 20.526 }, { "word": "going", "start_time": 20.546, "end_time": 20.687 }, { "word": "to", "start_time": 20.707, "end_time": 20.747 }, { "word": "be", "start_time": 20.787, "end_time": 20.867 }, { "word": "playing", "start_time": 20.928, "end_time": 21.269 }, { "word": "throughout", "start_time": 21.289, "end_time": 21.53 }, { "word": "today.", "start_time": 21.55, "end_time": 21.65 }], "alternatives": [], "language": "en" }, { "transcript": " There are a lot of games to focus on at this EVO, including Skullgirls, of course, which is, Sonic Fox is like tried and true, but obviously one match away from winner's top eight, so they're doing pretty well.", "confidence": 0.7377692307692306, "words": [{ "word": "There", "start_time": 22.311, "end_time": 22.411 }, { "word": "are", "start_time": 22.431, "end_time": 22.491 }, { "word": "a", "start_time": 22.511, "end_time": 22.531 }, { "word": "lot", "start_time": 22.591, "end_time": 22.752 }, { "word": "of", "start_time": 22.772, "end_time": 22.832 }, { "word": "games", "start_time": 22.912, "end_time": 23.313 }, { "word": "to", "start_time": 23.453, "end_time": 23.693 }, { "word": "focus", "start_time": 23.834, "end_time": 24.174 }, { "word": "on", "start_time": 24.295, "end_time": 24.395 }, { "word": "at", "start_time": 24.435, "end_time": 24.495 }, { "word": "this", "start_time": 24.535, "end_time": 24.675 }, { "word": "EVO,", "start_time": 24.816, "end_time": 25.116 }, { "word": "including", "start_time": 25.196, "end_time": 25.577 }, { "word": "Skullgirls,", "start_time": 25.617, "end_time": 26.038 }, { "word": "of", "start_time": 26.078, "end_time": 26.138 }, { "word": "course,", "start_time": 26.178, "end_time": 26.399 }, { "word": "which", "start_time": 26.439, "end_time": 26.579 }, { "word": "is,", "start_time": 26.759, "end_time": 26.879 }, { "word": "Sonic", "start_time": 27.24, "end_time": 27.481 }, { "word": "Fox", "start_time": 27.501, "end_time": 27.661 }, { "word": "is", "start_time": 27.701, "end_time": 27.781 }, { "word": "like", "start_time": 27.801, "end_time": 27.921 }, { "word": "tried", "start_time": 27.982, "end_time": 28.282 }, { "word": "and", "start_time": 28.302, "end_time": 28.382 }, { "word": "true,", "start_time": 28.422, "end_time": 28.683 }, { "word": "but", "start_time": 29.104, "end_time": 29.344 }, { "word": "obviously", "start_time": 29.845, "end_time": 30.226 }, { "word": "one", "start_time": 30.286, "end_time": 30.366 }, { "word": "match", "start_time": 30.386, "end_time": 30.546 }, { "word": "away", "start_time": 30.566, "end_time": 30.687 }, { "word": "from", "start_time": 30.707, "end_time": 30.787 }, { "word": "winner's", "start_time": 30.827, "end_time": 31.007 }, { "word": "top", "start_time": 31.027, "end_time": 31.228 }, { "word": "eight,", "start_time": 31.288, "end_time": 31.448 }, { "word": "so", "start_time": 31.508, "end_time": 31.729 }, { "word": "they're", "start_time": 31.789, "end_time": 32.009 }, { "word": "doing", "start_time": 32.029, "end_time": 32.169 }, { "word": "pretty", "start_time": 32.19, "end_time": 32.31 }, { "word": "well.", "start_time": 32.33, "end_time": 32.45 }], "alternatives": [], "language": "en" }, { "transcript": " Well, I would say.", "confidence": 0.61975, "words": [{ "word": "Well,", "start_time": 32.583, "end_time": 32.829 }, { "word": "I", "start_time": 32.912, "end_time": 32.973 }, { "word": "would", "start_time": 32.994, "end_time": 33.138 }, { "word": "say.", "start_time": 33.158, "end_time": 33.22 }], "alternatives": [], "language": "en" }, { "transcript": " Yeah, SonicFox, it's interesting because when", "confidence": 0.6903333333333332, "words": [{ "word": "Yeah,", "start_time": 33.843, "end_time": 34.085 }, { "word": "SonicFox,", "start_time": 34.125, "end_time": 34.649 }, { "word": "it's", "start_time": 35.152, "end_time": 35.293 }, { "word": "interesting", "start_time": 35.354, "end_time": 35.756 }, { "word": "because", "start_time": 35.797, "end_time": 36.079 }, { "word": "when", "start_time": 36.099, "end_time": 36.179 }], "alternatives": [], "language": "en" }] + const entries = raw as TranscriptEntry[]; + + // 2. Flatten into word list + const allWords = entries.flatMap(e => e.words); + + // 3. Build WebVTT string + let vtt = 'WEBVTT\n\n'; + + const fmt = (sec: number) => { + const ms = Math.floor((sec % 1) * 1000).toString().padStart(3, '0'); + const s = Math.floor(sec % 60).toString().padStart(2, '0'); + const m = Math.floor((sec / 60) % 60).toString().padStart(2, '0'); + const h = Math.floor(sec / 3600).toString().padStart(2, '0'); + return `${h}:${m}:${s}.${ms}`; + }; + + for (let i = 0; i < allWords.length; i += maxWords) { + const block = allWords.slice(i, i + maxWords); + const start = fmt(block[0].start_time); + const end = fmt(block[block.length - 1].end_time); + + // Wrap each word in a span for highlighting + const text = block + .map((w, idx) => `${w.word}`) + .join(' '); + + vtt += `${start} --> ${end}\n${text}\n\n`; + } + + // 4️. Create a blob URL + const blob = new Blob([vtt], { type: 'text/vtt' }); + const srcUrl = URL.createObjectURL(blob); + opts = { + src: srcUrl, + kind: 'subtitles', + srclang: entries[0].language || 'en', + label: 'AI Gen Subtitles', + default: true, + } + + if (wordHighlight) { + let subtitleSyncInterval: any; + + player.on('play', () => { + subtitleSyncInterval = setInterval(() => { + const time = player.currentTime(); + const display = player.el().querySelector('.vjs-text-track-display'); + if (!display) return; + + display.querySelectorAll('span[class^="word-"], span[class*=" word-"]').forEach(el => { + const cls = Array.from(el.classList) + .find(c => c.startsWith('word-'))!; + const idx = Number(cls.split('-')[1]); + const w = allWords[idx]; + el.classList.toggle('vjs-word-highlight', (time ?? 0) >= w.start_time && (time ?? 0) <= w.end_time); + }); + }, 50); // every 100ms + }); + + player.on('pause', () => { + clearInterval(subtitleSyncInterval); + }); + player.on('ended', () => { + clearInterval(subtitleSyncInterval); + }); + } + return opts; +} + +function hasSrc(opts: RemoteTextTrackOptions): opts is TextTrackOptions & { src: string } { + return typeof (opts as any).src === 'string'; +} + +function isAutoGenerate(opts: RemoteTextTrackOptions): opts is AutoGeneratedTextTrackOptions { + return (opts as any).autoGenerateSubtitles === true; +} + + +export function validateRemoteTextTrackOptions(opts: RemoteTextTrackOptions): void { + // Invalid: src/kind/srclang/label with auto-generation + if (isAutoGenerate(opts)) { + const conflicting = ['src', 'kind', 'srclang', 'label'].filter(key => key in opts); + if (conflicting.length > 0) { + throw new Error( + `Invalid RemoteTextTrackOptions: Cannot specify [${conflicting.join(', ')}] when autoGenerateSubtitles is true.` + ); + } + + if (opts.translate?.length && (opts.maxWords || opts.wordHighlight)) { + throw new Error( + `Invalid RemoteTextTrackOptions: Cannot use maxWords or wordHighlight when translate is provided.` + ); + } + } + // Invalid: if src is not a transcript URL, and maxWords/wordHighlight is set + if (hasSrc(opts) && (opts.maxWords || opts.wordHighlight)) { + const srcURL = new URL(opts.src); + const isTranscript = srcURL.pathname.endsWith('.transcript'); + if (!isTranscript) { + throw new Error( + `Invalid RemoteTextTrackOptions: src must be a transcript URL when using maxWords or wordHighlight.` + ); + } + } +} + + +export function overrideAddRemoteTextTrack( + player: Player) { + const orig = player.addRemoteTextTrack.bind(player); + + player.addRemoteTextTrack = ( + options: RemoteTextTrackOptions, + manualCleanup?: boolean + ) => { + + // 1) Validate options + try { + validateRemoteTextTrackOptions(options); + } catch (err) { + player.error(err instanceof Error ? err.message : String(err)); + } + + // 2) Check if src is a transcript URL + const srcURL = hasSrc(options) ? new URL(options.src) : null; + const isTranscript = srcURL && srcURL.pathname.endsWith('.transcript'); + + // 3) Create the track element immediately + const trackEl = orig({ + kind: 'subtitles', + src: 'src' in options && !isTranscript && options.src || '', + srclang: 'srclang' in options && options.srclang || 'en', + label: 'label' in options && options.label || 'Loading…', + default: options.default ?? false, + }, manualCleanup) as HTMLTrackElement; + + // 4) if we need to auto‐generate, fire off the async work now + if (isAutoGenerate(options) || isTranscript) { + setupSubtitles({ player: player, opts: options }) + .then(async (finalOpts) => { + // finalOpts.src is a Blob URL with your full VTT + const src = finalOpts.src!; + const vttText = await fetch(src).then(r => r.text()); + + // 3) parse the VTT yourself and add cues to the same TextTrack + // (video.js bundles vtt.js under window.WebVTT) + const parser = new (window as any).WebVTT.Parser( + window, + //@ts-ignore + WebVTT.StringDecoder() + ); + const cues: TextTrackCue[] = []; + parser.oncue = (c: TextTrackCue) => cues.push(c); + parser.parse(vttText); + parser.flush(); + + // 4) clear out the “Loading…” label and replace with your real one + trackEl.label = finalOpts.label; + trackEl.srclang = finalOpts.srclang; + // 5) enable the track + //@ts-ignore + trackEl.track!.mode = 'hidden'; + + // 6) add all the real cues + //@ts-ignore + cues.forEach(cue => trackEl.track!.addCue(cue)); + //@ts-ignore + trackEl.track.default = true; + //@ts-ignore + trackEl.track.label = finalOpts.label; + + //@ts-ignore + const tt = trackEl.track!; + + // 1) force it to show + tt.mode = 'showing'; + + // 2) turn _off_ every other textTrack so yours is the only one + //@ts-ignore + for (const other of Array.from(player.textTracks())) { + if (other !== tt) { + //@ts-ignore + other.mode = 'disabled'; + } + } + + // 7) finally, rebuild the Subs/Caps button so it picks up the new label/default + // @ts-ignore + const ccButton = player.controlBar.getChild('SubsCapsButton') as any; + if (ccButton && typeof ccButton.update === 'function') { + ccButton.update(); + } + }) + .catch(err => { + player.error(err instanceof Error ? err.message : String(err)); + }); + } + + // 7) return the *same* HTMLTrackElement you just mutated later + return trackEl; + }; +} \ No newline at end of file diff --git a/packages/javascript/styles/index.scss b/packages/javascript/styles/index.scss new file mode 100644 index 0000000..eac62db --- /dev/null +++ b/packages/javascript/styles/index.scss @@ -0,0 +1,15 @@ +// import videojs css +@use "video.js/dist/video-js.css"; + +// @use all modules +@use "../modules/http-source-selector/plugin.scss"; + +@use "../modules/playlist/playlist-ui.scss"; + +@use "../modules/chapters/chapter.css"; + +@use "../modules/recommendations-overlay/recommendation-overlay.css"; + +@use "../modules/seek-thumbnails/seek-thumbnails.css"; + +@use "../modules/subtitles/subtitles.css"; \ No newline at end of file diff --git a/packages/javascript/types/videojs-extensions.d.ts b/packages/javascript/types/videojs-extensions.d.ts new file mode 100644 index 0000000..a6e3ae4 --- /dev/null +++ b/packages/javascript/types/videojs-extensions.d.ts @@ -0,0 +1,15 @@ +// types/videojs-extensions.d.ts + +import type { PlayerOptions } from '../interfaces'; + +declare module 'video.js' { + interface Player { + imagekitVideoPlayer(options: PlayerOptions): void; + } + + export interface VideoJsPlayerPluginOptions { + imagekitVideoPlayer?: PlayerOptions; + } +} + +export { } \ No newline at end of file diff --git a/packages/javascript/utils.ts b/packages/javascript/utils.ts new file mode 100644 index 0000000..6dcc012 --- /dev/null +++ b/packages/javascript/utils.ts @@ -0,0 +1,494 @@ +// helpers.ts +import { buildSrc as ikBuild } from '@imagekit/javascript'; +import type { PlayerOptions, Transformation } from './interfaces'; +import type { SourceOptions } from './interfaces'; +import type { ABSOptions } from './interfaces'; +import { StreamingResolution } from '@imagekit/javascript/dist/interfaces'; + +/** + * Take a single input (string or SourceOptions), + * apply ABS suffix & transformations, run through signerFn if provided, + * then build & sign the final ImageKit URL. + */ +export async function prepareSource( + input: string | SourceOptions, + opts: PlayerOptions +): Promise { + // turn plain strings into a minimal SourceOptions + let source: SourceOptions = + typeof input === 'string' ? { src: input } : { ...input }; + + // 1️⃣ Add ABS suffix + add streamingResolutions to transformation + const { src: absSrc, transformation: absTransforms } = + addABSSuffixToSrcURL(source, opts); + source.src = absSrc; + + // 2️⃣ Finally build the ImageKit URL + source.src = ikBuild({ + src: source.src, + urlEndpoint: '', // you could inject opts.urlEndpoint here + transformation: absTransforms ?? [], + }); + + // 3️⃣ If they passed a signerFn, sign the built URL + if (opts.signerFn) { + try { + source.src = await opts.signerFn(source.src); + } catch (err) { + throw new Error(`Signing failed: ${err}`); + } + } + + return source; +} + +/** + * Flatten whatever the integrator passed (.src could be string, object, or array) + * into a uniform array of SourceOptions. + */ +export function normalizeInput( + input: string | SourceOptions | Array +): Array { + if (Array.isArray(input)) return input; + return [input]; +} + + +/** + * Poll a URL until it’s ready (HTTP 200) or give up. + * + * @param url The fully-built (and signed) video URL + * @param maxTries From PlayerOptions.maxTries + * @param timeoutMs From PlayerOptions.videoTimeoutInMS + * @param fixedDelayMs From PlayerOptions.delayInMS, or undefined for exponential + */ +export async function waitForVideoReady( + url: string, + maxTries: number, + timeoutMs: number, + fixedDelayMs?: number +): Promise { + for (let attempt = 1; attempt <= maxTries; attempt++) { + // 1️⃣ Attempt fetch with timeout + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + let res: Response; + try { + res = await fetch(url, { + method: 'HEAD', // or "GET" if your server rejects HEAD + signal: controller.signal + }); + } catch (err) { + clearTimeout(timer); + return; + } + clearTimeout(timer); + + if (res.status === 202) { + // still processing → retry + if (attempt === maxTries) break; + // compute delay + const delay = fixedDelayMs != null + ? fixedDelayMs + : 10000 * Math.pow(2, attempt - 1); // 10s, 20s, 40s… + await new Promise(r => setTimeout(r, delay)); + continue; + } + else { + return; // doesn't matter any other status code, we only care about 202 + } + } + + // if we get here, we exhausted tries + throw new Error(`Video unavailable after ${maxTries} attempts`); +} + + +/** + * If ABS is configured (on the source or globally), + * append the proper HLS/DASH suffix and push a + * `streamingResolutions` step into the transformation. + */ +export function addABSSuffixToSrcURL( + input: string | SourceOptions, + opts: PlayerOptions +): { src: string; transformation: Transformation[] } { + // start from either the input.src or string + const baseUrl = typeof input === 'string' ? input : input.src; + const url = new URL(baseUrl); + + // ABS can be on the per-source or global options + const absOpts: ABSOptions | undefined = + typeof input === 'object' && input.abs != null + ? input.abs + : opts.abs; + + // build a new transformation array from either input or global + const existingTransforms: Transformation[] = + (typeof input === 'object' && input.transformation) ? input.transformation : (opts.transformation || []); + + // if ABS is set, append the suffix and streamingResolutions + if (absOpts) { + if (!isTransformationAllowedWithABS(existingTransforms)) { + throw new Error( + 'You can transform the final video using any supported video transformation parameter in ImageKit except w, h, ar, f, vc, ac, and q. ' + ); + } + if (absOpts.protocol === 'hls') { + url.pathname += '/ik-master.m3u8'; + } else if (absOpts.protocol === 'dash') { + url.pathname += '/ik-master.mpd'; + } + return { + src: url.toString(), + transformation: [ + ...existingTransforms, + { streamingResolutions: absOpts.sr.map(res => res as unknown as StreamingResolution) }, + ], + }; + } + + // no ABS at all → return original + return { + src: url.toString(), + transformation: [...existingTransforms], + }; +} + +/** + * Build a poster URL for Video.js from a given video SourceOptions. + * + * - If no `poster` field is present on the source, generates a default thumbnail URL + * by swapping or appending “ik-thumbnail.jpg” to the video URL. + * - If `input.poster.src` or `input.poster.transformation` is provided, uses that instead. + * - Always strips any existing query string before building. + * - Runs the final URL through ImageKit’s `buildSrc`, applying any transformations. + * - Applies your `signerFn` if present in player options. + * + * @param input The source object (must have a `.src` URL and optionally `.transformation` or `.poster`). + * @param opts The plugin’s global PlayerOptions (for default `transformation` and `signerFn`). + * @returns A fully built (and signed) poster image URL. + */ +export async function preparePosterSrc( + input: SourceOptions, + opts: PlayerOptions +): Promise { + // 1️⃣ Grab the raw video URL + let videoSrcUrl = input.src; + let posterSrcUrl: string; + + // 2️⃣ Create a URL object, so we can manipulate path vs. query cleanly + const url = new URL(videoSrcUrl); + + // 3️⃣ Derive a “default” thumbnail path if the user didn’t supply one + // - If streaming suffix “ik-master.m3u8” → swap to “ik-thumbnail.jpg” + // - If streaming suffix “ik-master.mpd” → likewise + // - Otherwise append “/ik-thumbnail.jpg” after the existing path + if (url.pathname.endsWith('ik-master.m3u8')) { + url.pathname = url.pathname.replace(/ik-master\.m3u8$/, 'ik-thumbnail.jpg'); + } else if (url.pathname.endsWith('ik-master.mpd')) { + url.pathname = url.pathname.replace(/ik-master\.mpd$/, 'ik-thumbnail.jpg'); + } else { + url.pathname = `${url.pathname.replace(/\/$/, '')}/ik-thumbnail.jpg`; + } + + // 4️⃣ Always clear any existing query parameters before building + url.search = ''; + posterSrcUrl = url.toString(); + + // 5️⃣ Run through ImageKit’s URL builder (applies any default/global transforms) + posterSrcUrl = ikBuild({ + src: posterSrcUrl, + urlEndpoint: '', + transformation: input.transformation ?? opts.transformation ?? [], + }); + + // 6️⃣ If the user explicitly provided a custom poster.src or transformations, use those + if (input.poster && (input.poster.src || input.poster.transformation)) { + const baseVideoUrl = new URL(videoSrcUrl); + baseVideoUrl.search = ''; + posterSrcUrl = ikBuild({ + src: input.poster.src ?? baseVideoUrl.toString(), + urlEndpoint: '', // same note: maybe pull from opts.urlEndpoint + transformation: input.poster.transformation!, + }); + } + + // 7️⃣ Finally, if the integrator passed a signerFn, sign the built URL + if (opts.signerFn) { + try { + posterSrcUrl = await opts.signerFn(posterSrcUrl); + } catch (err) { + throw new Error(`Signing failed: ${err}`); + } + } + + return posterSrcUrl; +} + + +export async function prepareSeekThumbnailVttSrc( + input: SourceOptions, + opts: PlayerOptions +): Promise { + // 1️⃣ Grab the raw video URL + let videoSrcUrl = input.src; + let seekThumbnailVttSrc: string; + + // 2️⃣ Create a URL object, so we can manipulate path vs. query cleanly + const url = new URL(videoSrcUrl); + + // 3️⃣ Append “/ik-seek-thumbnail-track.vtt” after the existing path + url.pathname = `${url.pathname.replace(/\/$/, '')}/ik-seek-thumbnail-track.vtt`; + + + // 4️⃣ Always clear any existing query parameters before building + url.search = ''; + seekThumbnailVttSrc = url.toString(); + + // 1️⃣ Add streamingResolutions to transformation + const { transformation: absTransforms } = + addABSSuffixToSrcURL(input, opts); + + + // 5️⃣ Run through ImageKit’s URL builder (applies any default/global transforms) + seekThumbnailVttSrc = ikBuild({ + src: seekThumbnailVttSrc, + urlEndpoint: '', + transformation: absTransforms ?? [], + }); + + // 7️⃣ Finally, if the integrator passed a signerFn, sign the built URL + if (opts.signerFn) { + try { + seekThumbnailVttSrc = await opts.signerFn(seekThumbnailVttSrc); + } catch (err) { + throw new Error(`Signing failed: ${err}`); + } + } + + return seekThumbnailVttSrc; +} + +export async function prepareChaptersVttSrc( + input: SourceOptions, + opts: PlayerOptions +): Promise { + // 1️⃣ Grab the raw video URL + let videoSrcUrl = input.src; + let chaptersVttSrc: string; + + // 2️⃣ Create a URL object, so we can manipulate path vs. query cleanly + const url = new URL(videoSrcUrl); + + // 3️⃣ Add ik-genchapters.vtt suffix + // - If streaming suffix “ik-master.m3u8” → swap to “ik-genchapters.vtt” + // - If streaming suffix “ik-master.mpd” → likewise + // - Otherwise append “/ik-thumbnail.jpg” after the existing path + if (url.pathname.endsWith('ik-master.m3u8')) { + url.pathname = url.pathname.replace(/ik-master\.m3u8$/, 'ik-genchapters.vtt'); + } else if (url.pathname.endsWith('ik-master.mpd')) { + url.pathname = url.pathname.replace(/ik-master\.mpd$/, 'ik-genchapters.vtt'); + } else { + url.pathname = `${url.pathname.replace(/\/$/, '')}/ik-genchapters.vtt`; + } + + // 4️⃣ Always clear any existing query parameters before building + url.search = ''; + chaptersVttSrc = url.toString(); + + + // 5️⃣ Run through ImageKit’s URL builder (applies any default/global transforms) + chaptersVttSrc = ikBuild({ + src: chaptersVttSrc, + urlEndpoint: '', + transformation: [], + }); + + // 7️⃣ Finally, if the integrator passed a signerFn, sign the built URL + if (opts.signerFn) { + try { + chaptersVttSrc = await opts.signerFn(chaptersVttSrc); + } catch (err) { + throw new Error(`Signing failed: ${err}`); + } + } + + return chaptersVttSrc; +} + +/** + * Returns true if none of the transformations use parameters + * that are forbidden in ABS mode (w, h, ar, f, vc, ac, q), + * either via their typed properties or via a raw string. + */ +export function isTransformationAllowedWithABS( + transformations: Transformation[] +): boolean { + // Typed‐out properties that must not appear + const forbiddenProps: Array = [ + 'width', + 'height', + 'aspectRatio', + 'format', + 'videoCodec', + 'audioCodec', + 'quality', + ]; + + // Regex that matches any forbidden raw‐string flag, e.g. "w-100", "ar-16-9", "q-80", etc. + const forbiddenRaw = /\b(?:w|h|ar|f|vc|ac|q)-/; + + for (const step of transformations) { + // 1) Check typed properties + for (const prop of forbiddenProps) { + if (step[prop] !== undefined) { + return false; + } + } + + // 2) Check raw string, if present + if (typeof step.raw === 'string' && forbiddenRaw.test(step.raw)) { + return false; + } + } + + return true; +} + +/** + * Runtime‐validates a PlayerOptions object, ensuring all required + * properties are present and all fields conform to their expected + * types/ranges. Throws an Error on the first violation. + * + * After this call, TS will treat opts as Required. + */ +export function validateIKPlayerOptions( + opts: PlayerOptions +): asserts opts is Required { + // imagekitId: required, non‐empty string + if (typeof opts.imagekitId !== 'string' || opts.imagekitId.trim() === '') { + throw new Error('`imagekitId` is required and must be a non‐empty string.'); + } + + // floatingWhenNotVisible: if present, must be 'left' or 'right' or null + if ( + opts.floatingWhenNotVisible != null && + opts.floatingWhenNotVisible !== 'left' && + opts.floatingWhenNotVisible !== 'right' + ) { + throw new Error("`floatingWhenNotVisible` must be 'left', 'right', or null."); + } + + // hideContextMenu: if present, must be boolean + if (opts.hideContextMenu != null && typeof opts.hideContextMenu !== 'boolean') { + throw new Error('`hideContextMenu` must be a boolean.'); + } + + // logo: if present, must have all three fields + if (opts.logo != null) { + const { showLogo, logoImageUrl, logoOnclickUrl } = opts.logo; + if (typeof showLogo !== 'boolean') { + throw new Error('`logo.showLogo` must be a boolean.'); + } + if (showLogo) { + if (typeof logoImageUrl !== 'string' || !logoImageUrl) { + throw new Error('`logo.logoImageUrl` must be a non‐empty string when `showLogo` is true.'); + } + if (typeof logoOnclickUrl !== 'string' || !logoOnclickUrl) { + throw new Error('`logo.logoOnclickUrl` must be a non‐empty string when `showLogo` is true.'); + } + } + } + + // seekThumbnails: if present, must be boolean + if (opts.seekThumbnails != null && typeof opts.seekThumbnails !== 'boolean') { + throw new Error('`seekThumbnails` must be a boolean.'); + } + + // abs: if present, must have valid protocol and sr array + if (opts.abs != null) { + const { protocol, sr } = opts.abs; + if (protocol !== 'hls' && protocol !== 'dash') { + throw new Error("`abs.protocol` must be 'hls' or 'dash'."); + } + if (!Array.isArray(sr) || sr.length === 0) { + throw new Error('`abs.sr` must be a non‐empty array of numbers.'); + } + sr.forEach((res, i) => { + if (typeof res !== 'number' || res <= 0) { + throw new Error(`\`abs.sr[${i}]\` must be a positive number.`); + } + }); + } + + // transformation: if present, must be an array + if ( + opts.transformation != null && + !Array.isArray(opts.transformation) + ) { + throw new Error('`transformation` must be an array of Transformation objects.'); + } + + if (opts.transformation && opts.abs) { + if (!isTransformationAllowedWithABS(opts.transformation)) { + throw new Error( + 'You can transform the final video using any supported video transformation parameter in ImageKit except w, h, ar, f, vc, ac, and q.' + ); + } + } + + // maxTries: if present, must be a positive integer + if ( + opts.maxTries != null && + (!Number.isInteger(opts.maxTries) || opts.maxTries < 1) + ) { + throw new Error('`maxTries` must be an integer ≥ 1.'); + } + + // videoTimeoutInMS: if present, must be a non‐negative number + if ( + opts.videoTimeoutInMS != null && + (typeof opts.videoTimeoutInMS !== 'number' || opts.videoTimeoutInMS < 0) + ) { + throw new Error('`videoTimeoutInMS` must be a number ≥ 0.'); + } + + // playedEventPercents: if present, array of numbers between 0 and 100 + if (opts.playedEventPercents != null) { + if (!Array.isArray(opts.playedEventPercents)) { + throw new Error('`playedEventPercents` must be an array of numbers.'); + } + opts.playedEventPercents.forEach((p, i) => { + if (typeof p !== 'number' || p < 0 || p > 100) { + throw new Error(`\`playedEventPercents[${i}]\` must be between 0 and 100.`); + } + }); + } + + // playedEventTimes: if present, array of non‐negative numbers + if (opts.playedEventTimes != null) { + if (!Array.isArray(opts.playedEventTimes)) { + throw new Error('`playedEventTimes` must be an array of numbers.'); + } + opts.playedEventTimes.forEach((t, i) => { + if (typeof t !== 'number' || t < 0) { + throw new Error(`\`playedEventTimes[${i}]\` must be ≥ 0.`); + } + }); + } + + // delayInMS: if present, must be a non‐negative number + if ( + opts.delayInMS != null && + (typeof opts.delayInMS !== 'number' || opts.delayInMS < 0) + ) { + throw new Error('`delayInMS` must be a number ≥ 0.'); + } + + // signerFn: if present, must be a function returning a Promise + if (opts.signerFn != null && typeof opts.signerFn !== 'function') { + throw new Error('`signerFn` must be a function that returns a Promise.'); + } +} \ No newline at end of file diff --git a/packages/package.json b/packages/package.json new file mode 100644 index 0000000..400ff0c --- /dev/null +++ b/packages/package.json @@ -0,0 +1,59 @@ +{ + "name": "@imagekit/video-player", + "version": "1.0.0", + "description": "Core ImageKit Video Player + framework-specific wrappers (React, Angular)", + "license": "MIT", + "main": "dist/index.js", + "module": "dist/index.mjs", + "types": "dist/index.d.ts", + "exports": { + "./package.json": "./package.json", + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + }, + "./react": { + "types": "./dist/react/index.d.ts", + "import": "./dist/react/index.mjs", + "require": "./dist/react/index.js" + }, + "./styles.css": "./dist/styles.css" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup && npm run build-css", + "dev": "tsup --watch & npm run watch-css", + "build-css": "sass --load-path=../node_modules javascript/styles/index.scss dist/styles.css", + "watch-css": "sass --watch --load-path=../node_modules javascript/styles/index.scss dist/styles.css" + }, + "peerDependencies": { + "react": "^17 || ^18", + "react-dom": "^17 || ^18", + "@angular/core": "^12 || ^13 || ^14 || ^15" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "@angular/core": { + "optional": true + } + }, + "devDependencies": { + "@types/react": "^18.0.0", + "@types/react-dom": "^18.0.0", + "@imagekit/javascript": "^5.0.0", + "tsup": "^8.3.5", + "typescript": "^5.0.0", + "sass": "^1.75.0", + "video.js": "^8.20.0", + "react": "^18.2.0", + "react-dom": "^18.2.0" + } +} \ No newline at end of file diff --git a/packages/react-wrapper/IKVideoPlayer.tsx b/packages/react-wrapper/IKVideoPlayer.tsx new file mode 100644 index 0000000..42cc7a3 --- /dev/null +++ b/packages/react-wrapper/IKVideoPlayer.tsx @@ -0,0 +1,91 @@ +import { + useRef, + useEffect, + forwardRef, + useImperativeHandle, +} from 'react'; +import { videoPlayer } from '../javascript'; + +import type { IKVideoPlayerProps, IKVideoPlayerRef } from './interfaces'; + +const IKVideoPlayer = forwardRef( + ( + { ikOptions, videoJsOptions = {}, source, playlist, className, style }, + ref + ) => { + // A ref to the actual