diff --git a/.github/workflows/deploy-examples.yml b/.github/workflows/deploy-examples.yml new file mode 100644 index 0000000..1e44b51 --- /dev/null +++ b/.github/workflows/deploy-examples.yml @@ -0,0 +1,60 @@ +name: Deploy JavaScript Examples to GitHub Pages + +on: + push: + branches: + - dev # Or your default branch, e.g., 'main' + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + build-and-deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # Enable Corepack to use the yarn version defined in package.json + - name: Enable Corepack + run: corepack enable + + + - name: Set up Node.js and Yarn + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'yarn' + + - name: Install root dependencies + # This single command installs dependencies for the entire monorepo using Yarn. + # It respects your yarn.lock file and workspace setup. + run: yarn install --immutable + + - name: Build all packages + # This runs the "build" script in your root package.json, + # which in turn runs `lerna run build`. + run: yarn run build + + - name: Set up GitHub Pages + uses: actions/configure-pages@v5 + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + # The path is correct, pointing to your example app's build output + path: './examples/javascript/dist' + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 \ No newline at end of file diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..c316841 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,86 @@ +name: Publish NPM + +on: + workflow_dispatch: + inputs: + tag: + description: NPM dist tag (alpha, beta, latest) + required: true + default: 'alpha' + type: choice + options: + - alpha + - beta + - latest + + release: + types: [published] + +jobs: + publish: + name: publish + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Enable Corepack + run: corepack enable + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + registry-url: 'https://registry.npmjs.org' + cache: 'yarn' + + - name: Install dependencies + run: | + yarn install --immutable + + - name: Build packages + run: | + yarn build + + - name: Determine NPM tag + id: npm-tag + run: | + if [ -n "${{ github.event.inputs.tag }}" ]; then + NPM_TAG="${{ github.event.inputs.tag }}" + elif [ "${{ github.event_name }}" == "release" ]; then + # For releases, use 'latest' tag unless version contains alpha/beta + RELEASE_TAG="${{ github.event.release.tag_name }}" + if [[ "$RELEASE_TAG" == *"alpha"* ]] || [[ "$RELEASE_TAG" == *"-alpha"* ]]; then + NPM_TAG="alpha" + elif [[ "$RELEASE_TAG" == *"beta"* ]] || [[ "$RELEASE_TAG" == *"-beta"* ]]; then + NPM_TAG="beta" + else + NPM_TAG="latest" + fi + else + NPM_TAG="alpha" + fi + + echo "tag=$NPM_TAG" >> $GITHUB_OUTPUT + + - name: Publish to NPM + run: | + cp README.md packages/video-player/ + cd packages/video-player + + # Publish with the specified tag + npm publish --tag ${{ steps.npm-tag.outputs.tag }} --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Output published version + run: | + cd packages/video-player + PUBLISHED_VERSION=$(node -p "require('./package.json').version") + echo "✅ Published @imagekit/video-player@${PUBLISHED_VERSION} with tag: ${{ steps.npm-tag.outputs.tag }}" \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..20f7a42 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +old-code +.yarn \ No newline at end of file diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..1d898f1 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +v20.19.2 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/.yarnrc.yml b/.yarnrc.yml new file mode 100644 index 0000000..3186f3f --- /dev/null +++ b/.yarnrc.yml @@ -0,0 +1 @@ +nodeLinker: node-modules diff --git a/README.md b/README.md index ec8be54..632f68b 100644 --- a/README.md +++ b/README.md @@ -1 +1,36 @@ -# imagekit-video-player \ No newline at end of file +ImageKit.io +ImageKit.io Video Player +[![Node CI](https://img.shields.io/badge/Node-CI-blue)](https://github.com/imagekit-developer/imagekit-video-player) +[![npm version](https://img.shields.io/npm/v/@imagekit/video-player.svg)](https://www.npmjs.com/package/@imagekit/video-player) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![Twitter Follow](https://img.shields.io/twitter/follow/imagekitio?style=social)](https://twitter.com/imagekitio) + +## Introduction + +ImageKit's video player is an advanced video player built on top of [Video.js 8.20.0](https://videojs.org/guides) delivering enhanced features for a seamless, interactive video experience. It's fully integrated with ImageKit's video delivery and transformation solution. + +## Key features + +- **Video transformations**: Apply ImageKit video transformations at the player level or per-video level. +- **Adaptive bitrate streaming**: Full support for HLS and MPEG-DASH with automatic generation of streaming representations. +- **Subtitles and chapters**: Easy addition of subtitles, captions, and chapter markers for better video navigation. +- **Player enhancements**: Floating player when scrolled out of view, and seek thumbnails on the progress bar. +- **Custom branding**: Add a clickable logo to brand your video player. +- **Playlists and recommendations**: Create playlists with scrollable widgets or show recommended videos when playback ends. +- **Shoppable Video**: Make videos shoppable by displaying product images alongside videos. + +## Installation + +You can install the SDK in your project using npm or yarn. + +```bash +npm install @imagekit/video-player +``` + +## TypeScript support + +The SDK is written in TypeScript, offering first-class TypeScript support. Enjoy excellent type safety and IntelliSense in your IDE. You can use it in your TypeScript projects without any additional configuration. + +## Documentation + +Refer to the [ImageKit official documentation](https://imagekit.io/docs/video-player/overview) for more details on how to use the SDK. diff --git a/examples/javascript/index.html b/examples/javascript/index.html new file mode 100644 index 0000000..0c34f77 --- /dev/null +++ b/examples/javascript/index.html @@ -0,0 +1,33 @@ + + + + + + + ImageKit Video Player Examples + + + + +
+

ImageKit Video Player Examples

+

Select a feature below to see a live demonstration and its implementation code.

+ +
+ + + + \ No newline at end of file diff --git a/examples/javascript/package.json b/examples/javascript/package.json new file mode 100644 index 0000000..d34a98a --- /dev/null +++ b/examples/javascript/package.json @@ -0,0 +1,24 @@ +{ + "name": "javascript-example", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "vite", + "build": "vite build", + "serve": "vite preview" + }, + "devDependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/node": "^20.14.2", + "typescript": "^5.2.2", + "vite": "^5.2.0" + }, + "dependencies": { + "@imagekit/video-player": "workspace:*", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.19.2", + "imagekit": "^3.1.5" + } +} diff --git a/examples/javascript/pages/abs.html b/examples/javascript/pages/abs.html new file mode 100644 index 0000000..38f4de8 --- /dev/null +++ b/examples/javascript/pages/abs.html @@ -0,0 +1,27 @@ + + + + + + + ABS Example + + + + +
+ ← Back to examples +

ABS Example

+

DASH

+ +

HLS

+ +

Example Code

+
+
+
+
+ + + + \ No newline at end of file diff --git a/examples/javascript/pages/chapters.html b/examples/javascript/pages/chapters.html new file mode 100644 index 0000000..c46a806 --- /dev/null +++ b/examples/javascript/pages/chapters.html @@ -0,0 +1,59 @@ + + + + + + Chapters Example + + + + +
+ ← Back to examples +

Chapters Example

+

This example demonstrates three different ways to configure chapters:

+
+ + + +
+ +

Example Code

+
+
+
+
+ + + \ No newline at end of file diff --git a/examples/javascript/pages/floating-window.html b/examples/javascript/pages/floating-window.html new file mode 100644 index 0000000..d34e185 --- /dev/null +++ b/examples/javascript/pages/floating-window.html @@ -0,0 +1,26 @@ + + + + + + Floating Window Example + + + +
+ ← Back to examples +

Floating Window Example

+

Scroll down the page to see the player shrink and float in the corner.

+ +

Example Code

+
+
+
+
+

Scroll down to test...

+

As you scroll down and the main player leaves the viewport, it will automatically float into the corner so you can keep watching.

+
+
+ + + \ No newline at end of file diff --git a/examples/javascript/pages/logo.html b/examples/javascript/pages/logo.html new file mode 100644 index 0000000..26ba5fc --- /dev/null +++ b/examples/javascript/pages/logo.html @@ -0,0 +1,22 @@ + + + + + + Logo Button Example + + + +
+ ← Back to examples +

Logo Button Example

+

This example demonstrates how to add a custom logo button to the video player control bar. The logo appears in the bottom-right corner of the control bar and opens a link when clicked.

+ +

Example Code

+
+
+
+
+ + + diff --git a/examples/javascript/pages/playlist.html b/examples/javascript/pages/playlist.html new file mode 100644 index 0000000..e266e3e --- /dev/null +++ b/examples/javascript/pages/playlist.html @@ -0,0 +1,25 @@ + + + + + + + Playlist Example + + + + +
+ ← Back to examples +

Playlist Example

+ + +

Example Code

+
+
+
+
+ + + + \ No newline at end of file diff --git a/examples/javascript/pages/recommendations.html b/examples/javascript/pages/recommendations.html new file mode 100644 index 0000000..d60e435 --- /dev/null +++ b/examples/javascript/pages/recommendations.html @@ -0,0 +1,21 @@ + + + + + + Recommendations Example + + + +
+ ← Back to examples +

Recommendations Example

+ +

Example Code

+
+
+
+
+ + + \ No newline at end of file diff --git a/examples/javascript/pages/seek-thumbnails.html b/examples/javascript/pages/seek-thumbnails.html new file mode 100644 index 0000000..6425d6c --- /dev/null +++ b/examples/javascript/pages/seek-thumbnails.html @@ -0,0 +1,22 @@ + + + + + + Seek Thumbnails Example + + + +
+ ← Back to examples +

Seek Thumbnails Example

+

Hover your mouse over the progress bar to see image thumbnails appear as you seek through the video.

+ +

Example Code

+
+
+
+
+ + + \ No newline at end of file diff --git a/examples/javascript/pages/shoppable.html b/examples/javascript/pages/shoppable.html new file mode 100644 index 0000000..3a0ed9b --- /dev/null +++ b/examples/javascript/pages/shoppable.html @@ -0,0 +1,21 @@ + + + + + + Shoppable Video Example + + + +
+ ← Back to examples +

Shoppable Video Example

+ +

Example Code

+
+
+
+
+ + + \ No newline at end of file diff --git a/examples/javascript/pages/subtitles.html b/examples/javascript/pages/subtitles.html new file mode 100644 index 0000000..3d158e1 --- /dev/null +++ b/examples/javascript/pages/subtitles.html @@ -0,0 +1,21 @@ + + + + + + Subtitles Example + + + +
+ ← Back to examples +

Subtitles Example

+ +

Example Code

+
+
+
+
+ + + \ No newline at end of file diff --git a/examples/javascript/pages/try-it-yourself.html b/examples/javascript/pages/try-it-yourself.html new file mode 100644 index 0000000..b617030 --- /dev/null +++ b/examples/javascript/pages/try-it-yourself.html @@ -0,0 +1,335 @@ + + + + + + Try It Yourself - ImageKit Video Player + + + + +
+ ← Back to examples +

Try It Yourself

+

Configure your video player with custom settings and see the code generated in real-time.

+ +
+
+
+ + +
+ +
+ + +
+ +
+ + +
JSON array of transformation objects. Example: [{"width": 800, "height": 600}]
+
+ +
+
+ + +
+ +
+ +
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ + +
+
+
+ + +
+
+ +
+ +
+ +

Generated Code

+
+
+
+
+ + + + + diff --git a/examples/javascript/server.js b/examples/javascript/server.js new file mode 100644 index 0000000..b0df6ee --- /dev/null +++ b/examples/javascript/server.js @@ -0,0 +1,59 @@ +import express from 'express'; +import cors from 'cors'; +import dotenv from 'dotenv'; +import ImageKit from 'imagekit'; + +// Load environment variables from .env file +dotenv.config(); + +const app = express(); +const port = 3001; // Backend runs on a different port than the frontend + +// Middleware +app.use(cors()); // Allow requests from the Vite dev server +app.use(express.json()); // To parse JSON request bodies + +// Check for required environment variables +if (!process.env.IMAGEKIT_PUBLIC_KEY || !process.env.IMAGEKIT_PRIVATE_KEY || !process.env.IMAGEKIT_URL_ENDPOINT) { + console.error("Error: Make sure to create a .env file with your ImageKit credentials."); + process.exit(1); +} + +// Initialize ImageKit SDK +const imagekit = new ImageKit({ + publicKey: process.env.IMAGEKIT_PUBLIC_KEY, + privateKey: process.env.IMAGEKIT_PRIVATE_KEY, + urlEndpoint: process.env.IMAGEKIT_URL_ENDPOINT, +}); + +// --- The Signing Route --- +app.post('/sign-url', (req, res) => { + const { url: urlToSign } = req.body; + + if (!urlToSign) { + return res.status(400).json({ error: 'URL to sign is required.' }); + } + + try { + // We need to get the path from the full URL for the SDK's url method + const urlObject = new URL(urlToSign); + const path = urlObject.pathname; + + // Sign the URL with a 10-minute expiration + const signedUrl = imagekit.url({ + path: path, + signed: true, + expireSeconds: 600, + }); + + res.json({ signedUrl }); + + } catch (error) { + console.error("Error signing URL:", error); + res.status(500).json({ error: 'Failed to sign URL.' }); + } +}); + +app.listen(port, () => { + console.log(`Backend server listening on http://localhost:${port}`); +}); \ No newline at end of file diff --git a/examples/javascript/src/abs.ts b/examples/javascript/src/abs.ts new file mode 100644 index 0000000..96361e2 --- /dev/null +++ b/examples/javascript/src/abs.ts @@ -0,0 +1,53 @@ +import { videoPlayer } from '@imagekit/video-player'; +import '@imagekit/video-player/styles.css'; +import { formatObjectAsCode } from './codegen'; + +// --- Actual Player Initialization (single source of truth) --- +const playerOptions = { + imagekitId: 'imagekit_id', // Replace with your ImageKit ID + logo: { + showLogo: true, + logoImageUrl: 'https://imagekit.io/icons/icon-144x144.png', + logoOnclickUrl: 'https://imagekit.io/', + }, +}; + +const videoJsOptions = { + muted: true, +}; + +const srcConfigDash = { + src: 'https://ik.imagekit.io/demo/sample-video.mp4', + abs: { + protocol: 'dash', + sr: [240, 360, 720, 1080], + }, +}; + +const srcConfigHls = { + src: 'https://ik.imagekit.io/demo/sample-video.mp4', + abs: { + protocol: 'hls', + sr: [240, 360, 720, 1080], + }, +}; + +const codeToDisplay = `// HTML:
+ +import { videoPlayer } from '@imagekit/video-player'; +import '@imagekit/video-player/styles.css'; + +const player = videoPlayer('player', ${formatObjectAsCode(playerOptions)}, ${formatObjectAsCode(videoJsOptions)}); +player.src(${formatObjectAsCode(srcConfigDash)}); + +const player2 = videoPlayer('player-2', ${formatObjectAsCode(playerOptions)}, ${formatObjectAsCode(videoJsOptions)}); +player2.src(${formatObjectAsCode(srcConfigHls)});`; + +// Mount the code to the display block +document.getElementById('code-display')!.textContent = codeToDisplay.trim(); + +const player = videoPlayer('player', playerOptions, videoJsOptions); +player.src(srcConfigDash); + +const player2 = videoPlayer('player-2', playerOptions, videoJsOptions); +player2.src(srcConfigHls); diff --git a/examples/javascript/src/chapters.ts b/examples/javascript/src/chapters.ts new file mode 100644 index 0000000..2d479f2 --- /dev/null +++ b/examples/javascript/src/chapters.ts @@ -0,0 +1,111 @@ +import { videoPlayer } from '@imagekit/video-player'; +import type { SourceOptions } from '@imagekit/video-player'; +import '@imagekit/video-player/styles.css'; +import { buildPlayerInitCode, formatObjectAsCode } from './codegen'; + +// --- Actual Player Initialization (single source of truth) --- +const playerOptions = { + imagekitId: 'imagekit_id', // Replace with your ImageKit ID + logo: { + showLogo: true, + logoImageUrl: 'https://imagekit.io/icons/icon-144x144.png', + logoOnclickUrl: 'https://imagekit.io/', + }, +}; + +const videoJsOptions = { + muted: true, +}; + +const videoSrc = 'https://ik.imagekit.io/ikmedia/docs/video-player/subtitle_chapter/demo.mp4'; +// Method 1: Auto-generate chapters (AI) +const srcConfigAuto = { + src: videoSrc, + chapters: true, + textTracks: [ + { + autoGenerate: true as const, + translations: [ + { + langCode: 'hi' as const, + label: 'Hindi (AI)', + default: true, + }, + { + langCode: 'de' as const, + label: 'German (AI)', + }, + ], + } + ], +}; + +// Method 2: Load from VTT URL +const srcConfigUrl = { + src: videoSrc, + chapters: { + url: 'https://ik.imagekit.io/ikmedia/docs/video-player/subtitle_chapter/demo.vtt', // Replace with your VTT file URL + }, +}; + +// Method 3: Manual chapter object +const srcConfigManual = { + src: videoSrc, + chapters: { + 0: 'Introduction', + 146: 'Main Content', + 302: 'Advanced Topics', + 443: 'Q&A Session', + 563: 'Conclusion' + }, +}; + +let currentSrcConfig: SourceOptions = srcConfigAuto; + +function updateCodeDisplay() { + const codeToDisplay = buildPlayerInitCode({ + htmlHint: '', + playerTarget: 'player', + playerOptions, + videoJsOptions, + afterInitLines: [``, `player.src(${formatObjectAsCode(currentSrcConfig)});`], + }); + document.getElementById('code-display')!.textContent = codeToDisplay.trim(); +} + +// Tab switching logic +const tabButtons = document.querySelectorAll('.tab-button'); +tabButtons.forEach((button) => { + button.addEventListener('click', () => { + // Remove active class from all buttons and tabs + tabButtons.forEach((btn) => btn.classList.remove('active')); + button.classList.add('active'); + + const tabName = button.getAttribute('data-tab'); + + // Update source config based on selected tab + switch (tabName) { + case 'auto': + currentSrcConfig = srcConfigAuto; + break; + case 'url': + currentSrcConfig = srcConfigUrl; + break; + case 'manual': + currentSrcConfig = srcConfigManual; + break; + } + + // Update code display + updateCodeDisplay(); + + // Update player source + player.src(currentSrcConfig); + }); +}); + +// Initial code display +updateCodeDisplay(); + +const player = videoPlayer('player', playerOptions, videoJsOptions); +player.src(currentSrcConfig); \ No newline at end of file diff --git a/examples/javascript/src/codegen.ts b/examples/javascript/src/codegen.ts new file mode 100644 index 0000000..03b87a2 --- /dev/null +++ b/examples/javascript/src/codegen.ts @@ -0,0 +1,64 @@ +/** + * Small helpers to ensure the "Generated Code" blocks always match the + * actual configuration used to initialize the demo players. + */ + +/** + * Formats a JS value into a readable code string. + * - Uses JSON.stringify with indentation + * - Unquotes identifier-like keys + * - Uses single quotes + * - Supports `undefined` via a placeholder + */ +export function formatObjectAsCode(value: any): string { + const replacer = (_key: string, val: any) => { + if (val === undefined) return '__UNDEFINED__'; + return val; + }; + + let jsonString = JSON.stringify(value, replacer, 4); + + // Remove quotes from valid JS identifier keys + jsonString = jsonString.replace(/"([^"]+)":/g, (_match, key) => { + const isValidIdentifier = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key); + return isValidIdentifier ? `${key}:` : `'${key}':`; + }); + + // Convert remaining quotes to single quotes + jsonString = jsonString.replace(/"/g, "'"); + + // Restore undefined + jsonString = jsonString.replace(/__UNDEFINED__/g, 'undefined'); + + return jsonString; +} + +export function buildPlayerInitCode(params: { + htmlHint: string; + playerTarget: string; + playerOptions: any; + videoJsOptions?: any; + afterInitLines?: string[]; +}): string { + const { + htmlHint, + playerTarget, + playerOptions, + videoJsOptions, + afterInitLines = [], + } = params; + + const playerOptionsCode = formatObjectAsCode(playerOptions); + const videoJsOptionsCode = videoJsOptions + ? `, ${formatObjectAsCode(videoJsOptions)}` + : ''; + + return `// HTML: ${htmlHint} + +import { videoPlayer } from '@imagekit/video-player'; +import '@imagekit/video-player/styles.css'; + +const player = videoPlayer('${playerTarget}', ${playerOptionsCode}${videoJsOptionsCode}); +${afterInitLines.join('\n')}`.trim(); +} + diff --git a/examples/javascript/src/floating-window.ts b/examples/javascript/src/floating-window.ts new file mode 100644 index 0000000..6e4b790 --- /dev/null +++ b/examples/javascript/src/floating-window.ts @@ -0,0 +1,30 @@ +import { videoPlayer } from '@imagekit/video-player'; +import '@imagekit/video-player/styles.css'; +import { buildPlayerInitCode, formatObjectAsCode } from './codegen'; + +// --- Actual Player Initialization (single source of truth) --- +const playerOptions = { + imagekitId: 'imagekit_id', // Replace with your ImageKit ID + floatingWhenNotVisible: 'right', + logo: { + showLogo: true, + logoImageUrl: 'https://imagekit.io/icons/icon-144x144.png', + logoOnclickUrl: 'https://imagekit.io/', + }, +}; + +const srcConfig = { + src: 'https://ik.imagekit.io/demo/sample-video.mp4', +}; + +const codeToDisplay = buildPlayerInitCode({ + htmlHint: '', + playerTarget: 'player', + playerOptions, + afterInitLines: [``, `player.src(${formatObjectAsCode(srcConfig)});`], +}); + +document.getElementById('code-display')!.textContent = codeToDisplay.trim(); + +const player = videoPlayer('player', playerOptions); +player.src(srcConfig); \ No newline at end of file diff --git a/examples/javascript/src/logo.ts b/examples/javascript/src/logo.ts new file mode 100644 index 0000000..e7fd1a9 --- /dev/null +++ b/examples/javascript/src/logo.ts @@ -0,0 +1,29 @@ +import { videoPlayer } from '@imagekit/video-player'; +import '@imagekit/video-player/styles.css'; +import { buildPlayerInitCode, formatObjectAsCode } from './codegen'; + +// --- Actual Player Initialization (single source of truth) --- +const playerOptions = { + imagekitId: 'imagekit_id', // Replace with your ImageKit ID + logo: { + showLogo: true, + logoImageUrl: 'https://imagekit.io/icons/icon-144x144.png', + logoOnclickUrl: 'https://imagekit.io/', + }, +}; + +const srcConfig = { + src: 'https://ik.imagekit.io/demo/sample-video.mp4', +}; + +const codeToDisplay = buildPlayerInitCode({ + htmlHint: '', + playerTarget: 'player', + playerOptions, + afterInitLines: [``, `player.src(${formatObjectAsCode(srcConfig)});`], +}); + +document.getElementById('code-display')!.textContent = codeToDisplay.trim(); + +const player = videoPlayer('player', playerOptions); +player.src(srcConfig); diff --git a/examples/javascript/src/playlist.ts b/examples/javascript/src/playlist.ts new file mode 100644 index 0000000..7fda463 --- /dev/null +++ b/examples/javascript/src/playlist.ts @@ -0,0 +1,98 @@ +import { videoPlayer } from '@imagekit/video-player'; +import '@imagekit/video-player/styles.css'; +import { formatObjectAsCode } from './codegen'; + +const playerOptions = { + imagekitId: 'imagekit_id', // Replace with your ImageKit ID + logo: { + showLogo: true, + logoImageUrl: 'https://imagekit.io/icons/icon-144x144.png', + logoOnclickUrl: 'https://imagekit.io/', + }, +}; + +const videoJsOptions = { + muted: true, +}; + +const playlistConfig = { + sources: [ + { + src: "https://ik.imagekit.io/ikmedia/docs/video-player/playlist/horses.mp4", + info: { title: "Horses Running", description: "Horses grazing in the field" }, + }, + { + src: "https://ik.imagekit.io/ikmedia/docs/video-player/playlist/lion.mp4", + info: { + title: "Lion", + description: "Lion roaming in the wild", + }, + }, + { + src: "https://ik.imagekit.io/ikmedia/docs/video-player/playlist/dog_running.mp4", + info: { title: "Dog Running" }, + }, + { + src: "https://ik.imagekit.io/ikmedia/docs/video-player/playlist/man_smiling.mp4", + info: { + title: "Man Smiling", + description: "Man smiling at the camera", + }, + }, + { + src: "https://ik.imagekit.io/ikmedia/docs/video-player/playlist/rhino.mp4", + info: { title: "Rhino at the zoo"}, + }, + { + src: "https://ik.imagekit.io/demo/sample-video.mp4", + info: { title: "Bird on branch"} + } + ], + options: { + autoAdvance: 3, + repeat: true, + presentUpcoming: 10, + widgetProps: { direction: 'vertical' as const }, + }, +}; + +const player2Options = { + imagekitId: 'zuqlyov9d', // Replace with your ImageKit ID + logo: { + showLogo: true, + logoImageUrl: 'https://imagekit.io/icons/icon-144x144.png', + logoOnclickUrl: 'https://imagekit.io/', + }, +}; + +const playlistConfig2 = { + ...playlistConfig, + options: { + ...playlistConfig.options, + widgetProps: { direction: 'horizontal' as const }, + }, +}; + +const codeToDisplay = `// HTML:
+ +import { videoPlayer } from '@imagekit/video-player'; +import '@imagekit/video-player/styles.css'; + +const player = videoPlayer('player', ${formatObjectAsCode(playerOptions)}, ${formatObjectAsCode(videoJsOptions)}); +const playlistManager = player.playlist(${formatObjectAsCode(playlistConfig)}); +playlistManager.loadFirstItem(); + +const player2 = videoPlayer('player-2', ${formatObjectAsCode(player2Options)}); +const playlistManager2 = player2.playlist(${formatObjectAsCode(playlistConfig2)}); +playlistManager2.loadFirstItem();`; + +// Mount the code to the display block +document.getElementById('code-display')!.textContent = codeToDisplay.trim(); + +const player = videoPlayer('player', playerOptions, videoJsOptions); +const playlistManager = player.playlist(playlistConfig); +playlistManager.loadFirstItem(); + +const player2 = videoPlayer('player-2', player2Options); +const playlistManager2 = player2.playlist(playlistConfig2); +playlistManager2.loadFirstItem(); diff --git a/examples/javascript/src/recommendations.ts b/examples/javascript/src/recommendations.ts new file mode 100644 index 0000000..b568b09 --- /dev/null +++ b/examples/javascript/src/recommendations.ts @@ -0,0 +1,63 @@ +import { videoPlayer } from '@imagekit/video-player'; +import '@imagekit/video-player/styles.css'; +import { buildPlayerInitCode, formatObjectAsCode } from './codegen'; + +// --- Actual Player Initialization (single source of truth) --- +const playerOptions = { + imagekitId: 'imagekit_id', // Replace with your ImageKit ID + logo: { + showLogo: true, + logoImageUrl: 'https://imagekit.io/icons/icon-144x144.png', + logoOnclickUrl: 'https://imagekit.io/', + }, +}; + +const videoJsOptions = { + muted: true, +}; + +const srcConfig = { + src: "https://ik.imagekit.io/ikmedia/docs/video-player/playlist/horses.mp4", + info: { title: "Horses Running", description: "Horses grazing in the field" }, + recommendations: [ + { + src: "https://ik.imagekit.io/ikmedia/docs/video-player/playlist/lion.mp4", + info: { + title: "Lion", + description: "Lion roaming in the wild", + }, + }, + { + src: "https://ik.imagekit.io/ikmedia/docs/video-player/playlist/dog_running.mp4", + info: { title: "Dog Running" }, + }, + { + src: "https://ik.imagekit.io/ikmedia/docs/video-player/playlist/man_smiling.mp4", + info: { + title: "Man Smiling", + description: "Man smiling at the camera", + }, + }, + { + src: "https://ik.imagekit.io/ikmedia/docs/video-player/playlist/rhino.mp4", + info: { title: "Rhino at the zoo" }, + }, + { + src: "https://ik.imagekit.io/demo/sample-video.mp4", + info: { title: "Bird on branch"} + } + ], +}; + +const codeToDisplay = buildPlayerInitCode({ + htmlHint: '', + playerTarget: 'player', + playerOptions, + videoJsOptions, + afterInitLines: [``, `player.src(${formatObjectAsCode(srcConfig)});`], +}); + +document.getElementById('code-display')!.textContent = codeToDisplay.trim(); + +const player = videoPlayer('player', playerOptions, videoJsOptions); +player.src(srcConfig); \ No newline at end of file diff --git a/examples/javascript/src/seek-thumbnails.ts b/examples/javascript/src/seek-thumbnails.ts new file mode 100644 index 0000000..3580a3d --- /dev/null +++ b/examples/javascript/src/seek-thumbnails.ts @@ -0,0 +1,35 @@ +import { videoPlayer } from '@imagekit/video-player'; +import '@imagekit/video-player/styles.css'; +import { buildPlayerInitCode, formatObjectAsCode } from './codegen'; + +// --- Actual Player Initialization (single source of truth) --- +const playerOptions = { + imagekitId: 'imagekit_id', // Replace with your ImageKit ID + seekThumbnails: true, + logo: { + showLogo: true, + logoImageUrl: 'https://imagekit.io/icons/icon-144x144.png', + logoOnclickUrl: 'https://imagekit.io/', + }, +}; + +const videoJsOptions = { + muted: true, +}; + +const srcConfig = { + src: 'https://ik.imagekit.io/demo/sample-video.mp4', +}; + +const codeToDisplay = buildPlayerInitCode({ + htmlHint: '', + playerTarget: 'player', + playerOptions, + videoJsOptions, + afterInitLines: [``, `player.src(${formatObjectAsCode(srcConfig)});`], +}); + +document.getElementById('code-display')!.textContent = codeToDisplay.trim(); + +const player = videoPlayer('player', playerOptions, videoJsOptions); +player.src(srcConfig); \ No newline at end of file diff --git a/examples/javascript/src/shoppable.ts b/examples/javascript/src/shoppable.ts new file mode 100644 index 0000000..71c011a --- /dev/null +++ b/examples/javascript/src/shoppable.ts @@ -0,0 +1,113 @@ +import { videoPlayer } from '@imagekit/video-player'; +import '@imagekit/video-player/styles.css'; +import { buildPlayerInitCode, formatObjectAsCode } from './codegen'; + +// --- Actual Player Initialization (single source of truth) --- +const playerOptions = { + imagekitId: 'imagekit_id', // Replace with your ImageKit ID + logo: { + showLogo: true, + logoImageUrl: 'https://imagekit.io/icons/icon-144x144.png', + logoOnclickUrl: 'https://imagekit.io/', + }, +}; + +const srcConfig = { + src: "https://ik.imagekit.io/ikmedia/docs/video-player/shoppable/video.mp4", + shoppable: { + products: [ + { + productId: 1, + productName: "Classic Aviators", + highlightTime: { start: 2, end: 6 }, + imageUrl: "https://ik.imagekit.io/ikmedia/docs/video-player/shoppable/aviators.jpeg", + hotspots: [ + { + time: "00:06", + x: "48%", + y: "35%", + tooltipPosition: "left", + clickUrl: "https://images.pexels.com/photos/701877/pexels-photo-701877.jpeg" + } + ], + onHover: { + action: "overlay", + args: "Click to see this product in the video" + }, + onClick: { + action: "seek", + pause: 5, + args: { time: "00:06" } + } + }, + { + productId: 2, + productName: "Wooden frame glasses", + imageUrl: "https://ik.imagekit.io/ikmedia/docs/video-player/shoppable/wooden_frames.jpeg", + onHover: { + action: "switch", + args: { + url: "https://ik.imagekit.io/ikmedia/docs/video-player/shoppable/wooden_frames.jpeg" + } + }, + onClick: { + action: "goto", + pause: true, + args: { + url: "https://www.pexels.com/search/wooden%20glasses%20frames/" + } + } + }, + { + productId: 3, + productName: "Sunglasses", + imageUrl: "https://ik.imagekit.io/ikmedia/docs/video-player/shoppable/sunglass.jpeg", + onHover: { + action: "overlay", + args: "Click to go to website" + }, + onClick: { + action: "goto", + pause: true, + args: { + url: "https://www.pexels.com/photo/red-lens-sunglasses-on-sand-near-sea-at-sunset-selective-focus-photography-46710/" + } + } + }, + { + productId: 4, + productName: "Eye protection", + highlightTime: { start: 7, end: 9 }, + imageUrl: "https://ik.imagekit.io/ikmedia/docs/video-player/shoppable/protection.jpeg", + onClick: { + action: "goto", + pause: true, + args: { + url: "https://www.pexels.com/photo/red-lens-sunglasses-on-sand-near-sea-at-sunset-selective-focus-photography-46710/" + } + } + } + ], + showPostPlayOverlay: true, + autoClose: false, + startState: "open" + } +}; + +const codeToDisplay = buildPlayerInitCode({ + htmlHint: '', + playerTarget: 'player', + playerOptions, + afterInitLines: [``, `player.src(${formatObjectAsCode(srcConfig)});`], +}); + +document.getElementById('code-display')!.textContent = codeToDisplay.trim(); + +// Helper function to initialize players +function initPlayer(playerId: string, options: typeof playerOptions, config: typeof srcConfig) { + const player = videoPlayer(playerId, options); + player.src(config); +} + +// Initialize all players +initPlayer('player', playerOptions, srcConfig); \ No newline at end of file diff --git a/examples/javascript/src/style.css b/examples/javascript/src/style.css new file mode 100644 index 0000000..78f69fa --- /dev/null +++ b/examples/javascript/src/style.css @@ -0,0 +1,68 @@ +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + margin: 0; + background-color: #f4f7f9; + color: #333; + } + + .container { + max-width: 960px; + margin: 2rem auto; + padding: 0 1rem; + } + + h1, h2 { + color: #1a202c; + border-bottom: 1px solid #e2e8f0; + padding-bottom: 0.5rem; + margin-bottom: 1rem; + } + + a { + color: #3182ce; + text-decoration: none; + } + a:hover { + text-decoration: underline; + } + + .back-link { + display: inline-block; + margin-bottom: 2rem; + font-weight: 500; + } + + .example-nav ul { + list-style: none; + padding: 0; + } + .example-nav li { + margin-bottom: 0.5rem; + font-size: 1.1rem; + } + + .code-block { + background-color: #2d3748; + color: #e2e8f0; + padding: 1rem; + border-radius: 8px; + overflow-x: auto; + margin-top: 2rem; + font-family: "Fira Code", "Courier New", monospace; + font-size: 0.9rem; + line-height: 1.6; + max-width: 100%; + } + .code-block pre { + margin: 0; + white-space: pre-wrap; + word-wrap: break-word; + overflow-wrap: break-word; + } + .code-block code { + display: block; + white-space: pre-wrap; + word-wrap: break-word; + overflow-wrap: break-word; + max-width: 100%; + } \ No newline at end of file diff --git a/examples/javascript/src/subtitles.ts b/examples/javascript/src/subtitles.ts new file mode 100644 index 0000000..047e95f --- /dev/null +++ b/examples/javascript/src/subtitles.ts @@ -0,0 +1,57 @@ +import { videoPlayer } from '@imagekit/video-player'; +import '@imagekit/video-player/styles.css'; +import { buildPlayerInitCode, formatObjectAsCode } from './codegen'; + +// --- Actual Player Initialization (single source of truth) --- +const playerOptions = { + imagekitId: 'imagekit_id', // Replace with your ImageKit ID + logo: { + showLogo: true, + logoImageUrl: 'https://imagekit.io/icons/icon-144x144.png', + logoOnclickUrl: 'https://imagekit.io/', + }, +}; + +const videoJsOptions = { + muted: true, +}; + +const srcConfig = { + src: "https://ik.imagekit.io/ikmedia/docs/video-player/subtitle_chapter/demo.mp4", + textTracks: [ + { + autoGenerate: true, + maxChars: 60, + highlightWords: true, + }, + { + autoGenerate: true, + showAutoGenerated: false, + translations: [ + { + langCode: "hi", + label: "Hindi (AI)", + default: true, + }, + { + langCode: "de", + label: "German (AI)", + }, + ], + }, + ], +}; + +// Generated code always matches the real config above +const codeToDisplay = buildPlayerInitCode({ + htmlHint: '', + playerTarget: 'player', + playerOptions, + videoJsOptions, + afterInitLines: [``, `player.src(${formatObjectAsCode(srcConfig)});`], +}); + +document.getElementById('code-display')!.textContent = codeToDisplay.trim(); + +const player = videoPlayer('player', playerOptions, videoJsOptions); +player.src(srcConfig); \ No newline at end of file diff --git a/examples/javascript/src/try-it-yourself.ts b/examples/javascript/src/try-it-yourself.ts new file mode 100644 index 0000000..9283d29 --- /dev/null +++ b/examples/javascript/src/try-it-yourself.ts @@ -0,0 +1,369 @@ +import { videoPlayer, languageCodes } from '@imagekit/video-player'; +import '@imagekit/video-player/styles.css'; +import type { + IKPlayerOptions, + SourceOptions, + RemoteTextTrackOptions, + AutoGeneratedTextTrackOptions, + Transformation +} from '@imagekit/video-player'; +import type Player from 'video.js/dist/types/player'; + +let currentPlayer: Player | null = null; + +/** + * Formats a JavaScript object/array/value as a code string with proper indentation + * Uses JSON.stringify with a replacer to handle undefined, then converts to JS format + */ +function formatObjectAsCode(value: any): string { + // Custom replacer to handle undefined (JSON.stringify normally skips it) + const replacer = (key: string, val: any) => { + if (val === undefined) { + return '__UNDEFINED__'; // Placeholder we'll replace later + } + return val; + }; + + // Use JSON.stringify with 4-space indentation + let jsonString = JSON.stringify(value, replacer, 4); + + // Convert JSON format to JavaScript format: + // 1. Remove quotes from valid JavaScript identifier keys + // 2. Replace double quotes with single quotes for string values + // 3. Replace undefined placeholder + + // Step 1: Remove quotes from valid identifier keys and convert to single quotes for invalid ones + jsonString = jsonString.replace(/"([^"]+)":/g, (_match, key) => { + const isValidIdentifier = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key); + if (isValidIdentifier) { + return `${key}:`; // Remove quotes from valid identifiers + } + return `'${key}':`; // Use single quotes for invalid identifiers + }); + + // Step 2: Replace double quotes in string values with single quotes + // We need to be careful - only replace quotes that are part of string values + // After keys are handled, remaining " should be string values + jsonString = jsonString.replace(/"/g, "'"); + + // Step 3: Replace undefined placeholder (but only when it's a value, not part of a string) + jsonString = jsonString.replace(/__UNDEFINED__/g, 'undefined'); + + return jsonString; +} + +/** + * Builds the player configuration objects based on form inputs + */ +function buildPlayerConfig( + imagekitId: string, + srcUrl: string, + features: string[], + maxChars?: number, + wordHighlight?: boolean, + translationLangs?: Array<{ label?: string; langCode: string }>, + signerUrl?: string, + transformation?: Transformation[] +): { playerOptions: IKPlayerOptions; srcConfig: SourceOptions } { + // Build player options + const playerOptions: IKPlayerOptions = { + imagekitId: imagekitId, + logo: { + showLogo: true, + logoImageUrl: 'https://imagekit.io/icons/icon-144x144.png', + logoOnclickUrl: 'https://imagekit.io/' + } + }; + + if (features.includes('seek-thumbnails')) { + playerOptions.seekThumbnails = true; + } + + // Add signer function if URL is provided + if (signerUrl && signerUrl.trim()) { + playerOptions.signerFn = async (url: string): Promise => { + try { + const signerEndpoint = signerUrl.trim(); + // Send POST request with URL in request body + const response = await fetch(signerEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ url }), + }); + if (!response.ok) { + throw new Error(`Signer function failed: ${response.status}`); + } + const signedUrl = await response.text(); + return signedUrl.trim(); + } catch (error) { + console.error('Signer function error:', error); + throw error; + } + }; + } + + // Build source config + const srcConfig: SourceOptions = { + src: srcUrl, + }; + + if (transformation && transformation.length > 0) { + srcConfig.transformation = transformation; + } + + if (features.includes('chapters')) { + srcConfig.chapters = true; + } + + // Build text tracks + if (features.includes('subtitles') || features.includes('translation')) { + const textTracks: RemoteTextTrackOptions[] = []; + + // Add subtitles track if subtitles is checked + if (features.includes('subtitles')) { + const subtitleConfig: AutoGeneratedTextTrackOptions = { + autoGenerate: true, + }; + + if (maxChars !== undefined && maxChars > 0) { + subtitleConfig.maxChars = maxChars; + } + if (wordHighlight) { + subtitleConfig.highlightWords = true; + } + + textTracks.push(subtitleConfig); + } + + // Add translation track as a separate track if translation is checked + if (features.includes('translation')) { + const translateArray: Array<{ langCode: keyof typeof languageCodes; label?: string; default?: boolean }> = []; + if (translationLangs && translationLangs.length > 0) { + translationLangs.forEach(({ label, langCode }) => { + const trimmedLangCode = (langCode || '').trim(); + if (trimmedLangCode) { + const trimmedLabel = (label || '').trim(); + translateArray.push({ + langCode: trimmedLangCode as keyof typeof languageCodes, + ...(trimmedLabel ? { label: trimmedLabel } : {}) + }); + } + }); + } + + if (translateArray.length > 0) { + const translationConfig: AutoGeneratedTextTrackOptions = { + autoGenerate: true, + showAutoGenerated: false, + translations: translateArray, + }; + + textTracks.push(translationConfig); + } + } + + if (textTracks.length > 0) { + srcConfig.textTracks = textTracks; + } + } + + return { playerOptions, srcConfig }; +} + +/** + * Generates code string from configuration objects + */ +function generateCode( + imagekitId: string, + srcUrl: string, + features: string[], + maxChars?: number, + wordHighlight?: boolean, + translationLangs?: Array<{ label?: string; langCode: string }>, + signerUrl?: string, + transformation?: Transformation[] +): string { + const { playerOptions, srcConfig } = buildPlayerConfig( + imagekitId, + srcUrl, + features, + maxChars, + wordHighlight, + translationLangs, + signerUrl, + transformation + ); + + // Keep video.js options in one place so displayed code always matches runtime config + const videoJsOptions = { + muted: true + }; + + // Format player options, but handle signerFn separately since it's a function + const optionsForFormatting = { ...playerOptions }; + if (optionsForFormatting.signerFn) { + delete (optionsForFormatting as any).signerFn; + } + let playerOptionsCode = formatObjectAsCode(optionsForFormatting); + + // If signer function exists, add it to the code + if (playerOptions.signerFn && signerUrl) { + // Remove the closing brace and add signer function before it + playerOptionsCode = playerOptionsCode.replace(/\n\}$/, `,\n signerFn: async (url: string) => { + const response = await fetch('${signerUrl}', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ url }), + }); + if (!response.ok) { + throw new Error(\`Signer function failed: \${response.status}\`); + } + return (await response.text()).trim(); + }\n}`); + } + + const srcConfigCode = formatObjectAsCode(srcConfig); + const videoJsOptionsCode = formatObjectAsCode(videoJsOptions); + + return `// HTML: + +import { videoPlayer } from '@imagekit/video-player'; +import '@imagekit/video-player/styles.css'; + +const player = videoPlayer('player', ${playerOptionsCode}, ${videoJsOptionsCode}); + +player.src(${srcConfigCode});`; +} + + +function updatePlayer() { + const imagekitId = (document.getElementById('imagekit-id') as HTMLInputElement).value.trim(); + const srcUrl = (document.getElementById('src-url') as HTMLInputElement).value.trim(); + const checkboxes = document.querySelectorAll('input[name="features"]:checked'); + const features = Array.from(checkboxes).map(cb => cb.value); + + if (!imagekitId || !srcUrl) { + alert('Please fill in both ImageKit ID and Source URL'); + return; + } + + // Get subtitle options + const maxCharsInput = document.getElementById('max-chars') as HTMLInputElement; + const maxChars = maxCharsInput?.value ? parseInt(maxCharsInput.value, 10) : undefined; + const wordHighlight = (document.getElementById('word-highlight') as HTMLInputElement)?.checked || false; + + // Get translation languages from the list + const translationLangsList = document.querySelectorAll('.translation-lang-item'); + const translationLangs: Array<{ label?: string; langCode: string }> = []; + translationLangsList.forEach(item => { + const labelInput = item.querySelector('.translation-label-input') as HTMLInputElement; + const langCodeInput = item.querySelector('.lang-code-input') as HTMLInputElement; + const label = labelInput?.value.trim() || ''; + const langCode = langCodeInput?.value.trim() || ''; + if (langCode) { + translationLangs.push({ label: label || undefined, langCode }); + } + }); + const translationLangsForConfig = translationLangs.length > 0 ? translationLangs : undefined; + + // Get signer function URL + const enableSigner = (document.getElementById('enable-signer') as HTMLInputElement)?.checked || false; + const signerUrlInput = document.getElementById('signer-url') as HTMLInputElement; + const signerUrl = enableSigner && signerUrlInput?.value.trim() ? signerUrlInput.value.trim() : undefined; + + // Get transformation + const transformationInput = (document.getElementById('transformation') as HTMLInputElement)?.value.trim() || ''; + let transformation: Transformation[] | undefined = undefined; + if (transformationInput) { + try { + const parsed = JSON.parse(transformationInput); + if (Array.isArray(parsed)) { + transformation = parsed; + } else { + console.warn('Transformation must be a JSON array'); + } + } catch (e) { + alert('Invalid transformation JSON. Please check the format.'); + return; + } + } + + // Build configuration using shared function + const { playerOptions, srcConfig } = buildPlayerConfig( + imagekitId, + srcUrl, + features, + maxChars, + wordHighlight, + translationLangsForConfig, + signerUrl, + transformation + ); + + // Generate and display code + const code = generateCode(imagekitId, srcUrl, features, maxChars, wordHighlight, translationLangsForConfig, signerUrl, transformation); + document.getElementById('code-display')!.textContent = code; + + // Get the video element before disposing + let videoElement = document.getElementById('player') as HTMLVideoElement; + + // Destroy existing player if it exists + if (currentPlayer) { + try { + // Check if player is already disposed + if (!currentPlayer.isDisposed && !currentPlayer.isDisposed()) { + currentPlayer.dispose(); + } + } catch (e) { + // Player might already be disposed or in the process of being disposed + console.warn('Error disposing player:', e); + } finally { + currentPlayer = null; + } + } + + // Ensure the video element still exists, recreate if needed + if (!videoElement || !videoElement.parentNode) { + const playerContainer = document.querySelector('.player-container'); + if (playerContainer) { + playerContainer.innerHTML = ''; + videoElement = document.getElementById('player') as HTMLVideoElement; + } else { + console.error('Player container not found'); + return; + } + } + + // Small delay to ensure previous player is fully cleaned up + setTimeout(() => { + const videoJsOptions = { + muted: true + }; + + // Initialize new player using the element directly + currentPlayer = videoPlayer(videoElement, playerOptions, videoJsOptions); + + // Set source with the configured options + currentPlayer.src(srcConfig); + }, 0); +} + +// Initial code display +const initialCode = generateCode( + '', + '', + [] +); +document.getElementById('code-display')!.textContent = initialCode; + + +// Handle form submission +document.getElementById('player-form')!.addEventListener('submit', (e) => { + e.preventDefault(); + updatePlayer(); +}); + 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..fa50ed1 --- /dev/null +++ b/examples/javascript/vite.config.ts @@ -0,0 +1,47 @@ +import path from 'path'; +import { defineConfig } from 'vite'; + +// IMPORTANT: Replace 'your-repo-name' with the actual name of your GitHub repository. +const REPO_NAME = 'imagekit-video-player'; + +export default defineConfig({ + // The root of the vite project is the current directory. + root: '.', + // --- CHANGE 1: Set the base path for deployment --- + base: `/${REPO_NAME}/`, + + server: { + port: 3000, + sourcemapIgnoreList: false, // Show original source files in DevTools + }, + build: { + // --- CHANGE 2: Define the output directory --- + outDir: './dist', + emptyOutDir: true, + sourcemap: true, // Enable source maps for debugging + rollupOptions: { + input: { + main: path.resolve(__dirname, 'index.html'), + shoppable: path.resolve(__dirname, 'pages/shoppable.html'), + playlist: path.resolve(__dirname, 'pages/playlist.html'), + recommendations: path.resolve(__dirname, 'pages/recommendations.html'), + floatingWindow: path.resolve(__dirname, 'pages/floating-window.html'), + chapters: path.resolve(__dirname, 'pages/chapters.html'), + subtitles: path.resolve(__dirname, 'pages/subtitles.html'), + seekThumbnails: path.resolve(__dirname, 'pages/seek-thumbnails.html'), + abs: path.resolve(__dirname, 'pages/abs.html'), + logo: path.resolve(__dirname, 'pages/logo.html'), + tryItYourself: path.resolve(__dirname, 'pages/try-it-yourself.html'), + // Note: You didn't provide a context-menu example, so it's commented out. + // contextMenu: path.resolve(__dirname, 'pages/context-menu.html'), + }, + }, + }, + resolve: { + // This alias is likely not needed if your workspaces are set up correctly, + // but we can leave it as it doesn't cause harm. + alias: { + '@imagekit/video-player': path.resolve(__dirname, '../../packages/video-player/dist'), + }, + }, +}); \ No newline at end of file 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..071edae --- /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": { + "@imagekit/video-player": "workspace:*", + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "devDependencies": { + "@types/react": "^18.0.0", + "@types/react-dom": "^18.0.0", + "@vitejs/plugin-react": "^3.0.0", + "typescript": "^5.0.0", + "vite": "^4.0.0" + } +} diff --git a/examples/react/src/App.tsx b/examples/react/src/App.tsx new file mode 100644 index 0000000..4ea3a4d --- /dev/null +++ b/examples/react/src/App.tsx @@ -0,0 +1,372 @@ +import React, { useRef } from 'react'; +import { IKVideoPlayer } from '@imagekit/video-player/react'; +import type { + IKVideoPlayerRef, + IKPlayerOptions, + SourceOptions, + PlaylistOptions +} from '@imagekit/video-player/react'; + +import '@imagekit/video-player/styles.css'; + +export default function App() { + const playerRef = useRef(null); + + // 1) Define your ImageKit IKPlayerOptions + const ikOptions: IKPlayerOptions = { + 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/demo/sample-video.mp4', + transformation: [ + { width: 400, height: 400 }, + ], + chapters: true, + info: { title: 'Bird on branch', description: 'This is a video containing bird on a branch.' } + }; + + // 3) (alternative) for a playlist of videos with ALL features enabled + const playlist: { + sources: SourceOptions[]; + options?: PlaylistOptions; + } = { + sources: [ + { + src: "https://ik.imagekit.io/ikmedia/docs/video-player/playlist/horses.mp4", + info: { title: "Horses Running", description: "Horses grazing in the field" }, + // Chapters: Auto-generate chapters + chapters: true, + // Subtitles: Auto-generated with translations + textTracks: [ + { + autoGenerate: true, + maxChars: 60, + highlightWords: true, + }, + { + autoGenerate: true, + showAutoGenerated: false, + translations: [ + { + langCode: "hi", + label: "Hindi (AI)", + }, + { + langCode: "de", + label: "German (AI)", + }, + ], + }, + ], + }, + { + src: "https://ik.imagekit.io/ikmedia/docs/video-player/playlist/lion.mp4", + info: { + title: "Lion", + description: "Lion roaming in the wild", + }, + // Chapters: Auto-generate chapters + chapters: true, + // Subtitles: Auto-generated with translations + textTracks: [ + { + autoGenerate: true, + maxChars: 60, + highlightWords: true, + }, + { + autoGenerate: true, + showAutoGenerated: false, + translations: [ + { + langCode: "hi", + label: "Hindi (AI)", + }, + { + langCode: "de", + label: "German (AI)", + }, + ], + }, + ], + // Shoppable: Products with hotspots + shoppable: { + products: [ + { + productId: 1, + productName: "Wildlife Photography Gear", + highlightTime: { start: 2, end: 6 }, + imageUrl: "https://ik.imagekit.io/ikmedia/docs/video-player/shoppable/aviators.jpeg", + hotspots: [ + { + time: "00:05", + x: "50%", + y: "40%", + tooltipPosition: "right", + clickUrl: "https://www.example.com/wildlife-gear" + } + ], + onHover: { + action: "overlay", + args: "Click to see wildlife photography gear" + }, + onClick: { + action: "goto", + pause: true, + args: { + url: "https://www.example.com/wildlife-gear" + } + } + } + ], + showPostPlayOverlay: true, + autoClose: false, + startState: "openOnPlay" + }, + }, + { + src: "https://ik.imagekit.io/ikmedia/docs/video-player/playlist/dog_running.mp4", + info: { title: "Dog Running" }, + // Chapters: Auto-generate chapters + chapters: true, + // Subtitles: Auto-generated + textTracks: [ + { + autoGenerate: true, + maxChars: 60, + highlightWords: true, + }, + ], + }, + { + src: "https://ik.imagekit.io/ikmedia/docs/video-player/playlist/man_smiling.mp4", + info: { + title: "Man Smiling", + description: "Man smiling at the camera", + }, + // Chapters: Auto-generate chapters + chapters: true, + // Subtitles: Auto-generated with translations + textTracks: [ + { + autoGenerate: true, + maxChars: 60, + highlightWords: true, + }, + { + autoGenerate: true, + showAutoGenerated: false, + translations: [ + { + langCode: "hi", + label: "Hindi (AI)", + }, + ], + }, + ], + // Shoppable: Products + shoppable: { + products: [ + { + productId: 1, + productName: "Camera Equipment", + imageUrl: "https://ik.imagekit.io/ikmedia/docs/video-player/shoppable/wooden_frames.jpeg", + onHover: { + action: "overlay", + args: "Click to see camera equipment" + }, + onClick: { + action: "goto", + pause: true, + args: { + url: "https://www.example.com/camera-equipment" + } + } + } + ], + showPostPlayOverlay: true, + autoClose: false, + startState: "openOnPlay" + }, + }, + { + src: "https://ik.imagekit.io/ikmedia/docs/video-player/playlist/rhino.mp4", + info: { title: "Rhino at the zoo"}, + // Chapters: Auto-generate chapters + chapters: true, + // Subtitles: Auto-generated + textTracks: [ + { + autoGenerate: true, + maxChars: 60, + highlightWords: true, + }, + ], + }, + { + src: "https://ik.imagekit.io/demo/sample-video.mp4", + info: { title: "Bird on branch"}, + // Chapters: Auto-generate chapters + chapters: true, + // Subtitles: Auto-generated with translations + textTracks: [ + { + autoGenerate: true, + maxChars: 60, + highlightWords: true, + }, + { + autoGenerate: true, + showAutoGenerated: false, + translations: [ + { + langCode: "hi", + label: "Hindi (AI)", + }, + { + langCode: "de", + label: "German (AI)", + }, + ], + }, + ], + }, + { + src: "https://ik.imagekit.io/ikmedia/docs/video-player/shoppable/video.mp4", + info: { title: "Shoppable Video", description: "Full-featured shoppable video with multiple products" }, + // Shoppable: Full product configuration from JavaScript example + shoppable: { + products: [ + { + productId: 1, + productName: "Classic Aviators", + highlightTime: { start: 2, end: 6 }, + imageUrl: "https://ik.imagekit.io/ikmedia/docs/video-player/shoppable/aviators.jpeg", + hotspots: [ + { + time: "00:06", + x: "48%", + y: "35%", + tooltipPosition: "left", + clickUrl: "https://images.pexels.com/photos/701877/pexels-photo-701877.jpeg" + } + ], + onHover: { + action: "overlay", + args: "Click to see this product in the video" + }, + onClick: { + action: "seek", + pause: 5, + args: { time: "00:06" } + } + }, + { + productId: 2, + productName: "Wooden frame glasses", + imageUrl: "https://ik.imagekit.io/ikmedia/docs/video-player/shoppable/wooden_frames.jpeg", + onHover: { + action: "switch", + args: { + url: "https://ik.imagekit.io/ikmedia/docs/video-player/shoppable/wooden_frames.jpeg" + } + }, + onClick: { + action: "goto", + pause: true, + args: { + url: "https://www.pexels.com/search/wooden%20glasses%20frames/" + } + } + }, + { + productId: 3, + productName: "Sunglasses", + imageUrl: "https://ik.imagekit.io/ikmedia/docs/video-player/shoppable/sunglass.jpeg", + onHover: { + action: "overlay", + args: "Click to go to website" + }, + onClick: { + action: "goto", + pause: true, + args: { + url: "https://www.pexels.com/photo/red-lens-sunglasses-on-sand-near-sea-at-sunset-selective-focus-photography-46710/" + } + } + }, + { + productId: 4, + productName: "Eye protection", + highlightTime: { start: 7, end: 9 }, + imageUrl: "https://ik.imagekit.io/ikmedia/docs/video-player/shoppable/protection.jpeg", + onClick: { + action: "goto", + pause: true, + args: { + url: "https://www.pexels.com/photo/red-lens-sunglasses-on-sand-near-sea-at-sunset-selective-focus-photography-46710/" + } + } + } + ], + showPostPlayOverlay: true, + autoClose: false, + startState: "open" + } + }, + { + src: "https://ik.imagekit.io/ikmedia/docs/video-player/subtitle_chapter/demo.mp4", + info: { title: "Chapters & Subtitles Demo", description: "Video with AI-generated chapters and subtitles" }, + // Chapters: Auto-generate chapters + chapters: true, + // Subtitles: Auto-generated with translations + textTracks: [ + { + autoGenerate: true, + translations: [ + { + langCode: "hi", + label: "Hindi (AI)", + default: true, + }, + { + langCode: "de", + label: "German (AI)", + }, + ], + } + ], + } + ], options: { + autoAdvance: 1, + repeat: true, + presentUpcoming: 10, + widgetProps: { direction: 'vertical' } + } + }; + + return ( +
+ +
+ + ); +} diff --git a/examples/react/src/main.tsx b/examples/react/src/main.tsx new file mode 100644 index 0000000..609e987 --- /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..d76d110 --- /dev/null +++ b/examples/react/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["DOM", "ESNext"], + "jsx": "react-jsx", + "module": "NodeNext", + "moduleResolution": "nodenext", + "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..c04a3b2 --- /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: { + '@imagekit/video-player/react': path.resolve(__dirname, '../../packages/video-player/dist/react'), + '@imagekit/video-player': path.resolve(__dirname, '../../packages/video-player/dist'), + } + }, +}); \ No newline at end of file diff --git a/examples/vue/.gitignore b/examples/vue/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/examples/vue/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/examples/vue/.vscode/extensions.json b/examples/vue/.vscode/extensions.json new file mode 100644 index 0000000..a7cea0b --- /dev/null +++ b/examples/vue/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["Vue.volar"] +} diff --git a/examples/vue/index.html b/examples/vue/index.html new file mode 100644 index 0000000..dde16aa --- /dev/null +++ b/examples/vue/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + Vue + TS + + +
+ + + diff --git a/examples/vue/package.json b/examples/vue/package.json new file mode 100644 index 0000000..0f87c33 --- /dev/null +++ b/examples/vue/package.json @@ -0,0 +1,22 @@ +{ + "name": "my-vue-app", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@imagekit/video-player": "workspace:*", + "vue": "^3.5.13" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.3", + "@vue/tsconfig": "^0.7.0", + "typescript": "~5.8.3", + "vite": "^6.3.5", + "vue-tsc": "^2.2.8" + } +} diff --git a/examples/vue/public/vite.svg b/examples/vue/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/examples/vue/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/vue/src/App.vue b/examples/vue/src/App.vue new file mode 100644 index 0000000..deb4758 --- /dev/null +++ b/examples/vue/src/App.vue @@ -0,0 +1,377 @@ + + + + + \ No newline at end of file diff --git a/examples/vue/src/assets/vue.svg b/examples/vue/src/assets/vue.svg new file mode 100644 index 0000000..770e9d3 --- /dev/null +++ b/examples/vue/src/assets/vue.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/vue/src/components/HelloWorld.vue b/examples/vue/src/components/HelloWorld.vue new file mode 100644 index 0000000..b58e52b --- /dev/null +++ b/examples/vue/src/components/HelloWorld.vue @@ -0,0 +1,41 @@ + + + + + diff --git a/examples/vue/src/main.ts b/examples/vue/src/main.ts new file mode 100644 index 0000000..01433bc --- /dev/null +++ b/examples/vue/src/main.ts @@ -0,0 +1,4 @@ +import { createApp } from 'vue' +import App from './App.vue' + +createApp(App).mount('#app') diff --git a/examples/vue/src/vite-env.d.ts b/examples/vue/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/examples/vue/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/vue/tsconfig.app.json b/examples/vue/tsconfig.app.json new file mode 100644 index 0000000..3dbbc45 --- /dev/null +++ b/examples/vue/tsconfig.app.json @@ -0,0 +1,15 @@ +{ + "extends": "@vue/tsconfig/tsconfig.dom.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"] +} diff --git a/examples/vue/tsconfig.json b/examples/vue/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/examples/vue/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/examples/vue/tsconfig.node.json b/examples/vue/tsconfig.node.json new file mode 100644 index 0000000..9728af2 --- /dev/null +++ b/examples/vue/tsconfig.node.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/examples/vue/vite.config.ts b/examples/vue/vite.config.ts new file mode 100644 index 0000000..b771321 --- /dev/null +++ b/examples/vue/vite.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import path from 'path' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@imagekit/video-player/vue': path.resolve(__dirname, '../../packages/video-player/dist/vue'), + '@imagekit/video-player': path.resolve(__dirname, '../../packages/video-player/dist'), + } + }, +}) diff --git a/imagekit-signer-server/.env.example b/imagekit-signer-server/.env.example new file mode 100644 index 0000000..858b711 --- /dev/null +++ b/imagekit-signer-server/.env.example @@ -0,0 +1,10 @@ +# ImageKit Configuration +IMAGEKIT_PUBLIC_KEY=your_public_key +IMAGEKIT_PRIVATE_KEY=your_private_key +IMAGEKIT_URL_ENDPOINT=ik.imagekit.io + +# Server Configuration (optional) +PORT=3001 + +# URL Expiration Time in seconds (optional, default: 600 = 10 minutes) +IMAGEKIT_EXPIRE_SECONDS=600 diff --git a/imagekit-signer-server/package-lock.json b/imagekit-signer-server/package-lock.json new file mode 100644 index 0000000..579839b --- /dev/null +++ b/imagekit-signer-server/package-lock.json @@ -0,0 +1,1042 @@ +{ + "name": "imagekit-signer-server", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "imagekit-signer-server", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "cors": "^2.8.5", + "dotenv": "^16.3.1", + "express": "^4.18.2", + "imagekit": "^4.1.1" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://npm.imagekit.io/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://npm.imagekit.io/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://npm.imagekit.io/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/async-generator-function": { + "version": "1.0.0", + "resolved": "https://npm.imagekit.io/async-generator-function/-/async-generator-function-1.0.0.tgz", + "integrity": "sha512-+NAXNqgCrB95ya4Sr66i1CL2hqLVckAk7xwRYWdcm39/ELQ6YNn1aw5r0bdQtqNZgQpEWzc5yc/igXc7aL5SLA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://npm.imagekit.io/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "0.27.2", + "resolved": "https://npm.imagekit.io/axios/-/axios-0.27.2.tgz", + "integrity": "sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.14.9", + "form-data": "^4.0.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://npm.imagekit.io/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://npm.imagekit.io/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://npm.imagekit.io/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://npm.imagekit.io/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://npm.imagekit.io/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://npm.imagekit.io/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://npm.imagekit.io/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://npm.imagekit.io/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://npm.imagekit.io/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://npm.imagekit.io/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://npm.imagekit.io/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://npm.imagekit.io/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://npm.imagekit.io/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://npm.imagekit.io/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://npm.imagekit.io/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://npm.imagekit.io/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://npm.imagekit.io/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://npm.imagekit.io/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://npm.imagekit.io/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://npm.imagekit.io/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://npm.imagekit.io/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://npm.imagekit.io/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://npm.imagekit.io/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://npm.imagekit.io/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://npm.imagekit.io/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://npm.imagekit.io/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://npm.imagekit.io/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://npm.imagekit.io/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://npm.imagekit.io/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://npm.imagekit.io/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://npm.imagekit.io/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://npm.imagekit.io/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.1", + "resolved": "https://npm.imagekit.io/get-intrinsic/-/get-intrinsic-1.3.1.tgz", + "integrity": "sha512-fk1ZVEeOX9hVZ6QzoBNEC55+Ucqg4sTVwrVuigZhuRPESVFpMyXnd3sbXvPOwp7Y9riVyANiqhEuRF0G1aVSeQ==", + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "async-generator-function": "^1.0.0", + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://npm.imagekit.io/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://npm.imagekit.io/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hamming-distance": { + "version": "1.0.0", + "resolved": "https://npm.imagekit.io/hamming-distance/-/hamming-distance-1.0.0.tgz", + "integrity": "sha512-hYz2IIKtyuZGfOqCs7skNiFEATf+v9IUNSOaQSr6Ll4JOxxWhOvXvc3mIdCW82Z3xW+zUoto7N/ssD4bDxAWoA==", + "license": "MIT" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://npm.imagekit.io/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://npm.imagekit.io/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://npm.imagekit.io/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://npm.imagekit.io/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://npm.imagekit.io/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/imagekit": { + "version": "4.1.4", + "resolved": "https://npm.imagekit.io/imagekit/-/imagekit-4.1.4.tgz", + "integrity": "sha512-yie0hMi+GVk8gtwi3hr3lwh/pAJv/jGpzteYOubs0I4h3Jic5xBXzWPrlGM4pPbAfWMr5UFXk8w0I4A1Xr6fDQ==", + "license": "MIT", + "dependencies": { + "axios": "^0.27.2", + "form-data": "^4.0.0", + "hamming-distance": "^1.0.0", + "lodash": "^4.17.15", + "tslib": "^2.4.0", + "uuid": "^8.3.2" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://npm.imagekit.io/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://npm.imagekit.io/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://npm.imagekit.io/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://npm.imagekit.io/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://npm.imagekit.io/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://npm.imagekit.io/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://npm.imagekit.io/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://npm.imagekit.io/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://npm.imagekit.io/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://npm.imagekit.io/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://npm.imagekit.io/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://npm.imagekit.io/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://npm.imagekit.io/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://npm.imagekit.io/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://npm.imagekit.io/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://npm.imagekit.io/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://npm.imagekit.io/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://npm.imagekit.io/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.0", + "resolved": "https://npm.imagekit.io/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://npm.imagekit.io/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://npm.imagekit.io/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://npm.imagekit.io/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://npm.imagekit.io/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://npm.imagekit.io/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://npm.imagekit.io/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://npm.imagekit.io/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://npm.imagekit.io/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://npm.imagekit.io/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://npm.imagekit.io/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://npm.imagekit.io/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://npm.imagekit.io/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://npm.imagekit.io/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://npm.imagekit.io/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://npm.imagekit.io/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://npm.imagekit.io/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://npm.imagekit.io/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://npm.imagekit.io/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://npm.imagekit.io/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://npm.imagekit.io/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + } + } +} diff --git a/imagekit-signer-server/package.json b/imagekit-signer-server/package.json new file mode 100644 index 0000000..761f1b6 --- /dev/null +++ b/imagekit-signer-server/package.json @@ -0,0 +1,25 @@ +{ + "name": "imagekit-signer-server", + "version": "1.0.0", + "description": "Node.js server for signing ImageKit URLs", + "main": "server.js", + "type": "module", + "scripts": { + "start": "node server.js", + "dev": "node --watch server.js" + }, + "keywords": [ + "imagekit", + "signer", + "url-signing" + ], + "author": "", + "license": "ISC", + "dependencies": { + "express": "^4.18.2", + "cors": "^2.8.5", + "dotenv": "^16.3.1", + "imagekit": "^4.1.1" + } +} + diff --git a/imagekit-signer-server/server.js b/imagekit-signer-server/server.js new file mode 100644 index 0000000..1d73f1b --- /dev/null +++ b/imagekit-signer-server/server.js @@ -0,0 +1,84 @@ +import express from 'express'; +import cors from 'cors'; +import dotenv from 'dotenv'; +import ImageKit from 'imagekit'; + +// Load environment variables from .env file +dotenv.config(); + +const app = express(); +const port = process.env.PORT || 3001; + +// Middleware +app.use(cors()); // Allow CORS requests +app.use(express.json()); // To parse JSON request bodies (for POST if needed) + +// Check for required environment variables +if (!process.env.IMAGEKIT_PUBLIC_KEY || !process.env.IMAGEKIT_PRIVATE_KEY || !process.env.IMAGEKIT_URL_ENDPOINT) { + console.error("Error: Make sure to create a .env file with your ImageKit credentials."); + console.error("Required variables: IMAGEKIT_PUBLIC_KEY, IMAGEKIT_PRIVATE_KEY, IMAGEKIT_URL_ENDPOINT"); + process.exit(1); +} + +// Initialize ImageKit SDK +const imagekit = new ImageKit({ + publicKey: process.env.IMAGEKIT_PUBLIC_KEY, + privateKey: process.env.IMAGEKIT_PRIVATE_KEY, + urlEndpoint: process.env.IMAGEKIT_URL_ENDPOINT, +}); + +// --- The Signing Route (POST) --- +// This endpoint accepts a URL in the request body and returns the signed URL as plain text +app.post('/sign-url', (req, res) => { + // Get the URL from request body + const { url: urlToSign } = req.body; + + if (!urlToSign) { + return res.status(400).send('URL is required in request body. Usage: POST /sign-url with body: { "url": "" }'); + } + + try { + // Note: Express automatically decodes query parameters, so urlToSign is already decoded + // Parse the URL to separate base URL from query parameters + const urlObject = new URL(urlToSign); + + // Get URL without query parameters + const urlWithoutQuery = `${urlObject.protocol}//${urlObject.host}${urlObject.pathname}`; + + // Extract query parameters separately + // searchParams automatically decodes values, which is what we want + const queryParams = {}; + urlObject.searchParams.forEach((value, key) => { + queryParams[key] = value; + }); + + // Sign the URL with expiration (default 10 minutes, configurable via env) + // const expireSeconds = parseInt(process.env.IMAGEKIT_EXPIRE_SECONDS || '600', 10); + const signedUrl = imagekit.url({ + src: urlWithoutQuery, + signed: true, + // expireSeconds: expireSeconds, + queryParameters: Object.keys(queryParams).length > 0 ? queryParams : undefined, + }); + + // Return the signed URL as plain text (not JSON) + res.setHeader('Content-Type', 'text/plain'); + res.send(signedUrl); + + } catch (error) { + console.error("Error signing URL:", error); + res.status(500).send(`Failed to sign URL: ${error.message}`); + } +}); + +// Health check endpoint +app.get('/health', (req, res) => { + res.json({ status: 'ok', service: 'imagekit-signer-server' }); +}); + +app.listen(port, () => { + console.log(`ImageKit Signer Server listening on http://localhost:${port}`); + console.log(`Sign URL endpoint: POST http://localhost:${port}/sign-url`); + console.log(`Request body: { "url": "" }`); +}); + diff --git a/lerna.json b/lerna.json new file mode 100644 index 0000000..f519d32 --- /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..ab2eaae --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "imagekit-video-player", + "private": true, + "version": "1.0.0", + "workspaces": { + "packages": [ + "packages/*", + "examples/*" + ], + "nohoist": [ + "**/video.js/**" + ] + }, + "scripts": { + "build": "lerna run build", + "dev": "lerna run build && lerna run dev --parallel" + }, + "devDependencies": { + "lerna": "^8.1.8" + }, + "packageManager": "yarn@4.9.2" +} diff --git a/packages/video-player/javascript/assets/icons/forward-10.svg b/packages/video-player/javascript/assets/icons/forward-10.svg new file mode 100644 index 0000000..bb6bf1f --- /dev/null +++ b/packages/video-player/javascript/assets/icons/forward-10.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/video-player/javascript/assets/icons/info-circle.svg b/packages/video-player/javascript/assets/icons/info-circle.svg new file mode 100644 index 0000000..c7c4a0b --- /dev/null +++ b/packages/video-player/javascript/assets/icons/info-circle.svg @@ -0,0 +1,17 @@ + + + + FAB45380-2D6C-4F74-A415-E842A0488451 + Created with sketchtool. + + + + + + + + + + + + diff --git a/packages/video-player/javascript/assets/icons/replay-10.svg b/packages/video-player/javascript/assets/icons/replay-10.svg new file mode 100644 index 0000000..5e23bcb --- /dev/null +++ b/packages/video-player/javascript/assets/icons/replay-10.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/video-player/javascript/index.ts b/packages/video-player/javascript/index.ts new file mode 100644 index 0000000..c11371e --- /dev/null +++ b/packages/video-player/javascript/index.ts @@ -0,0 +1,252 @@ +import videojs, { type Player as VideoJsPlayer } from 'video.js'; +import PluginType from 'video.js/dist/types/plugin'; +import './modules/http-source-selector/plugin'; +import './modules/context-menu/plugin'; +import type { IKPlayerOptions, Player } from './interfaces'; +import type { SourceOptions } from './interfaces'; +import type { AugmentedSourceOptions } from './interfaces/AugementedSourceOptions'; + +import { PlaylistManager } from './modules/playlist/playlist-manager'; +import { SeekThumbnailsManager } from './modules/seek-thumbnails/seek-thumbnails-manager'; +import { initChapterMarkers } from './modules/chapters/chapters'; +import './modules/recommendations-overlay/recommendations-overlay'; +import { ShoppableManager } from './modules/shoppable/shoppable-manager'; +import { validateIKPlayerOptions, CleanupRegistry } from './utils'; +import { enableFloatingPlayer } from './modules/floating-player'; +import './modules/logo-button'; +import { initializeLogoButton } from './modules/logo-button/init'; +import { setupKeyboardShortcuts } from './modules/keyboard-shortcuts'; +import { setupContextMenu } from './modules/context-menu/setup'; +import { createSourceOverride } from './modules/source-handler'; +import { extendTrackSettings } from './modules/subtitles/track-settings-extension'; + +const defaults: IKPlayerOptions = { + imagekitId: '', + floatingWhenNotVisible: null, + hideContextMenu: false, + logo: { showLogo: false, logoImageUrl: '', logoOnclickUrl: '' }, + seekThumbnails: true, + maxTries: 3, + videoTimeoutInMS: 55000, +}; + +const Plugin = videojs.getPlugin('plugin') as typeof PluginType; +/** + * ImageKit Video Player plugin for Video.js + * Extends Video.js with ImageKit-specific features like seek thumbnails, chapters, recommendations, and shoppable videos. + */ +class ImageKitVideoPlayerPlugin extends Plugin { + private ikGlobalSettings_: IKPlayerOptions; + private currentSource_: SourceOptions | null = null; + private originalCurrentSource_: SourceOptions | null = null; + private playlistManager_?: PlaylistManager; + private seekThumbnailsManager_?: SeekThumbnailsManager; + private shoppableManager_?: ShoppableManager; + private cleanup_ = new CleanupRegistry(); + + + constructor(player: Player, options: IKPlayerOptions) { + super(player); + + this.ikGlobalSettings_ = videojs.mergeOptions(defaults, options); + try { + validateIKPlayerOptions(this.ikGlobalSettings_); + + this.overrideSrc(); + + this.playlistManager_ = new PlaylistManager(this.player, this.ikGlobalSettings_); + + if (this.ikGlobalSettings_.floatingWhenNotVisible) { + const floatingCleanup = enableFloatingPlayer(this.player, this.ikGlobalSettings_.floatingWhenNotVisible); + if (floatingCleanup) { + this.cleanup_.register(floatingCleanup); + } + } + + this.player.on('loadstart', async () => { + if (this.seekThumbnailsManager_) { + this.seekThumbnailsManager_.destroy(this.player); + this.seekThumbnailsManager_ = undefined; + } + + if (this.shoppableManager_) { + this.shoppableManager_.destroy(); + this.shoppableManager_ = undefined; + } + + const initPromises: Promise[] = []; + + if (this.ikGlobalSettings_.seekThumbnails && this.currentSource_) { + initPromises.push( + SeekThumbnailsManager.initSeekThumbnails( + this.player, + this.currentSource_, + this.ikGlobalSettings_ + ).then(mgr => { + if (mgr) { + this.seekThumbnailsManager_ = mgr; + } + }) + ); + } + + initPromises.push( + initChapterMarkers(this.player, this.currentSource_, this.ikGlobalSettings_) + ); + + initPromises.push(this.initRecommendationsOverlay()); + + if (this.currentSource_?.shoppable) { + this.shoppableManager_ = new ShoppableManager(this.player, this.currentSource_); + } + + await Promise.all(initPromises); + }); + + this.player.ready(() => { + const playerEl = this.player.el(); + + this.cleanup_.registerEventListener( + playerEl, + 'mouseleave', + () => { + if (!this.player.paused()) { + this.player.addClass('vjs-user-inactive'); + } + } + ); + + this.cleanup_.registerEventListener( + playerEl, + 'mouseenter', + () => { + this.player.removeClass('vjs-user-inactive'); + } + ); + + initializeLogoButton(this.player, this.ikGlobalSettings_); + extendTrackSettings(this.player); + }); + + this.player.ready(() => { + setupKeyboardShortcuts(this.player, this.cleanup_); + }); + } + catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + player.error(`ImageKitVideoPlayerPlugin: ${errorMessage}`); + } + } + /** + * Overrides the Video.js src method to handle ImageKit source preparation, + * including URL signing, transformations, and ABS (HLS/DASH) support. + */ + private overrideSrc() { + this.player.src = createSourceOverride(this.player, { + options: this.ikGlobalSettings_, + getCurrentSource: () => { + return this.currentSource_; + }, + getOriginalCurrentSource: () => { + return this.originalCurrentSource_; + }, + onSourceUpdate: (source: SourceOptions) => { + this.currentSource_ = source; + }, + onOriginalSourceUpdate: (source: SourceOptions) => { + this.originalCurrentSource_ = source; + }, + hasPreparedSrc: (opts: SourceOptions): opts is AugmentedSourceOptions => { + return (opts as any).prepared && typeof (opts as any).prepared.src === 'string'; + }, + }) as any; + } + + + /** + * Initializes the recommendations overlay if recommendations are provided in the source. + */ + private async initRecommendationsOverlay() { + if (!this.currentSource_ || !this.currentSource_.recommendations) return; + + const overlay = this.player.getChild('RecommendationsOverlay'); + if (overlay) overlay.dispose(); + this.player.addChild('RecommendationsOverlay', { recommendations: this.currentSource_.recommendations, playerOptions: this.ikGlobalSettings_ }); + } + + /** + * Gets the current source being played, after ImageKit processing. + * @returns The current source or null if none is set + */ + public getCurrentSource() { + return this.currentSource_; + } + + /** + * Gets the original source as provided by the user, before ImageKit processing. + * @returns The original source or null if none is set + */ + public getOriginalCurrentSource() { + return this.originalCurrentSource_; + } + + /** + * Gets the playlist manager instance if a playlist is active. + * @returns The playlist manager instance or undefined + */ + public getPlaylistManager() { + return this.playlistManager_; + } + + /** + * Gets the ImageKit player options that were used to initialize the plugin. + * @returns The ImageKit player options + */ + public getPlayerOptions = (): IKPlayerOptions => { + return this.ikGlobalSettings_; + } + + /** + * Clean up all event listeners and resources when the plugin is disposed. + */ + dispose(): void { + this.cleanup_.dispose(); + super.dispose(); + } +} + +videojs.registerPlugin('imagekitVideoPlayer', ImageKitVideoPlayerPlugin); + +/** + * Creates and initializes an ImageKit Video Player instance. + * @param element - The video element ID or HTMLElement + * @param options - ImageKit player configuration options + * @param videoJsOptions - Optional Video.js player options + * @returns The initialized Video.js player instance with ImageKit extensions + */ +export function videoPlayer( + element: string | HTMLElement, + options: IKPlayerOptions, + videoJsOptions: any = {} +): Player { + + const player: VideoJsPlayer = videojs(element, { + ...videoJsOptions, + html5: { nativeTextTracks: false }, + plugins: { + ...(videoJsOptions.plugins ?? {}), + httpSourceSelector: { default: 'auto' }, + imagekitVideoPlayer: options, + }, + }); + + setupContextMenu(player, options); + + if (!('playlist' in player) || typeof (player as any).playlist !== 'function') { + throw new Error('ImageKit video player plugin failed to initialize: playlist method not found'); + } + + return player as unknown as Player; +} + +export * from './interfaces'; \ No newline at end of file diff --git a/packages/video-player/javascript/interfaces/ABSOptions.ts b/packages/video-player/javascript/interfaces/ABSOptions.ts new file mode 100644 index 0000000..227fc00 --- /dev/null +++ b/packages/video-player/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/video-player/javascript/interfaces/AugementedSourceOptions.ts b/packages/video-player/javascript/interfaces/AugementedSourceOptions.ts new file mode 100644 index 0000000..3fed06c --- /dev/null +++ b/packages/video-player/javascript/interfaces/AugementedSourceOptions.ts @@ -0,0 +1,43 @@ +import { SourceOptions } from "./SourceOptions"; + +export interface AugmentedSourceOptions extends SourceOptions { + prepared?: { + /** + * The source URL of the video after any transformations or processing. + */ + src?: string; + /** + * chapter URL after processing. + */ + chapter?: string; + /** + * Poster image URL after processing. + */ + poster?: string; + /** + * seek thumbnail URL after processing. + */ + seekThumbnail?: string; + /** + * playlist thumbnail URL after processing. + */ + playlistThumbnail?: string; + /** + * recommendation thumbnail URLs after processing. + */ + recommendationThumbnails?: string[] + /** + * shoppable thumbnail URLs after processing. + * + */ + shoppableThumbnails?: { + [key: number]: string[]; + }; + /** + * text tracks URLs after processing. + */ + textTracks?: { + [key: string]: string; + }; + } +} \ No newline at end of file diff --git a/packages/video-player/javascript/interfaces/Player.ts b/packages/video-player/javascript/interfaces/Player.ts new file mode 100644 index 0000000..e37137b --- /dev/null +++ b/packages/video-player/javascript/interfaces/Player.ts @@ -0,0 +1,83 @@ +import type BasePlayer from 'video.js/dist/types/player'; +import type { Transformation } from '@imagekit/javascript'; +import type { SourceOptions } from './SourceOptions'; +import type { PlaylistOptions } from './Playlist'; +import type { PlaylistManager } from '../modules/playlist/playlist-manager'; + +/** + * Interface for the ImageKit Video Player plugin instance. + * This is returned when calling player.imagekitVideoPlayer() without arguments. + */ +export interface ImageKitVideoPlayerPluginInstance { + getPlaylistManager(): PlaylistManager | undefined; + getOriginalCurrentSource(): SourceOptions | null; + getPlayerOptions(): IKPlayerOptions; +} + +export interface IKPlayerOptions { + /** 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; + /** Delay per try in ms */ + delayInMS?: number; + /** Signer function for generating signed url */ + signerFn?: (src: string) => Promise; +} + +/** + * Interface for ImageKit-specific Player methods. + * This interface defines method overloads that prioritize ImageKit signatures. + */ +interface ImageKitPlayerMethods { + /** + * Overridden src method that accepts ImageKit SourceOptions. + * This allows passing enhanced options like chapters, transformations, etc. + * + * @param source - ImageKit source options object + */ + src(source?: SourceOptions): void | string; + /** + * Initialize the ImageKit Video Player plugin with options. + * @param options - ImageKit player configuration options + */ + imagekitVideoPlayer(options: IKPlayerOptions): void; + /** + * Get the ImageKit Video Player plugin instance. + * Returns the plugin instance when called without arguments. + */ + imagekitVideoPlayer(): ImageKitVideoPlayerPluginInstance; + playlist(options: { + sources?: SourceOptions[]; + options?: PlaylistOptions; + }): PlaylistManager; +} + +/** + * Augmented Player type that includes ImageKit-specific methods. + * This type extends the base Video.js Player with additional functionality. + * + * Note: The src method is overridden to accept ImageKit SourceOptions. + */ +export type Player = BasePlayer & ImageKitPlayerMethods; \ No newline at end of file diff --git a/packages/video-player/javascript/interfaces/Playlist.ts b/packages/video-player/javascript/interfaces/Playlist.ts new file mode 100644 index 0000000..376bf38 --- /dev/null +++ b/packages/video-player/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/video-player/javascript/interfaces/Poster.ts b/packages/video-player/javascript/interfaces/Poster.ts new file mode 100644 index 0000000..de28dd1 --- /dev/null +++ b/packages/video-player/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/video-player/javascript/interfaces/Shoppable.ts b/packages/video-player/javascript/interfaces/Shoppable.ts new file mode 100644 index 0000000..dbc3b6b --- /dev/null +++ b/packages/video-player/javascript/interfaces/Shoppable.ts @@ -0,0 +1,56 @@ +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 type InteractionProps = + | { + action: 'overlay'; + /** in seconds or true/false for pause behavior */ + pause?: number | boolean; + /** The overlay text to display */ + args?: string; + } + | { + action: 'seek'; + /** in seconds or true/false for pause behavior */ + pause?: number | boolean; + /** Time to seek to (e.g., "00:06") */ + args?: { + time?: string; + }; + } + | { + action: 'goto' | 'switch'; + /** in seconds or true/false for pause behavior */ + pause?: number | boolean; + /** URL to navigate to or switch to */ + args?: { + 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 | false; + products: ProductProps[]; + showPostPlayOverlay?: boolean; + startState?: 'closed' | 'open' | 'openOnPlay'; + toggleIconUrl?: string; + transformation?: Transformation[]; + width?: number; // percentage of player width +} diff --git a/packages/video-player/javascript/interfaces/SourceOptions.ts b/packages/video-player/javascript/interfaces/SourceOptions.ts new file mode 100644 index 0000000..299cc24 --- /dev/null +++ b/packages/video-player/javascript/interfaces/SourceOptions.ts @@ -0,0 +1,63 @@ +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; + 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/description 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 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/video-player/javascript/interfaces/TextTrack.ts b/packages/video-player/javascript/interfaces/TextTrack.ts new file mode 100644 index 0000000..7ee8b75 --- /dev/null +++ b/packages/video-player/javascript/interfaces/TextTrack.ts @@ -0,0 +1,198 @@ +/** + * Core WebVTT options for text tracks (subtitles/captions). + * Used for regular text tracks with a source URL. + */ +export interface TextTrackOptions { + /** + * The kind of text track: 'subtitles' or 'captions' + */ + kind?: 'subtitles' | 'captions'; + /** + * The URL of the subtitle/caption file (VTT, SRT, etc.) + * For transcript files, use a URL ending with '.transcript' + */ + src?: string; + /** + * Language code for the text track (e.g., 'en', 'es', 'fr') + */ + srclang?: string; + /** + * Display label for the text track in the subtitle menu + */ + label?: string; + /** + * Whether this track should be selected by default + */ + default?: boolean; + /** + * Maximum number of characters that can appear on a subtitle frame (only for transcript files) + * @default 60 + */ + maxChars?: number; + /** + * Enable word-level highlighting in subtitles (only for transcript files) + * When enabled, words are highlighted as they are spoken + * @default false + */ + highlightWords?: boolean; +} + +export const languageCodes = Object.freeze({ + en: "English", + es: "Spanish", + fr: "French", + de: "German", + it: "Italian", + pt: "Portuguese", + nl: "Dutch", + hi: "Hindi", + ja: "Japanese", + zh: "Chinese", + fi: "Finnish", + ko: "Korean", + pl: "Polish", + ru: "Russian", + tr: "Turkish", + uk: "Ukrainian", + vi: "Vietnamese", + af: "Afrikaans", + sq: "Albanian", + am: "Amharic", + ar: "Arabic", + hy: "Armenian", + as: "Assamese", + az: "Azerbaijani", + ba: "Bashkir", + eu: "Basque", + be: "Belarusian", + bn: "Bengali", + bs: "Bosnian", + br: "Breton", + bg: "Bulgarian", + my: "Burmese", + ca: "Catalan", + hr: "Croatian", + cs: "Czech", + da: "Danish", + et: "Estonian", + fo: "Faroese", + gl: "Galician", + ka: "Georgian", + el: "Greek", + gu: "Gujarati", + ht: "Haitian", + ha: "Hausa", + haw: "Hawaiian", + he: "Hebrew", + hu: "Hungarian", + is: "Icelandic", + id: "Indonesian", + jw: "Javanese", + kn: "Kannada", + kk: "Kazakh", + km: "Khmer", + lo: "Lao", + la: "Latin", + lv: "Latvian", + ln: "Lingala", + lt: "Lithuanian", + lb: "Luxembourgish", + mk: "Macedonian", + mg: "Malagasy", + ms: "Malay", + ml: "Malayalam", + mt: "Maltese", + mi: "Maori", + mr: "Marathi", + mn: "Mongolian", + ne: "Nepali", + no: "Norwegian", + nn: "Norwegian Nynorsk", + oc: "Occitan", + pa: "Panjabi", + ps: "Pashto", + fa: "Persian", + ro: "Romanian", + sa: "Sanskrit", + sr: "Serbian", + sn: "Shona", + sd: "Sindhi", + si: "Sinhala", + sk: "Slovak", + sl: "Slovenian", + so: "Somali", + su: "Sundanese", + sw: "Swahili", + sv: "Swedish", + tl: "Tagalog", + tg: "Tajik", + ta: "Tamil", + tt: "Tatar", + te: "Telugu", + th: "Thai", + bo: "Tibetan", + tk: "Turkmen", + ur: "Urdu", + uz: "Uzbek", + cy: "Welsh", + yi: "Yiddish", + yo: "Yoruba", +}); + +/** + * Options for auto-generated subtitles using AI transcription. + * When autoGenerate is true, subtitles are automatically generated from the video's audio. + */ +export interface AutoGeneratedTextTrackOptions { + /** + * Must be set to true to enable auto-generated subtitles + */ + autoGenerate: true; + /** + * Whether to show auto-generated subtitles in the subtitle dropdown menu + * @default true + */ + showAutoGenerated?: boolean; + /** + * Maximum number of characters that can appear on a subtitle frame + * @default 60 + */ + maxChars?: number; + /** + * Enable word-level highlighting in subtitles + * When enabled, words are highlighted as they are spoken + * @default false + */ + highlightWords?: boolean; + /** + * Custom label for the auto-generated subtitle track + * If not provided, defaults to "AI Gen Subtitles" + */ + autoGeneratedLabel?: string; + /** + * Whether this track should be selected by default + */ + default?: boolean; + /** + * Array of translation options for the auto-generated subtitles + * Each translation provides subtitles in a different language + */ + translations?: Array<{ + /** + * Language code for the translation (e.g., 'fr', 'hi', 'es') + * Must be a valid key from the languageCodes object + */ + langCode: keyof typeof languageCodes; + /** + * Custom label for this translation track + * If not provided, defaults to "{Language Name} (AI generated)" + */ + label?: string; + /** + * Whether this translation should be selected by default + */ + default?: boolean; + }>; +} + +export type RemoteTextTrackOptions = TextTrackOptions | AutoGeneratedTextTrackOptions; \ No newline at end of file diff --git a/packages/video-player/javascript/interfaces/index.ts b/packages/video-player/javascript/interfaces/index.ts new file mode 100644 index 0000000..6351340 --- /dev/null +++ b/packages/video-player/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/video-player/javascript/modules/chapters/chapter.scss b/packages/video-player/javascript/modules/chapters/chapter.scss new file mode 100644 index 0000000..5696aa0 --- /dev/null +++ b/packages/video-player/javascript/modules/chapters/chapter.scss @@ -0,0 +1,57 @@ +.video-js { + .vjs-control-bar { + .vjs-chapter-boundary { + position: absolute; + top: 0; + height: 100%; + width: 2px; + background-color: black; + pointer-events: none; + z-index: 3; + } + + .vjs-chapter-tooltip-container { + 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; + position: absolute; + bottom: calc(100% + 1em); + transform: translateX(-50%) scaleY(0.5); + display: none; + pointer-events: none; + z-index: 4; + transition: transform 0.1s ease-in-out; + } + + .vjs-progress-control:hover { + .vjs-progress-holder .vjs-chapter-tooltip-container { + font-size: 1em; + } + } + + .vjs-control-bar-chapter-wrapper { + display: flex; + align-items: center; + container-type: inline-size; + } + + .vjs-control-bar-chapter-display { + line-height: 1.5; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + padding: 0 0.5em; + &:not(:empty)::before { + content: '•'; + padding-right: 0.5em; + } + @container (max-width: 150px) { + display: none; + } + } + } +} \ No newline at end of file diff --git a/packages/video-player/javascript/modules/chapters/chapterMarkerProgressBar.ts b/packages/video-player/javascript/modules/chapters/chapterMarkerProgressBar.ts new file mode 100644 index 0000000..461756e --- /dev/null +++ b/packages/video-player/javascript/modules/chapters/chapterMarkerProgressBar.ts @@ -0,0 +1,235 @@ +import videojs from 'video.js'; +import type Player from 'video.js/dist/types/player'; +import { CleanupRegistry } from '../../utils'; + +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 { + private chapterTooltipContainer: HTMLElement | null = null; + private chapterBoundaries: HTMLElement[] = []; + private chapters: ChapterMarker[] = []; + private cleanup_ = new CleanupRegistry(); + + constructor(player: Player, options: ChapterMarkersProgressBarControlOptions) { + super(player, options); + this.chapters = options.chapters || []; + + player.ready(() => { + this.addMarkers(this.chapters, player); + this.attachHoverHandlers(player); + }); + } + + /** + * Creates the visual elements for chapter markers and a single tooltip container. + */ + addMarkers(chapters: ChapterMarker[], player: Player) { + const duration = player.duration(); + if (!duration || duration <= 0) return; + + const playheadWell = player.el().querySelector('.vjs-progress-holder'); + if (!playheadWell) return; + + // Clear any previous markers + this.dispose(); + + // Create a single container for the chapter tooltip + this.chapterTooltipContainer = document.createElement('div'); + this.chapterTooltipContainer.className = 'vjs-chapter-tooltip-container'; // New class for styling + playheadWell.appendChild(this.chapterTooltipContainer); + + chapters.forEach((chapter, idx) => { + if (chapter.startTime < 0 || chapter.endTime > duration || chapter.endTime <= chapter.startTime) { + return; + } + + const leftPct = (chapter.startTime / duration) * 100; + + // Only add boundary markers + if (idx > 0) { + const boundary = document.createElement('div'); + boundary.className = 'vjs-chapter-boundary'; + boundary.style.left = leftPct + '%'; + playheadWell.appendChild(boundary); + this.chapterBoundaries.push(boundary); + } + }); + } + + /** + * Attaches mouse move and leave handlers to the main progress control. + */ + attachHoverHandlers(player: Player) { + // Access controlBar.progressControl via type assertion (Video.js internal API) + const playerWithControlBar = player as unknown as { + controlBar: { + progressControl: { + el(): HTMLElement; + }; + }; + }; + const progressControl = playerWithControlBar.controlBar.progressControl; + + const mousemoveHandler = (e: MouseEvent) => { + if (!this.chapterTooltipContainer) return; + + const playerEl = player.el(); + const playerRect = playerEl.getBoundingClientRect(); + const playerWidth = (playerEl as HTMLElement).offsetWidth; + + const barRect = progressControl.el().getBoundingClientRect(); + const progressHolder = progressControl.el().querySelector('.vjs-progress-holder') as HTMLElement; + + if (!progressHolder) return; + + const pct = Math.max(0, Math.min(1, (e.clientX - barRect.left) / barRect.width)); + const time = pct * player.duration(); + + // Find the chapter for the current hover time + const chapter = this.chapters.find(c => time >= c.startTime && time < c.endTime); + + if (chapter) { + // Update tooltip text first + this.chapterTooltipContainer.innerText = chapter.label; + this.chapterTooltipContainer.style.display = 'block'; + + // Measure tooltip width (now that it's visible with text) + const tooltipWidth = this.chapterTooltipContainer.offsetWidth; + const tooltipHalfWidth = tooltipWidth / 2; + + // Calculate position in player coordinates + const progressBarLeftOffset = barRect.left - playerRect.left; + const hoverPositionInPlayer = progressBarLeftOffset + (pct * barRect.width); + + // Clamp position + let clampedLeft = hoverPositionInPlayer; + if (clampedLeft < tooltipHalfWidth) { + clampedLeft = tooltipHalfWidth; + } else if (clampedLeft > playerWidth - tooltipHalfWidth) { + clampedLeft = playerWidth - tooltipHalfWidth; + } + + // Convert to progress-holder relative position + const progressHolderRect = progressHolder.getBoundingClientRect(); + const leftRelativeToHolder = clampedLeft - (progressHolderRect.left - playerRect.left); + const leftPct = (leftRelativeToHolder / progressHolderRect.width) * 100; + + // Set position + this.chapterTooltipContainer.style.left = `${leftPct}%`; + } else { + // Hide if not over a chapter + this.chapterTooltipContainer.style.display = 'none'; + } + }; + + const mouseleaveHandler = () => { + // Hide the tooltip when leaving the progress bar + if (this.chapterTooltipContainer) { + this.chapterTooltipContainer.style.display = 'none'; + } + }; + + this.cleanup_.registerVideoJsListener(progressControl, 'mousemove', mousemoveHandler); + this.cleanup_.registerVideoJsListener(progressControl, 'mouseleave', mouseleaveHandler); + } + + /** + * On dispose, remove all created elements. + */ + dispose() { + this.chapterBoundaries.forEach((boundary) => { + if (boundary && boundary.parentNode) { + boundary.remove(); + } + }); + this.chapterBoundaries = []; + + if (this.chapterTooltipContainer && this.chapterTooltipContainer.parentNode) { + this.chapterTooltipContainer.remove(); + this.chapterTooltipContainer = null; + } + + this.cleanup_.dispose(); + 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/video-player/javascript/modules/chapters/chapters.ts b/packages/video-player/javascript/modules/chapters/chapters.ts new file mode 100644 index 0000000..d51a54c --- /dev/null +++ b/packages/video-player/javascript/modules/chapters/chapters.ts @@ -0,0 +1,432 @@ +import type Player from 'video.js/dist/types/player'; +import type { SourceOptions, IKPlayerOptions } from '../../interfaces'; +import { ChapterMarker, parseChaptersFromVTT } from './chapterMarkerProgressBar'; +import { prepareChaptersVttSrc, CleanupRegistry } from '../../utils'; + +interface ChapterTrackMetadata { + langCode: string; + chapterList: ChapterMarker[]; + vttUrl: string; +} + +// Map to store chapter tracks by language +const chapterTracksCache = new Map(); + +// Cleanup registry for per-chapter resources (tracks, labels, progress bar) +let perChapterCleanup: CleanupRegistry | null = null; + +// Cleanup registry for persistent resources (subtitle sync listener) +let persistentCleanup: CleanupRegistry | null = null; + +/** + * Clean up existing chapter text tracks from the player. + */ +function cleanupChapterTextTracks(player: Player): void { + const playerWithTextTracks = player as unknown as { + remoteTextTracks(): TextTrackList; + removeRemoteTextTrack(track: TextTrack): void; + }; + const textTracks = playerWithTextTracks.remoteTextTracks(); + + for (let i = textTracks.length - 1; i >= 0; i--) { + if (textTracks[i].kind === 'chapters') { + playerWithTextTracks.removeRemoteTextTrack(textTracks[i]); + } + } +} + +/** + * Clean up existing chapter label display from the control bar. + */ +function cleanupChapterLabelDisplay(player: Player): void { + const playerWithQuery = player as unknown as { + $(selector: string): HTMLElement | null; + }; + const spacer = playerWithQuery.$('.vjs-control-bar .vjs-spacer') as HTMLElement | null; + if (spacer) { + spacer.classList.remove('vjs-control-bar-chapter-wrapper'); + const display = spacer.querySelector('.vjs-control-bar-chapter-display'); + if (display) { + display.remove(); + } + } +} + +/** + * Clean up all chapter-related components from the player. + * Removes chapter text tracks, label display, progress bar control, and per-chapter event listeners. + * Does NOT clean up persistent listeners (like subtitle sync). + */ +function cleanupChapters(player: Player): void { + cleanupChapterTextTracks(player); + cleanupChapterLabelDisplay(player); + + // Dispose per-chapter cleanups (cuechange listeners, etc.) + if (perChapterCleanup) { + perChapterCleanup.dispose(); + perChapterCleanup = null; + } + + const existing = player.getChild('ChapterMarkersProgressBarControl'); + if (existing) { + existing.dispose(); + } +} + +/** + * Clean up ALL chapter resources including persistent listeners. + * Should be called when completely removing chapters (e.g., new source). + */ +function cleanupAllChapters(player: Player): void { + cleanupChapters(player); + + // Dispose persistent cleanups (subtitle sync listener) + if (persistentCleanup) { + persistentCleanup.dispose(); + persistentCleanup = null; + } +} + + +/** + * Load chapters for a specific language + */ +async function loadChaptersForLanguage( + player: Player, + vttUrl: string, + langCode: string = 'en' +): Promise { + // Check cache first + if (chapterTracksCache.has(langCode)) { + return chapterTracksCache.get(langCode)!.chapterList; + } + + try { + const res = await fetch(vttUrl); + if (!res.ok) { + player.log.warn(`Chapter VTT fetch failed for ${langCode} with status: (${res.status})`); + return []; + } + const data = await res.text(); + const chapterList = parseChaptersFromVTT(data); + + // Cache the chapters + chapterTracksCache.set(langCode, { langCode, chapterList, vttUrl }); + + return chapterList; + } catch (e) { + player.log.error(`Failed to fetch chapters for ${langCode}: ${e}`); + return []; + } +} + +/** + * Switch chapters to match the active subtitle language + */ +let isSwitchingChapters = false; + +async function switchChaptersLanguage( + player: Player, + langCode: string +): Promise { + if (isSwitchingChapters) { + return; // Prevent concurrent switches + } + + isSwitchingChapters = true; + try { + const metadata = chapterTracksCache.get(langCode); + + if (!metadata) { + player.log.warn(`No chapter track found for language: ${langCode}`); + return; + } + + // Clean up existing chapters + cleanupChapters(player); + + // Apply new chapters + applyChaptersToPlayer(player, metadata.chapterList); + } finally { + isSwitchingChapters = false; + } +} + +/** + * Apply chapter list to player (extracted for reuse) + */ +function applyChaptersToPlayer(player: Player, chapterList: ChapterMarker[]): void { + if (chapterList.length === 0) return; + + try { + const trackEl = player.addRemoteTextTrack( + { + kind: 'chapters', + default: true, + }, + false + ) as unknown as HTMLTrackElement; + + chapterList.forEach((chapter) => { + const cue = new VTTCue(chapter.startTime, chapter.endTime, chapter.label); + trackEl.track.addCue(cue); + }); + + setupChapterLabelDisplay(player, trackEl.track); + + // Update the chapters button + const controlBar = player.getChild('ControlBar'); + if (controlBar) { + const chaptersButton = controlBar.getChild('ChaptersButton'); + if (chaptersButton && typeof (chaptersButton as any).update === 'function') { + (chaptersButton as any).update(); + } + } + } catch (e) { + player.log.warn(`Failed to create chapter text track: ${e}`); + } + + const existing = player.getChild('ChapterMarkersProgressBarControl'); + if (existing) { + existing.dispose(); + } + player.addChild('ChapterMarkersProgressBarControl', { chapters: chapterList }); +} + +/** + * Setup listener for subtitle track changes to switch chapters accordingly + */ +function setupSubtitleChapterSync(player: Player): void { + // Initialize persistent cleanup registry if needed + if (!persistentCleanup) { + persistentCleanup = new CleanupRegistry(); + } + + let currentChapterLang = 'base'; + let pendingSwitch: number | null = null; + + const handler = () => { + // Get fresh reference to textTracks inside handler to avoid stale references + // when source changes and textTracks are cleared/reset + const currentTextTracks = player.textTracks(); + if (!currentTextTracks || player.isDisposed()) { + return; + } + + let targetLang = 'base'; + + // Check for active subtitle track + // TextTrackList is array-like, iterate using index access + const textTracksList = currentTextTracks as unknown as TextTrack[]; + for (let i = 0; i < textTracksList.length; i++) { + const track = textTracksList[i]; + + // Find the active subtitle track + if ((track.kind === 'subtitles' || track.kind === 'captions') && track.mode === 'showing') { + targetLang = track.language || 'en'; + break; + } + } + + // normalize base language fallback + if (!chapterTracksCache.has(targetLang) && chapterTracksCache.has('base')) { + targetLang = 'base'; + } + + // Skip if already on this language + if (targetLang === currentChapterLang) { + return; + } + + // Debounce to avoid rapid switching + if (pendingSwitch !== null) { + clearTimeout(pendingSwitch); + } + + pendingSwitch = window.setTimeout(() => { + pendingSwitch = null; + if (player.isDisposed() || !chapterTracksCache.has(targetLang)) { + return; + } + + currentChapterLang = targetLang; + switchChaptersLanguage(player, targetLang).catch((err) => { + player.log.warn('Chapter switch failed:', err); + }); + }, 50); // Small delay to let Video.js finish internal updates + }; + + // Defer listener setup to next tick to ensure TextTrackList is stable + setTimeout(() => { + if (player.isDisposed()) return; + + const textTracks = player.textTracks(); + persistentCleanup!.registerEventListener( + textTracks as unknown as EventTarget, + 'change', + handler + ); + handler(); // initial sync + }, 0); +} + +/** + * Setup chapter label display in the control bar. + * Displays the current chapter name and updates on cuechange events. + */ +function setupChapterLabelDisplay(player: Player, chaptersTrack: TextTrack): void { + const playerWithQuery = player as unknown as { + $(selector: string): HTMLElement | null; + }; + + const controlBarChapterHolder = + (playerWithQuery.$('.vjs-control-bar-chapter-display') as HTMLElement) || + document.createElement('div'); + controlBarChapterHolder.setAttribute('class', 'vjs-control-bar-chapter-display'); + + const spacer = playerWithQuery.$('.vjs-control-bar .vjs-spacer') as HTMLElement | null; + if (!spacer) { + player.log.warn('Control bar spacer not found, cannot setup chapter display'); + return; + } + + const existingDisplay = spacer.querySelector('.vjs-control-bar-chapter-display'); + if (existingDisplay) { + existingDisplay.remove(); + } + + spacer.classList.add('vjs-control-bar-chapter-wrapper'); + spacer.appendChild(controlBarChapterHolder); + + const cueChangeHandler = () => { + // Safari needs Array.from() for activeCues + const activeCues = Array.from(chaptersTrack.activeCues); + if (activeCues.length > 0) { + const cue = activeCues[0] as VTTCue; + controlBarChapterHolder.innerText = cue.text || ''; + } else { + controlBarChapterHolder.innerText = ''; + } + }; + + // Register the cuechange listener with per-chapter cleanup registry + if (!perChapterCleanup) { + perChapterCleanup = new CleanupRegistry(); + } + perChapterCleanup.registerEventListener(chaptersTrack, 'cuechange', cueChangeHandler); + cueChangeHandler(); +} + +/** + * Initialize chapter markers for the video player. + * Supports three methods: + * 1. Auto-generate: chapters: true (with optional translations) + * 2. VTT URL: chapters: { url: string } + * 3. Manual object: chapters: { [timeInSec]: title } + */ +export async function initChapterMarkers( + player: Player, + source: SourceOptions | SourceOptions[] | null, + ikGlobalSettings: IKPlayerOptions +): Promise { + + // Clean up ALL chapters including persistent listeners for new source + cleanupAllChapters(player); + + // Clear cache for new source + chapterTracksCache.clear(); + + if (!source) return; + + const src = Array.isArray(source) ? source[0] : source; + if (!src.chapters) return; + + let chapterList: ChapterMarker[] = []; + + function waitForLoadedMetadata(player: Player): Promise { + return new Promise((resolve) => { + if (player.readyState() > 0) { + resolve(); + return; + } + player.one('loadedmetadata', () => resolve()); + }); + } + + await waitForLoadedMetadata(player); + + if (typeof src.chapters === 'object' && 'url' in src.chapters) { + // Manual VTT URL - load directly + try { + let vttUrl = src.chapters.url; + + if (ikGlobalSettings.signerFn) { + try { + vttUrl = await ikGlobalSettings.signerFn(vttUrl); + } catch (err) { + player.log.error(`Failed to sign chapter VTT URL: ${err instanceof Error ? err.message : String(err)}`); + return; + } + } + + const res = await fetch(vttUrl); + if (!res.ok) { + player.log.warn(`VTT fetch failed with status: (${res.status}); skipping chapters.`); + return; + } + const data = await res.text(); + chapterList = parseChaptersFromVTT(data); + } catch (e) { + player.log.error(`Failed to fetch chapters VTT: ${e instanceof Error ? e.message : String(e)}`); + return; + } + } else if (typeof src.chapters === 'object') { + // Manual chapter object - convert to ChapterMarker[] + const entries = Object.entries(src.chapters) + .map(([time, label]) => ({ startTime: Number(time), label: String(label) })) + .sort((a, b) => a.startTime - b.startTime); + + const duration = player.duration() || 0; + + chapterList = entries.map((entry, index) => { + const endTime = + index < entries.length - 1 + ? entries[index + 1].startTime + : duration || entry.startTime + 10; + return { + startTime: entry.startTime, + endTime: endTime, + label: entry.label, + }; + }); + } else if (src.chapters === true) { + // Auto-generate chapters with translations support + try { + const { baseUrl, translatedUrls } = await prepareChaptersVttSrc(src, ikGlobalSettings); + + // Load base language chapters + chapterList = await loadChaptersForLanguage(player, baseUrl, 'base'); + + // Pre-load translated chapters in background + if (translatedUrls.size > 0) { + for (const [langCode, url] of translatedUrls.entries()) { + // Load in background without blocking + loadChaptersForLanguage(player, url, langCode).catch(err => { + player.log.warn(`Failed to pre-load chapters for ${langCode}:`, err); + }); + } + + // Setup subtitle-chapter synchronization + setupSubtitleChapterSync(player); + } + } catch (e) { + player.log.error(`Failed to fetch default chapters VTT: ${e}`); + return; + } + } + + // Apply the base/default chapters + if (chapterList.length > 0) { + applyChaptersToPlayer(player, chapterList); + } +} diff --git a/packages/video-player/javascript/modules/context-menu/context-menu-item.ts b/packages/video-player/javascript/modules/context-menu/context-menu-item.ts new file mode 100644 index 0000000..9a52650 --- /dev/null +++ b/packages/video-player/javascript/modules/context-menu/context-menu-item.ts @@ -0,0 +1,54 @@ +// ./context-menu-item.ts +import videojs from 'video.js'; +import type Player from 'video.js/dist/types/player'; +import type MenuItem from 'video.js/dist/types/menu/menu-item'; +import { CleanupRegistry } from '../../utils'; +import './types'; + +const VjsMenuItem = videojs.getComponent('MenuItem') as typeof MenuItem; + +interface ContextMenuItemOptions { + label: string; + listener: (this: Player) => void; + children?: unknown[]; + className?: string; +} + +class ContextMenuItem extends VjsMenuItem { + private cleanup_ = new CleanupRegistry(); + private listener_: (this: Player) => void; + + constructor(player: Player, options: ContextMenuItemOptions) { + super(player, { + label: options.label, + selectable: false, + children: options.children, + className: options.className + }); + + this.listener_ = options.listener; + } + + handleClick(event: Event): void { + // Parent handleClick just calls selected(true), which does nothing + // when selectable: false, so we skip it entirely + + this.listener_.call(this.player()); + + this.cleanup_.registerTimeout(() => { + const player = this.player() as Player & { + contextmenuUI?: { menu?: { dispose(): void } } + }; + player.contextmenuUI?.menu?.dispose(); + }, 0); + } + + dispose(): void { + this.cleanup_.dispose(); + super.dispose(); + } +} + +videojs.registerComponent('ContextMenuItem', ContextMenuItem); + +export default ContextMenuItem; \ No newline at end of file diff --git a/packages/video-player/javascript/modules/context-menu/context-menu.css b/packages/video-player/javascript/modules/context-menu/context-menu.css new file mode 100644 index 0000000..4bc40a0 --- /dev/null +++ b/packages/video-player/javascript/modules/context-menu/context-menu.css @@ -0,0 +1,51 @@ +/** + * Updated styles for the Video.js Context Menu + * - Increases overall size and font size. + * - Left-aligns text for a more standard menu look. + * - Aligns hover effects and spacing with the native Video.js theme. + */ + + .vjs-contextmenu-ui-menu { + /* Set a minimum width to make the menu feel more substantial */ + min-width: 12em; + position: absolute; +} + +.vjs-contextmenu-ui-menu .vjs-menu-content { + /* Use the standard Video.js semi-transparent background */ + background-color: #2B333F; + background-color: rgba(43, 51, 63, 0.85); /* Slightly less transparent for readability */ + border-radius: 0.3em; + /* Increased padding for more breathing room around the items */ + padding: 0.5em; +} + +.vjs-contextmenu-ui-menu .vjs-menu-item { + /* --- REQUEST: Font to be little big --- */ + font-size: 1.3em; /* Increased from 1em */ + color: #ffffff; + + /* --- REQUEST: Items to be left aligned --- */ + text-align: left; + + /* --- AESTHETICS: Better spacing and alignment --- */ + padding: 0.7em 1.2em; /* Increased padding for a less cramped feel */ + line-height: 1.4; + cursor: pointer; + margin: 1px 0; + border-radius: 0.2em; + text-transform: none; + justify-content: flex-start; + + /* Use a smooth transition for the hover effect, just like native VJS controls */ + transition: background-color 0.15s ease-in-out; +} + +/* --- AESTHETICS: Hover state aligned with Video.js native theme --- */ +.vjs-contextmenu-ui-menu .vjs-menu-item:hover, +.vjs-contextmenu-ui-menu .vjs-menu-item:focus { + /* A lighter shade of the background, similar to VJS control bar button hovers */ + background-color: #3d4858; + /* Removed the text-shadow for a cleaner, more modern look */ + text-shadow: none; +} \ No newline at end of file diff --git a/packages/video-player/javascript/modules/context-menu/context-menu.ts b/packages/video-player/javascript/modules/context-menu/context-menu.ts new file mode 100644 index 0000000..e2b5f24 --- /dev/null +++ b/packages/video-player/javascript/modules/context-menu/context-menu.ts @@ -0,0 +1,59 @@ +// ./context-menu.ts +import videojs from 'video.js'; +import type Player from 'video.js/dist/types/player'; +import type Menu from 'video.js/dist/types/menu/menu'; +import ContextMenuItem from './context-menu-item'; +import { ContextMenuItemOptions } from './types'; +import './types'; + +const VjsMenu = videojs.getComponent('Menu') as typeof Menu; + +interface ContextMenuOptions { + content: ContextMenuItemOptions[]; + position: { left: number; top: number }; + children?: any[]; + className?: string; +} + +class ContextMenu extends VjsMenu { + constructor(player: Player, options: ContextMenuOptions) { + super(player, options); + + // Build menu items from content + options.content.forEach(contentItem => { + // Determine listener function + const listener: (this: Player) => void = + typeof contentItem.listener === 'function' + ? contentItem.listener + : typeof contentItem.href === 'string' + ? function() { window.open(contentItem.href); } + : function() { /* no-op */ }; + + // Add menu item + this.addItem( + new ContextMenuItem(player, { + label: contentItem.label, + listener: listener.bind(player), + }) + ); + }); + } + + createEl(): HTMLElement { + const el = super.createEl() as HTMLElement; + + // Add CSS class + el.classList.add('vjs-contextmenu-ui-menu'); + + // Set position + const position = (this.options_ as ContextMenuOptions).position; + el.style.left = `${position.left}px`; + el.style.top = `${position.top}px`; + + return el; + } +} + +videojs.registerComponent('ContextMenu', ContextMenu); + +export default ContextMenu; \ No newline at end of file diff --git a/packages/video-player/javascript/modules/context-menu/plugin.ts b/packages/video-player/javascript/modules/context-menu/plugin.ts new file mode 100644 index 0000000..2c52dfb --- /dev/null +++ b/packages/video-player/javascript/modules/context-menu/plugin.ts @@ -0,0 +1,219 @@ +// ./plugin.ts +import videojs from 'video.js'; +import type Player from 'video.js/dist/types/player'; +import type Component from 'video.js/dist/types/component'; + +import ContextMenu from './context-menu'; +import { getPointerPosition } from './utils'; +import { PluginOptions, ContextMenuUI } from './types'; +import { addEventListener } from '../../utils'; +import './types'; // Import for module augmentation side-effects + +// Extended Player type with contextmenuUI plugin properties +type PlayerWithContextMenu = Player & { + contextmenuUI?: ContextMenuUI; + contextmenuUICleanups_?: Array<() => void>; +}; + +// Type guard to check if contextmenuUI property exists (may not be initialized) +function hasContextMenuUI(player: Player): player is PlayerWithContextMenu & { contextmenuUI: ContextMenuUI } { + const playerWithContextMenu = player as PlayerWithContextMenu; + return playerWithContextMenu.contextmenuUI !== undefined; +} + +// Check if the plugin is properly initialized (has required properties) +function isContextMenuUIInitialized(player: PlayerWithContextMenu): boolean { + if (!hasContextMenuUI(player)) { + return false; + } + const plugin = player.contextmenuUI; + return typeof plugin.onContextMenu === 'function' && + typeof plugin.createContextMenuContent === 'function'; +} + +function hasMenu(player: PlayerWithContextMenu): boolean { + return hasContextMenuUI(player) && + player.contextmenuUI.menu !== undefined && + player.contextmenuUI.menu.el() !== null; +} + +function excludeElements(targetEl: Element): boolean { + const tagName = targetEl.tagName.toLowerCase(); + return tagName === 'input' || tagName === 'textarea'; +} + +function findMenuPosition(pointerPosition: { x: number; y: number }, playerSize: { width: number; height: number }): { left: number; top: number } { + // This standard calculation positions the menu's top-left corner at the pointer. + return { + left: Math.round(playerSize.width * pointerPosition.x), + top: Math.round(playerSize.height * pointerPosition.y) + }; +} + +function onContextMenu(this: PlayerWithContextMenu, e: MouseEvent): void { + // Always prevent default to block native menu - must be first! + e.preventDefault(); + e.stopPropagation(); + + if (!hasContextMenuUI(this)) { + return; + } + + // If menu already exists, close it and return + // preventDefault already called above, so native menu won't show + if (hasMenu(this)) { + this.contextmenuUI.menu!.dispose(); + return; + } + + if (!(e.target instanceof HTMLElement) || excludeElements(e.target)) { + return; + } + + const playerEl = this.el(); + if (!playerEl || !(playerEl instanceof HTMLElement)) { + return; + } + + const pointerPosition = getPointerPosition(playerEl, e); + const playerRect = playerEl.getBoundingClientRect(); + const menuPosition = findMenuPosition(pointerPosition, playerRect); + const documentEl = videojs.browser.IS_FIREFOX ? document.documentElement : document; + + // Get fresh content by calling the function + const content = this.contextmenuUI.createContextMenuContent(this); + + const menu = new ContextMenu(this, { + content: content, + position: menuPosition + }); + this.contextmenuUI.menu = menu; + + this.contextmenuUI.closeMenu = () => { + videojs.log.warn('player.contextmenuUI.closeMenu() is deprecated, please use player.contextmenuUI.menu.dispose() instead!'); + menu.dispose(); + }; + + // Store document listener cleanup function + if (!this.contextmenuUICleanups_) { + this.contextmenuUICleanups_ = []; + } + const handleMenuClose = (evt: Event) => { + menu.dispose(); + }; + + const documentCleanup = addEventListener(documentEl, 'click', handleMenuClose); + const tapCleanup = addEventListener(documentEl, 'tap', handleMenuClose); + this.contextmenuUICleanups_.push(documentCleanup, tapCleanup); + + menu.on('dispose', () => { + // Clean up document listeners + if (this.contextmenuUICleanups_) { + this.contextmenuUICleanups_.forEach(cleanup => cleanup()); + this.contextmenuUICleanups_ = []; + } + // Type assertion for Video.js component methods + (this as unknown as Component).removeChild(menu); + + if (hasContextMenuUI(this)) { + delete this.contextmenuUI.menu; + } + }); + + // Type assertion for Video.js component methods + (this as unknown as Component).addChild(menu); + + const menuEl = menu.el(); + if (!menuEl || !(menuEl instanceof HTMLElement)) { + return; + } + + + // Type assertions for Video.js component methods + const playerComponent = this as unknown as Component; + const currentWidth = typeof playerComponent.currentWidth === 'function' + ? playerComponent.currentWidth() + : playerEl.offsetWidth; + const currentHeight = typeof playerComponent.currentHeight === 'function' + ? playerComponent.currentHeight() + : playerEl.offsetHeight; + const menuWidth = typeof menu.currentWidth === 'function' + ? menu.currentWidth() + : menuEl.offsetWidth; + const menuHeight = typeof menu.currentHeight === 'function' + ? menu.currentHeight() + : menuEl.offsetHeight; + + // Always constrain menu to stay within player bounds + + const constrainedLeft = Math.min(menuPosition.left, currentWidth - menuWidth); + const constrainedTop = Math.min(menuPosition.top, currentHeight - menuHeight); + menuEl.style.left = `${Math.floor(constrainedLeft)}px`; + menuEl.style.top = `${Math.floor(constrainedTop)}px`; + +} + +function contextmenuUI(this: PlayerWithContextMenu, options: PluginOptions): void { + if (typeof options.createContextMenuContent !== 'function') { + throw new Error('"createContextMenuContent" option is required and must be a function'); + } + + // Check if plugin is properly initialized (not just if property exists) + if (isContextMenuUIInitialized(this)) { + // Plugin is properly initialized, just update the content function + const pluginState = this.contextmenuUI!; + pluginState.createContextMenuContent = options.createContextMenuContent; + pluginState.options_ = options; + return; + } else if (hasContextMenuUI(this)) { + // Plugin property exists but is not properly initialized - re-initialize + // Clean up the incomplete plugin state + if (this.contextmenuUICleanups_) { + this.contextmenuUICleanups_.forEach(cleanup => cleanup()); + this.contextmenuUICleanups_ = undefined; + } + // Use type assertion to allow deletion of optional property + const playerWithOptional = this as PlayerWithContextMenu; + playerWithOptional.contextmenuUI = undefined; + // Fall through to initialization below + } + + // Teardown any orphaned state if it exists + if (this.contextmenuUICleanups_) { + this.contextmenuUICleanups_.forEach(cleanup => cleanup()); + delete this.contextmenuUICleanups_; + } + + // Create a callable function that also serves as the plugin's state namespace. + const cmui = ((opts: PluginOptions) => { + contextmenuUI.call(this, opts); + }) as ContextMenuUI; + + // Assign the function to the player + this.contextmenuUI = cmui; + + // Assign properties to the new plugin instance + cmui.options_ = options; + cmui.createContextMenuContent = options.createContextMenuContent; + cmui.onContextMenu = onContextMenu.bind(this); + + // Store contextmenu listener cleanup + if (!this.contextmenuUICleanups_) { + this.contextmenuUICleanups_ = []; + } + + if (hasContextMenuUI(this)) { + this.on('contextmenu', this.contextmenuUI.onContextMenu); + } + + this.ready(() => { + const playerComponent = this as unknown as Component; + if (typeof playerComponent.addClass === 'function') { + playerComponent.addClass('vjs-contextmenu-ui'); + } + }); +} + +videojs.registerPlugin('contextmenuUI', contextmenuUI); + +export default contextmenuUI; \ No newline at end of file diff --git a/packages/video-player/javascript/modules/context-menu/setup.ts b/packages/video-player/javascript/modules/context-menu/setup.ts new file mode 100644 index 0000000..9fe52d3 --- /dev/null +++ b/packages/video-player/javascript/modules/context-menu/setup.ts @@ -0,0 +1,75 @@ +import videojs from 'video.js'; +import type Player from 'video.js/dist/types/player'; +import type { IKPlayerOptions } from '../../interfaces'; + +/** + * Generates the context menu content based on the player's current state. + * @param player - The video player instance + * @returns Array of context menu items + */ +function createContextMenuContent(player: Player) { + return [{ + label: player.paused() ? "Play" : "Pause", + listener: function () { + if (player.paused()) { + player.play(); + } else { + player.pause(); + } + } + }, + { + label: player.loop() ? "Unloop" : "Loop", + listener: function () { + player.loop(!player.loop()); + } + }, + { + label: player.muted() ? "Unmute" : "Mute", + listener: function () { + player.muted(!player.muted()); + } + }, + { + label: player.isFullscreen() ? "Exit Fullscreen" : "Fullscreen", + listener: function () { + if (player.isFullscreen()) { + player.exitFullscreen(); + } else { + player.requestFullscreen(); + } + } + }]; +} + +/** + * Sets up the context menu for the video player. + * If hideContextMenu is true, prevents the default context menu. + * Otherwise, initializes the context menu plugin with default menu items. + * + * @param player - The Video.js player instance + * @param options - ImageKit player options + */ +export function setupContextMenu(player: Player, options: IKPlayerOptions): void { + if (options.hideContextMenu === true) { + player.on('contextmenu', (e: Event) => { + e.preventDefault(); + }); + return; + } + + const hasContextMenuUIMethod = typeof (player as any).contextmenuUI === 'function'; + + if (!hasContextMenuUIMethod) { + player.log.error(`[ImageKit Video Player] contextmenuUI method not found on player. Available plugins: ${Object.keys(videojs.getPlugins()).join(', ')}`); + return; + } + + try { + (player as any).contextmenuUI({ + createContextMenuContent: createContextMenuContent + }); + } catch (error) { + player.log.error('[ImageKit Video Player] Failed to initialize context menu plugin:', error); + } +} diff --git a/packages/video-player/javascript/modules/context-menu/types.ts b/packages/video-player/javascript/modules/context-menu/types.ts new file mode 100644 index 0000000..3426180 --- /dev/null +++ b/packages/video-player/javascript/modules/context-menu/types.ts @@ -0,0 +1,38 @@ +// ./types.ts +import type Player from 'video.js/dist/types/player'; +import type ContextMenu from './context-menu'; + +/** + * Interface for a single menu item object provided in the plugin's options. + */ +export interface ContextMenuItemOptions { + label: string; + listener?: (this: Player) => void; + href?: string; +} + +/** + * Interface for the options object passed to the main plugin function. + */ +export interface PluginOptions { + createContextMenuContent: (player: Player) => ContextMenuItemOptions[]; +} + +/** + * Describes the `contextmenuUI` object that will be attached to the Player instance. + * It's both a callable function (for re-initialization) and an object holding state. + */ +export interface ContextMenuUI { + (options: PluginOptions): void; + createContextMenuContent: (player: Player) => ContextMenuItemOptions[]; + options_: PluginOptions; + onContextMenu: (e: MouseEvent) => void; + menu?: ContextMenu; + closeMenu?: () => void; // Deprecated but kept for compatibility +} + +/** + * Module augmentation for contextmenuUI has been moved to + * packages/video-player/javascript/types/videojs-extensions.d.ts + * to consolidate all Video.js Player augmentations in one place. + */ \ No newline at end of file diff --git a/packages/video-player/javascript/modules/context-menu/utils.ts b/packages/video-player/javascript/modules/context-menu/utils.ts new file mode 100644 index 0000000..b0ce14b --- /dev/null +++ b/packages/video-player/javascript/modules/context-menu/utils.ts @@ -0,0 +1,59 @@ +// ./utils.ts + +/** + * Finds an element's position on the page. + * @param el The element to measure. + * @returns An object with `left` and `top` pixel values. + */ +export function findElPosition(el: HTMLElement): { left: number; top: number } { + let box: DOMRect | undefined; + + if (el.getBoundingClientRect && el.parentNode) { + box = el.getBoundingClientRect(); + } + + if (!box) { + return { left: 0, top: 0 }; + } + + const docEl = document.documentElement; + const body = document.body; + + const clientLeft = docEl.clientLeft || body.clientLeft || 0; + const scrollLeft = window.pageXOffset || body.scrollLeft; + const left = box.left + scrollLeft - clientLeft; + + const clientTop = docEl.clientTop || body.clientTop || 0; + const scrollTop = window.pageYOffset || body.scrollTop; + const top = box.top + scrollTop - clientTop; + + return { + left: Math.round(left), + top: Math.round(top), + }; +} + +/** + * Calculates the pointer's position relative to an element, returning normalized + * coordinates (from 0 to 1). + * @param el The element to measure against. + * @param event The mouse or touch event. + * @returns Object with `x` and `y` coordinates (0-1). + */ +export function getPointerPosition(el: HTMLElement, event: MouseEvent | TouchEvent): { x: number; y: number } { + const position = { x: 0, y: 0 }; + const box = findElPosition(el); + const boxW = el.offsetWidth; + const boxH = el.offsetHeight; + const boxY = box.top; + const boxX = box.left; + + const isTouchEvent = 'changedTouches' in event; + const pageX = isTouchEvent ? (event as TouchEvent).changedTouches[0].pageX : (event as MouseEvent).pageX; + const pageY = isTouchEvent ? (event as TouchEvent).changedTouches[0].pageY : (event as MouseEvent).pageY; + + position.y = Math.max(0, Math.min(1, (pageY - boxY) / boxH)); + position.x = Math.max(0, Math.min(1, (pageX - boxX) / boxW)); + + return position; +} \ No newline at end of file diff --git a/packages/video-player/javascript/modules/floating-player/floating-player.css b/packages/video-player/javascript/modules/floating-player/floating-player.css new file mode 100644 index 0000000..87c4580 --- /dev/null +++ b/packages/video-player/javascript/modules/floating-player/floating-player.css @@ -0,0 +1,63 @@ +.ik-player-wrapper { + transition: height 0.2s ease-out; +} + +.ik-player-floating { + position: fixed; + width: 360px; + height: 202.5px; + bottom: 20px; + z-index: 1000; + box-shadow: 0 5px 20px rgba(0, 0, 0, 0.35); + border-radius: 8px; + overflow: hidden; + /* cursor: pointer; has been removed */ +} + +.ik-player-floating-right { + right: 20px; +} + +.ik-player-floating-left { + left: 20px; +} + +/* .ik-player-floating .vjs-captions-button, +.ik-player-floating .vjs-chapters-button, */ +.ik-player-floating .vjs-shoppable-bar, +.ik-player-floating .vjs-shoppable-hotspot, +.ik-player-floating .vjs-present-upcoming, +.ik-player-floating .vjs-shoppable-postplay-overlay, +.ik-player-floating .vjs-recommendations-overlay { + display: none !important; +} + +.ik-floating-close-button { + position: absolute; + top: 0.5em; + right: 0.5em; + background: rgba(43, 51, 63, 0.7); + border: none; + color: #fff; + font-size: 1.5em; + line-height: 1.5em; + width: 1.5em; + height: 1.5em; + border-radius: 50%; + cursor: pointer; + z-index: 1001; /* Must be above the grid */ + transition: background-color 0.2s, transform 0.2s; + display: flex; + align-items: center; + justify-content: center; +} + +.ik-floating-close-button:hover { + background-color: rgba(60, 70, 80, 0.9); + transform: scale(1.1); +} + +.ik-floating-close-button svg { + width: 14px; + height: 14px; +} \ No newline at end of file diff --git a/packages/video-player/javascript/modules/floating-player/index.ts b/packages/video-player/javascript/modules/floating-player/index.ts new file mode 100644 index 0000000..3a6ab1f --- /dev/null +++ b/packages/video-player/javascript/modules/floating-player/index.ts @@ -0,0 +1,77 @@ +import { CleanupRegistry } from '../../utils'; + +/** + * Enables a robust floating-on-scroll functionality for the ImageKit Video Player. + * @param {any} playerInstance The instance of the ImageKit Video Player. + * @returns A cleanup function that should be called when the player is disposed. + */ +export const enableFloatingPlayer = (playerInstance: any, floatPosition: string): (() => void) => { + if (!floatPosition || (floatPosition !== 'left' && floatPosition !== 'right')) { + return; + } + + const cleanup = new CleanupRegistry(); + const playerElement = playerInstance.el(); + const parentContainer = playerElement.parentElement; + + const wrapper = document.createElement('div'); + wrapper.className = 'ik-player-wrapper'; + parentContainer.insertBefore(wrapper, playerElement); + wrapper.appendChild(playerElement); + cleanup.registerElement(wrapper); + + // --- STATE MANAGEMENT --- + let hasStarted = false; + let isFloatingDismissed = false; + + cleanup.registerVideoJsListener(playerInstance, 'play', () => { + hasStarted = true; + isFloatingDismissed = false; + }); + + // --- UI & INTERACTION LOGIC --- + const setFloating = (isFloating) => { + const className = `ik-player-floating-${floatPosition}`; + if (isFloating) { + playerElement.classList.remove('shoppable-panel-visible'); + playerElement.classList.add('ik-player-floating', className); + addCloseButton(); + } else { + playerElement.classList.remove('ik-player-floating', className); + playerElement.querySelector('.ik-floating-close-button')?.remove(); + isFloatingDismissed = false; + } + }; + + // --- CLOSE BUTTON with SVG ICON --- + const addCloseButton = () => { + if (playerElement.querySelector('.ik-floating-close-button')) return; + const closeButton = document.createElement('div'); + closeButton.className = 'ik-floating-close-button'; + closeButton.setAttribute('aria-label', 'Close floating video'); + closeButton.innerHTML = "✕"; + cleanup.registerEventListener(closeButton, 'click', (e: MouseEvent) => { + // Stop click from bubbling up to the player and toggling play/pause + e.stopPropagation(); + isFloatingDismissed = true; + setFloating(false); + }); + playerElement.appendChild(closeButton); + }; + + // --- OBSERVERS for ROBUSTNESS --- + const intersectionObserver = new IntersectionObserver(([entry]) => { + const isOutOfView = entry.intersectionRatio < 0.5; + if (isOutOfView && hasStarted && !isFloatingDismissed) { + setFloating(true); + } else { + setFloating(false); + } + }, { threshold: [0.5] }); + + cleanup.registerObserver(intersectionObserver); + intersectionObserver.observe(wrapper); + + // Return cleanup function + return () => cleanup.dispose(); +} \ No newline at end of file diff --git a/packages/video-player/javascript/modules/http-source-selector/components/SourceMenuButton.ts b/packages/video-player/javascript/modules/http-source-selector/components/SourceMenuButton.ts new file mode 100644 index 0000000..f37561c --- /dev/null +++ b/packages/video-player/javascript/modules/http-source-selector/components/SourceMenuButton.ts @@ -0,0 +1,124 @@ +import videojs from 'video.js'; +import type Player from 'video.js/dist/types/player'; +import type MenuButtonType from 'video.js/dist/types/menu/menu-button'; +import SourceMenuItem from './SourceMenuItem'; +import { CleanupRegistry } from '../../../utils'; + +const MenuButton = videojs.getComponent('MenuButton') as unknown as typeof MenuButtonType; + +interface QualityLevel { + enabled: boolean; + height?: string; + bitrate?: number; +} + +interface QualityLevelList { + length: number; + selectedIndex: number; + forEach(callback: (level: QualityLevel, index: number) => void): void; + on(event: string, handler: () => void): void; + off(event: string, handler: () => void): void; + [index: number]: QualityLevel; +} + +interface PlayerWithQualityLevels { + qualityLevels(): QualityLevelList; +} + +class SourceMenuButton extends MenuButton { + private cleanup_ = new CleanupRegistry(); + + constructor(player: Player, options: any) { + super(player, options); + + const playerWithQualityLevels = this.player() as unknown as PlayerWithQualityLevels; + const qualityLevels = playerWithQualityLevels.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 + // Use native bind instead of deprecated videojs.bind + const updateHandler = this.update.bind(this); + this.cleanup_.registerVideoJsListener(qualityLevels, 'change', updateHandler); + this.cleanup_.registerVideoJsListener(qualityLevels, 'addqualitylevel', updateHandler); + } + + createEl(): HTMLElement { + return videojs.dom.createEl('div', { + className: 'vjs-http-source-selector vjs-menu-button vjs-menu-button-popup vjs-control vjs-button' + }) as HTMLElement; + } + + buildCSSClass(): string { + return super.buildCSSClass() + ' vjs-icon-cog'; + } + + update(): void { + super.update(); + } + + createItems(): SourceMenuItem[] { + const menuItems: SourceMenuItem[] = []; + const playerWithQualityLevels = this.player() as unknown as PlayerWithQualityLevels; + const levels = playerWithQualityLevels.qualityLevels(); + const labels: string[] = []; + + 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(String(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; + } + + dispose(): void { + this.cleanup_.dispose(); + super.dispose(); + } +} + +// Register component with Video.js +videojs.registerComponent('SourceMenuButton', SourceMenuButton as any); + +export default SourceMenuButton; \ No newline at end of file diff --git a/packages/video-player/javascript/modules/http-source-selector/components/SourceMenuItem.ts b/packages/video-player/javascript/modules/http-source-selector/components/SourceMenuItem.ts new file mode 100644 index 0000000..eef1e62 --- /dev/null +++ b/packages/video-player/javascript/modules/http-source-selector/components/SourceMenuItem.ts @@ -0,0 +1,60 @@ +import videojs from 'video.js'; +import type Player from 'video.js/dist/types/player'; +import type MenuItemType from 'video.js/dist/types/menu/menu-item'; + +const MenuItem = videojs.getComponent('MenuItem') as typeof MenuItemType; + +interface QualityLevel { + enabled: boolean; + height?: string; + bitrate?: number; +} + +interface QualityLevelList { + length: number; + selectedIndex: number; + forEach(callback: (level: QualityLevel, index: number) => void): void; + [index: number]: QualityLevel; +} + +interface PlayerWithQualityLevels { + qualityLevels(): QualityLevelList; +} + +class SourceMenuItem extends MenuItem { + constructor(player: Player, options: any) { + options.selectable = true; + options.multiSelectable = false; + + super(player, options); + } + + handleClick(event: Event) { + const selected = this.options_; + // Call parent handleClick + super.handleClick(event); + + const player = this.player() as unknown as PlayerWithQualityLevels; + const levels = player.qualityLevels(); + for (let 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() { + const player = this.player() as unknown as PlayerWithQualityLevels; + const qualityLevels = player.qualityLevels(); + const selectedIndex = qualityLevels.selectedIndex; + this.selected(this.options_.index == selectedIndex); + } +} + +videojs.registerComponent('SourceMenuItem', SourceMenuItem as any); +export default SourceMenuItem; \ No newline at end of file diff --git a/packages/video-player/javascript/modules/http-source-selector/plugin.scss b/packages/video-player/javascript/modules/http-source-selector/plugin.scss new file mode 100644 index 0000000..173f634 --- /dev/null +++ b/packages/video-player/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/video-player/javascript/modules/http-source-selector/plugin.ts b/packages/video-player/javascript/modules/http-source-selector/plugin.ts new file mode 100644 index 0000000..5de2a19 --- /dev/null +++ b/packages/video-player/javascript/modules/http-source-selector/plugin.ts @@ -0,0 +1,87 @@ +import videojs from 'video.js'; +import type Player from 'video.js/dist/types/player'; + +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: any, options: any) => +{ + player.addClass('vjs-http-source-selector'); + + //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: any) + { + 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) + { + // do nothing + } + else + { + 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(this: Player, options: any) { + this.ready(() => { + onPlayerReady(this, videojs.mergeOptions(defaults, options)); + //this.getChild('controlBar').addChild('SourceMenuButton', {}); + }); + + videojs.registerComponent('SourceMenuButton', SourceMenuButton as any); + videojs.registerComponent('SourceMenuItem', SourceMenuItem as any); + }; + + // Register the plugin with video.js. + registerPlugin('httpSourceSelector', httpSourceSelector); + + export default httpSourceSelector; \ No newline at end of file diff --git a/packages/video-player/javascript/modules/keyboard-shortcuts/index.ts b/packages/video-player/javascript/modules/keyboard-shortcuts/index.ts new file mode 100644 index 0000000..19ffaa2 --- /dev/null +++ b/packages/video-player/javascript/modules/keyboard-shortcuts/index.ts @@ -0,0 +1,3 @@ +export { setupKeyboardShortcuts } from './keyboard-shortcuts'; +export type { KeyboardShortcutsOptions } from './keyboard-shortcuts'; +export { SeekFeedback } from './seek-feedback'; diff --git a/packages/video-player/javascript/modules/keyboard-shortcuts/keyboard-shortcuts.ts b/packages/video-player/javascript/modules/keyboard-shortcuts/keyboard-shortcuts.ts new file mode 100644 index 0000000..a536a24 --- /dev/null +++ b/packages/video-player/javascript/modules/keyboard-shortcuts/keyboard-shortcuts.ts @@ -0,0 +1,68 @@ +import type { CleanupRegistry } from '../../utils'; +import type Player from 'video.js/dist/types/player'; +import { SeekFeedback } from './seek-feedback'; + +/** + * Configuration options for keyboard shortcuts. + */ +export interface KeyboardShortcutsOptions { + /** Amount of time to skip in seconds when using arrow keys. Default: 10 */ + skipTime?: number; +} + +/** + * Sets up keyboard shortcuts for the video player. + * Supports: + * - Space: Play/Pause + * - Arrow Left/Right: Seek backward/forward + * - F: Toggle fullscreen + * + * @param player - The Video.js player instance + * @param cleanup - Cleanup registry for managing resources + * @param options - Optional configuration for keyboard shortcuts + */ +export function setupKeyboardShortcuts( + player: Player, + cleanup: CleanupRegistry, + options: KeyboardShortcutsOptions = {} +): void { + const skipTime = options.skipTime ?? 10; + const seekFeedback = new SeekFeedback(player, cleanup); + + const keydownHandler = (event: KeyboardEvent) => { + switch (event.key) { + case ' ': + event.preventDefault(); + if (player.paused()) { + player.play(); + } else { + player.pause(); + } + break; + + case 'ArrowRight': + event.preventDefault(); + player.currentTime((player.currentTime() ?? 0) + skipTime); + seekFeedback.show('forward'); + break; + + case 'ArrowLeft': + event.preventDefault(); + player.currentTime((player.currentTime() ?? 0) - skipTime); + seekFeedback.show('backward'); + break; + + case 'f': + case 'F': + event.preventDefault(); + if (player.isFullscreen()) { + player.exitFullscreen(); + } else { + player.requestFullscreen(); + } + break; + } + }; + + cleanup.registerVideoJsListener(player, 'keydown', keydownHandler); +} diff --git a/packages/video-player/javascript/modules/keyboard-shortcuts/seek-feedback.ts b/packages/video-player/javascript/modules/keyboard-shortcuts/seek-feedback.ts new file mode 100644 index 0000000..ca4d0d5 --- /dev/null +++ b/packages/video-player/javascript/modules/keyboard-shortcuts/seek-feedback.ts @@ -0,0 +1,44 @@ +import type { CleanupRegistry } from '../../utils'; +import type Player from 'video.js/dist/types/player'; + +/** + * Creates and manages the seek feedback UI element that shows visual feedback + * when the user seeks forward or backward using keyboard shortcuts. + */ +export class SeekFeedback { + private element: HTMLElement; + private currentTimeout: ReturnType | undefined; + private cleanup: CleanupRegistry; + + constructor(player: Player, cleanup: CleanupRegistry) { + this.cleanup = cleanup; + this.element = cleanup.registerElement(document.createElement('div')); + this.element.className = 'vjs-seek-feedback'; + player.el().appendChild(this.element); + } + + /** + * Shows the feedback icon with the specified direction and hides it after a delay. + * @param direction - The direction of the seek ('forward' or 'backward') + */ + show(direction: 'forward' | 'backward'): void { + if (this.currentTimeout) { + clearTimeout(this.currentTimeout); + } + + this.element.classList.remove('is-forward', 'is-backward'); + + if (direction === 'forward') { + this.element.classList.add('is-forward'); + } else { + this.element.classList.add('is-backward'); + } + + this.element.classList.add('is-visible'); + + this.currentTimeout = this.cleanup.registerTimeout(() => { + this.element.classList.remove('is-visible'); + this.currentTimeout = undefined; + }, 600); + } +} diff --git a/packages/video-player/javascript/modules/logo-button/index.ts b/packages/video-player/javascript/modules/logo-button/index.ts new file mode 100644 index 0000000..6399b71 --- /dev/null +++ b/packages/video-player/javascript/modules/logo-button/index.ts @@ -0,0 +1,3 @@ +import './logo-button'; + +export { default } from './logo-button'; diff --git a/packages/video-player/javascript/modules/logo-button/init.ts b/packages/video-player/javascript/modules/logo-button/init.ts new file mode 100644 index 0000000..4640602 --- /dev/null +++ b/packages/video-player/javascript/modules/logo-button/init.ts @@ -0,0 +1,32 @@ +import type Player from 'video.js/dist/types/player'; +import type { IKPlayerOptions } from '../../interfaces'; + +/** + * Initializes the logo button in the control bar based on the player options. + * If showLogo is enabled, adds or updates the logo button. + * If disabled, removes any existing logo button. + * + * @param player - The Video.js player instance + * @param options - ImageKit player options containing logo configuration + */ +export function initializeLogoButton(player: Player, options: IKPlayerOptions): void { + const controlBar = player.getChild('ControlBar'); + if (!controlBar) { + return; + } + + const existingLogoButton = controlBar.getChild('LogoButton'); + + if (options.logo?.showLogo) { + if (existingLogoButton) { + existingLogoButton.dispose(); + } + controlBar.addChild('LogoButton', { + playerOptions: options + }); + } else { + if (existingLogoButton) { + existingLogoButton.dispose(); + } + } +} diff --git a/packages/video-player/javascript/modules/logo-button/logo-button.scss b/packages/video-player/javascript/modules/logo-button/logo-button.scss new file mode 100644 index 0000000..411a77b --- /dev/null +++ b/packages/video-player/javascript/modules/logo-button/logo-button.scss @@ -0,0 +1,47 @@ +.vjs-control-bar .vjs-logo-button { + background-size: 25px; + background-position: center; + background-repeat: no-repeat; + color: inherit; + width: 2.5em; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + text-decoration: none; + opacity: 1; + + &:hover { + cursor: pointer; + opacity: 1; + } + + &:active, + &:focus { + outline: none; + opacity: 1; + } + + &:last-child { + margin-right: 0.4em; + margin-left: 0.8em; + + &::before { + content: ''; + position: absolute; + left: -0.25em; + top: 0.3em; + bottom: 0.3em; + border-left: 1px solid color-mix(in srgb, var(--color-text, currentColor) 25%, transparent); + opacity: 1; + pointer-events: none; + } + + &:hover::before, + &:active::before, + &:focus::before { + border-left-color: color-mix(in srgb, var(--color-text, currentColor) 25%, transparent); + opacity: 1; + } + } +} diff --git a/packages/video-player/javascript/modules/logo-button/logo-button.ts b/packages/video-player/javascript/modules/logo-button/logo-button.ts new file mode 100644 index 0000000..313acdd --- /dev/null +++ b/packages/video-player/javascript/modules/logo-button/logo-button.ts @@ -0,0 +1,45 @@ +import videojs from 'video.js'; +import type Player from 'video.js/dist/types/player'; +import type { IKPlayerOptions } from '../../interfaces'; + +interface LogoButtonOptions { + playerOptions?: IKPlayerOptions; + children?: any[]; + className?: string; +} + +const ClickableComponent = videojs.getComponent('ClickableComponent'); + +class LogoButton extends ClickableComponent { + constructor(player: Player, options?: LogoButtonOptions) { + super(player, options); + } + + createEl() { + const opts = (this.options_ as LogoButtonOptions).playerOptions; + + if (!opts || !opts.logo) { + return videojs.dom.createEl('div', {}, { + class: 'vjs-control vjs-logo-button', + style: 'display: none;' + }); + } + + const { showLogo, logoImageUrl, logoOnclickUrl } = opts.logo; + const display = showLogo ? 'block' : 'none'; + const bgImage = logoImageUrl ? `background-image: url(${logoImageUrl})` : ''; + + return videojs.dom.createEl('a', {}, { + class: 'vjs-control vjs-logo-button', + href: logoOnclickUrl || '#', + target: '_blank', + rel: 'noopener noreferrer', + style: `display: ${display}; ${bgImage}`, + 'aria-label': 'Logo link' + }); + } +} + +videojs.registerComponent('LogoButton', LogoButton); + +export default LogoButton; diff --git a/packages/video-player/javascript/modules/playlist/auto-advance.ts b/packages/video-player/javascript/modules/playlist/auto-advance.ts new file mode 100644 index 0000000..c8ff28e --- /dev/null +++ b/packages/video-player/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/video-player/javascript/modules/playlist/components/playlist-button.ts b/packages/video-player/javascript/modules/playlist/components/playlist-button.ts new file mode 100644 index 0000000..32ee580 --- /dev/null +++ b/packages/video-player/javascript/modules/playlist/components/playlist-button.ts @@ -0,0 +1,43 @@ +import videojs from 'video.js'; +import type Player from 'video.js/dist/types/player'; +import type ClickableComponentType from 'video.js/dist/types/clickable-component'; + +interface PlaylistButtonOptions { + type: string; // 'previous' or 'next' + children?: any[]; + className?: string; +} + +// Get the ClickableComponent base class from Video.js +const ClickableComponent = videojs.getComponent('ClickableComponent') as typeof ClickableComponentType; + +// Create a common class for playlist buttons +class PlaylistButton extends ClickableComponent { + + constructor(player: Player, options: PlaylistButtonOptions) { + // It is important to invoke the superclass before anything else, + // to get all the features of components out of the box! + super(player, options); + + const type = options.type; + + if (!type && type !== 'previous' && type !== 'next') { + throw new Error('Type must be either \'previous\' or \'next\''); + } + } + + // The `createEl` function of a component creates its DOM element. + createEl() { + const type = this.options_.type; + const typeCssClass = `vjs-icon-${type}-item`; + + return videojs.dom.createEl('button', { + // Prefixing classes of elements within a player with "vjs-" + // is a convention used in Video.js. + className: `vjs-control vjs-playlist-button vjs-button ${typeCssClass}`, + ariaLabel: `Playlist ${type} item` + }); + } +} + +export default PlaylistButton; diff --git a/packages/video-player/javascript/modules/playlist/components/playlist-next-button.ts b/packages/video-player/javascript/modules/playlist/components/playlist-next-button.ts new file mode 100644 index 0000000..8bb0c47 --- /dev/null +++ b/packages/video-player/javascript/modules/playlist/components/playlist-next-button.ts @@ -0,0 +1,25 @@ +import PlaylistButton from './playlist-button'; +import videojs from 'video.js'; +import type { Player } from '../../../interfaces/Player'; + + +class PlaylistNextButton extends PlaylistButton { + + constructor(player: Player) { + super(player, { type: 'next' }); + } + + handleClick(event: Event) { + event.stopPropagation(); + super.handleClick(event); + const player = this.player() as Player; + const playlistManager = player.imagekitVideoPlayer().getPlaylistManager(); + if (playlistManager) { + playlistManager.playNext(); + } + } +} + +videojs.registerComponent('PlaylistNextButton', PlaylistNextButton); + +export default PlaylistNextButton; diff --git a/packages/video-player/javascript/modules/playlist/components/playlist-previous-button.ts b/packages/video-player/javascript/modules/playlist/components/playlist-previous-button.ts new file mode 100644 index 0000000..979b62d --- /dev/null +++ b/packages/video-player/javascript/modules/playlist/components/playlist-previous-button.ts @@ -0,0 +1,24 @@ +import PlaylistButton from './playlist-button'; +import videojs from 'video.js'; +import type { Player } from '../../../interfaces/Player'; + + +class PlaylistPreviousButton extends PlaylistButton { + constructor(player: Player) { + super(player, { type: 'previous' }); + } + + handleClick(event: Event) { + event.stopPropagation(); + super.handleClick(event); + const player = this.player() as Player; + const playlistManager = player.imagekitVideoPlayer().getPlaylistManager(); + if (playlistManager) { + playlistManager.playPrevious(); + } + } +} + +videojs.registerComponent('PlaylistPreviousButton', PlaylistPreviousButton); + +export default PlaylistPreviousButton; diff --git a/packages/video-player/javascript/modules/playlist/playlist-manager.ts b/packages/video-player/javascript/modules/playlist/playlist-manager.ts new file mode 100644 index 0000000..5e287c5 --- /dev/null +++ b/packages/video-player/javascript/modules/playlist/playlist-manager.ts @@ -0,0 +1,560 @@ +import videojs from 'video.js'; +import type Player from 'video.js/dist/types/player'; +import type ComponentType from 'video.js/dist/types/component'; +import { isEqual, pick } from 'lodash' + +import { Playlist } from './playlist'; +import { AutoAdvance } from './auto-advance'; +import { PlaylistMenu } from './playlist-menu'; +import type { SourceOptions, PlaylistOptions, IKPlayerOptions } from '../../interfaces'; +import type { Player as ImageKitPlayer } from '../../interfaces/Player'; +import { isIndexInBounds, SOURCE_OPTION_KEYS } from './utils'; +import './present-upcoming'; +import { PresentUpcoming } from './present-upcoming'; +import './components/playlist-next-button'; +import './components/playlist-previous-button'; + +// 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_: IKPlayerOptions; + private playerContainer_?: HTMLElement; + private playlistOptions_: PlaylistOptions; + private presentUpcomingComponent_?: PresentUpcoming; + private presentUpcomingThreshold_: number | null = null; + private isUpcomingDismissed_ = false; + + + constructor(player: Player, playerOptions: IKPlayerOptions) { + this.player_ = player; + this.playerOptions_ = playerOptions; + this.playlistOptions_ = {}; + this.playlist_ = new Playlist({ + onError: msg => player.error(msg), + onWarn: msg => player.log.warn(msg) + }); + this.autoAdvance_ = new AutoAdvance(this.player_, this.playNext_); + + + /** + * Loads a playlist and sets up related functionality. + * @param sources - Array of sources to load + * @param opts - Options for the playlist + * @returns The playlist manager instance + */ + (player as any).playlist = ({ + sources, + options: opts + }: { + sources?: SourceOptions[], + options?: PlaylistOptions + }): PlaylistManager => { + const playerEl = this.player_.el(); + + let wrapper = playerEl.parentElement; + if (!wrapper || !wrapper.classList.contains('ik-player-container')) { + wrapper = document.createElement('div'); + wrapper.className = 'ik-player-container'; + playerEl.parentNode?.insertBefore(wrapper, playerEl); + wrapper.appendChild(playerEl); + } + + this.playerContainer_ = wrapper as HTMLElement; + + if (sources && Array.isArray(sources)) { + this.loadPlaylist(Playlist.from(sources, { + onError: msg => player.error(msg), + onWarn: msg => player.log.warn(msg) + })); + } + + this.playlistOptions_ = opts || {}; + this.configure(opts || {}); + this.initMenu_(opts || {}); + this.addUiComponents(); + + this.player_.one('loadedmetadata', () => this.updateLayout_()); + + return this; + }; + + this.player_.on('playerresize', () => this.updateLayout_()); + + } + + /** + * Applies dynamic styles to the player and playlist container. + * @private + */ + private updateLayout_() { + if (!this.playerContainer_) { + return; + } + + const playerWithOptions = this.player_ as unknown as { + options_: { + fluid?: boolean; + }; + }; + const isFluid = playerWithOptions.options_.fluid; + + if (isFluid) { + this.playerContainer_.style.width = ''; + this.playerContainer_.style.height = ''; + if (this.playlistMenu) { + (this.playlistMenu.el() as HTMLElement).style.width = ''; + (this.playlistMenu.el() as HTMLElement).style.height = ''; + } + return; + } + + const playerWidth = this.player_.width(); + const playerHeight = this.player_.height(); + + if (!playerWidth || !playerHeight) { + return; + } + + const opts = this.playlistOptions_ || {}; + const isHorizontal = opts.widgetProps?.direction === 'horizontal'; + + if (isHorizontal) { + const playlistHeight = Math.min(playerHeight * 0.45, 200); + this.playerContainer_.style.width = `${playerWidth}px`; + this.playerContainer_.style.height = `${playerHeight + playlistHeight}px`; + if (this.playlistMenu) { + (this.playlistMenu.el() as HTMLElement).style.height = `${playlistHeight}px`; + } + } else { + const playlistWidth = playerWidth * 0.45; + this.playerContainer_.style.width = `${playerWidth + playlistWidth}px`; + this.playerContainer_.style.height = `${playerHeight}px`; + if (this.playlistMenu) { + (this.playlistMenu.el() as HTMLElement).style.width = `${playlistWidth}px`; + } + } + } + + private initMenu_(opts: PlaylistOptions) { + this.playlistMenu?.dispose(); + + const defaults = { + className: 'vjs-playlist', + playOnSelect: true + }; + + const uiOpts = videojs.mergeOptions(defaults, opts.widgetProps || {}); + + const menuOptions = { + className: uiOpts.className, + horizontal: uiOpts.direction === 'horizontal', + showDescription: uiOpts.showDescription + }; + + const menu = new PlaylistMenu(this.player_, this.playlist_, menuOptions, this.playerOptions_); + + if (this.playerContainer_) { + this.playerContainer_.appendChild(menu.el()); + this.playerContainer_.classList.toggle('vjs-playlist-horizontal-container', menuOptions.horizontal); + } + + this.playlistMenu = menu; + (this.player_ as any).playlistMenu = menu; + } + + /** + * Configures looping, auto-advance, and present upcoming settings. + */ + public configure(opts: PlaylistOptions = {}) { + if (opts.repeat) { + this.playlist_.enableRepeat(); + } else { + this.playlist_.disableRepeat(); + } + + if (opts.autoAdvance === false) { + this.autoAdvance_.setDelay(false); + } else if (typeof opts.autoAdvance === 'number') { + this.autoAdvance_.setDelay(opts.autoAdvance); + } + + if (opts.presentUpcoming === false) { + this.presentUpcomingThreshold_ = null; + } else if (typeof opts.presentUpcoming === 'number' && opts.presentUpcoming > 0) { + this.presentUpcomingThreshold_ = opts.presentUpcoming; + } else if (opts.presentUpcoming === true) { + this.presentUpcomingThreshold_ = 10; + } else { + this.presentUpcomingThreshold_ = null; + } + this.setupPresentUpcoming_(); + } + + private addUiComponents() { + const controlBar = this.player_.getChild('ControlBar') as ComponentType | null; + if (!controlBar) return; + + const controlBarWithMethods = controlBar as unknown as { + children(): Array<{ name_?: string }>; + addChild(name: string, options?: any, index?: number): ComponentType; + }; + const children = controlBarWithMethods.children(); + const playToggleIndex = children.findIndex(c => c.name_ === 'PlayToggle'); + + controlBarWithMethods.addChild('PlaylistPreviousButton', {}, playToggleIndex); + controlBarWithMethods.addChild('PlaylistNextButton', {}, playToggleIndex + 1); + } + + /** + * Loads a new playlist array. + * @param sources - Array of sources to load + */ + public setPlaylistItems(sources: SourceOptions[]) { + this.playlist_.setItems(sources); + this.loadFirstItem(); + } + + /** + * Gets the current playlist array. + * @returns Array of playlist items + */ + public getItems(): SourceOptions[] { + return this.playlist_.getItems(); + } + + /** + * Advances to the next item in the playlist. + */ + public playNext(): void { + const next = this.playlist_.getNextIndex(); + if (next < 0) { return; } + this.playAtIndex(next); + } + + /** + * Advances to the previous item in the playlist. + */ + public playPrevious(): void { + const previous = this.playlist_.getPreviousIndex(); + if (previous < 0) { return; } + this.playAtIndex(previous); + } + + /** + * Plays a specific item in the playlist by index. + * @param index - The index of the item to play + */ + public playAtIndex(index: number): void { + this.playlist_.setCurrentIndex(index); + this.player_.src(this.playlist_.getCurrentItem()); + + this.player_.one('loadedmetadata', () => { + this.player_.play(); + }); + } + + /** + * Loads a playlist and sets up related functionality. + * @param playlist - The playlist to load + */ + public loadPlaylist(playlist: Playlist) { + this.unloadPlaylist(); + + this.playlist_ = playlist; + this.autoAdvance_ = new AutoAdvance(this.player_, this.playNext_); + + this.setupEventForwarding_(); + 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(); + } + + this.presentUpcomingComponent_?.dispose(); + this.player_.off('timeupdate', this.handleTimeUpdateForUpcoming_); + this.player_.off('loadstart', this.handleSourceChange_); + } + + /** + * Retrieves the currently loaded playlist object. + * @returns The current Playlist instance, or null if one is not loaded + */ + public getPlaylist(): Playlist | null { + return this.playlist_; + } + + /** + * Gets or sets the auto-advance configuration for the playlist. + * A positive integer sets the delay in seconds before playing the next video. + * A value of false cancels auto-advance. + * A value of 0 causes the next video to play immediately after the previous one finishes. + * @param delayInSeconds - The delay in seconds, false to disable, or undefined to get current value + * @returns The current delay value when getting, or void when setting + */ + 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 index - The index of the item to load + * @returns 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. + * @returns True if the first item was loaded successfully, false otherwise + */ + loadFirstItem(): boolean { + return this.loadPlaylistItem(0); + } + + /** + * Loads the last item in the playlist. + * @returns 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. + * @returns 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. + * @returns 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 item - The playlist item to load + * @private + */ + private loadItem_(item: SourceOptions) { + this.player_.trigger('beforeplaylistitem', item); + this.clearExistingItemTextTracks_(); + this.player_.src(item); + + this.player_.ready(() => { + 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 - 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) { + this.player_.one('loadstart', () => { + this.player_.play(); + }); + } + }; + + /** + * Clears text tracks of the currently loaded item. + * @private + */ + private clearExistingItemTextTracks_() { + const playerWithTextTracks = this.player_ as unknown as { + remoteTextTracks(): TextTrackList; + removeRemoteTextTrack(track: TextTrack): void; + }; + const textTracks = playerWithTextTracks.remoteTextTracks(); + let i = textTracks && textTracks.length || 0; + + while (i--) { + playerWithTextTracks.removeRemoteTextTrack(textTracks[i]); + } + } + + /** + * Adds text tracks for a playlist item. + * @param 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_ = () => { + const player = this.player_ as unknown as ImageKitPlayer; + const pluginInstance = player.imagekitVideoPlayer(); + const currentSrc = pluginInstance.getOriginalCurrentSource(); + + if (!currentSrc || !this.isSourceInPlaylist_(currentSrc)) { + this.handleNonPlaylistSource_(); + } + }; + + /** + * Checks if the current source is in the playlist. + * @param src - The source to check + * @returns 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) => { + return isEqual(pick(item, SOURCE_OPTION_KEYS), pick(src, SOURCE_OPTION_KEYS))} + ); + } + + /** + * Handles playback when the current source is not in the playlist. + * @private + */ + private handleNonPlaylistSource_() { + this.autoAdvance_.fullReset(); + this.playlist_.setCurrentIndex(null); + } + + private handleUpcomingDismiss_ = () => { + this.isUpcomingDismissed_ = true; + this.presentUpcomingComponent_?.hide(); + }; + + private setupPresentUpcoming_() { + this.presentUpcomingComponent_?.dispose(); + this.player_.off('timeupdate', this.handleTimeUpdateForUpcoming_); + + if (this.presentUpcomingThreshold_ === null) { + return; + } + + this.presentUpcomingComponent_ = this.player_.addChild('PresentUpcoming', this.playerOptions_) as PresentUpcoming; + this.presentUpcomingComponent_.on('dismiss', this.handleUpcomingDismiss_); + this.player_.on('timeupdate', this.handleTimeUpdateForUpcoming_); + + this.player_.on('loadstart', () => { + this.presentUpcomingComponent_?.hide(); + this.isUpcomingDismissed_ = false; + }); + } + + + private handleTimeUpdateForUpcoming_ = () => { + if (!this.presentUpcomingComponent_ || this.presentUpcomingThreshold_ === null) { + return; + } + + const currentTime = this.player_.currentTime(); + const duration = this.player_.duration(); + + if (!currentTime || !duration || !isFinite(duration) || !isFinite(currentTime)) { + return; + } + + const remainingTime = duration - currentTime; + const isTimeToShow = remainingTime <= this.presentUpcomingThreshold_ && remainingTime > 0 && !this.isUpcomingDismissed_; + + if (isTimeToShow) { + if (this.presentUpcomingComponent_.hasClass('vjs-hidden')) { + const nextIndex = this.playlist_.getNextIndex(); + + if (nextIndex !== -1) { + const nextItem = this.playlist_.getItems()[nextIndex]; + this.presentUpcomingComponent_.update(nextItem); + this.presentUpcomingComponent_.show(); + } + } + } else { + if (!this.presentUpcomingComponent_.hasClass('vjs-hidden')) { + this.presentUpcomingComponent_.hide(); + } + } + }; +} \ No newline at end of file diff --git a/packages/video-player/javascript/modules/playlist/playlist-menu-item.ts b/packages/video-player/javascript/modules/playlist/playlist-menu-item.ts new file mode 100644 index 0000000..4b96151 --- /dev/null +++ b/packages/video-player/javascript/modules/playlist/playlist-menu-item.ts @@ -0,0 +1,159 @@ +import videojs from 'video.js'; +import type { Player } from '../../interfaces/Player'; +import type ComponentType from 'video.js/dist/types/component'; +import type { IKPlayerOptions, Transformation } from '../../interfaces'; +import { Playlist } from './playlist'; +import { preparePosterSrc } from '../../utils'; +import { AugmentedSourceOptions } from '../../interfaces/AugementedSourceOptions'; + +const Component = videojs.getComponent('Component') as typeof ComponentType; + +interface PlaylistMenuItemOptions { + item: AugmentedSourceOptions; + showDescription?: boolean; + playOnSelect?: boolean; + children?: any[]; + className?: string; + playerOptions?: IKPlayerOptions; +} + +const DEFAULT_TRANSFORMATION: Transformation = { + width: 400, + aspectRatio: '16-9', + cropMode: 'pad_resize', + background: 'black', +} + +export class PlaylistMenuItem extends Component { + private item: AugmentedSourceOptions; + private spinnerEl!: HTMLElement; + private thumbnail!: HTMLElement; + private imgEl?: HTMLImageElement; + private playOnSelect: boolean; + private playlist: Playlist + private playerOptions: IKPlayerOptions; + + constructor(player: Player, playlist: Playlist, options: PlaylistMenuItemOptions, playerOptions: IKPlayerOptions) { + super(player, options as any); + 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) { + const player = this.player_ as Player; + const playlistManager = player.imagekitVideoPlayer().getPlaylistManager(); + if (playlistManager) { + playlistManager.loadPlaylistItem(idx); + } + if (this.playOnSelect) { + this.player_.play(); + } + } + } + + getItem() { + return this.options_.item; + } + + private async getThumbnail() { + const item = this.getItem(); + if (!item) { + throw new Error('No item provided for thumbnail'); + } + if (item?.prepared?.playlistThumbnail) { + return item.prepared.playlistThumbnail; + } + if (!item.poster?.transformation) { + if (!item.poster) { + item.poster = {}; + } + item.poster.transformation = [DEFAULT_TRANSFORMATION] + + } + const player = this.player_ as Player; + const preparedUrl = await preparePosterSrc(item, player.imagekitVideoPlayer().getPlayerOptions()) + if(!this.item.prepared) { + this.item.prepared = {}; + } + this.item.prepared.playlistThumbnail = preparedUrl; + return preparedUrl; + } + + createEl(): HTMLElement { + const li = document.createElement('li'); + li.className = 'vjs-playlist-item'; + li.tabIndex = 0; + + this.thumbnail = document.createElement('div'); + this.thumbnail.className = 'vjs-playlist-thumbnail'; + li.appendChild(this.thumbnail); + + this.spinnerEl = document.createElement('div'); + this.spinnerEl.className = 'vjs-playlist-thumbnail-spinner'; + this.thumbnail.appendChild(this.spinnerEl); + + this.getThumbnail() + .then((url) => { + if (!this.el_) return; + if (this.spinnerEl) this.spinnerEl.remove(); + + this.imgEl = document.createElement('img'); + this.imgEl.className = 'vjs-playlist-thumbnail-img'; + this.imgEl.loading = 'lazy'; + this.imgEl.alt = this.options_.item.info?.title || ''; + this.imgEl.onerror = () => { + if (!this.el_) return; + if (this.imgEl) { + this.imgEl.remove(); + } + this.thumbnail.classList.add('vjs-playlist-thumbnail-placeholder'); + }; + this.imgEl.src = url; + this.thumbnail.appendChild(this.imgEl); + }) + .catch((err) => { + if (!this.el_) return; + this.player_.log.error(`Failed to load poster for playlist item: ${err.message}`); + if (this.spinnerEl) this.spinnerEl.remove(); + this.thumbnail.classList.add('vjs-playlist-thumbnail-placeholder'); + }); + + const detailsEl = document.createElement('div'); + detailsEl.className = 'vjs-playlist-details'; + li.appendChild(detailsEl); + + const title = this.options_.item.info?.title || this.localize('Untitled Video'); + const titleEl = document.createElement('div'); + titleEl.className = 'vjs-playlist-name'; + titleEl.textContent = title; + titleEl.title = title; + detailsEl.appendChild(titleEl); + + if (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; + detailsEl.appendChild(descEl); + } + + return li; + } +} + +videojs.registerComponent('PlaylistMenuItem', PlaylistMenuItem as any); \ No newline at end of file diff --git a/packages/video-player/javascript/modules/playlist/playlist-menu.ts b/packages/video-player/javascript/modules/playlist/playlist-menu.ts new file mode 100644 index 0000000..57cc011 --- /dev/null +++ b/packages/video-player/javascript/modules/playlist/playlist-menu.ts @@ -0,0 +1,152 @@ +// playlist-menu.ts +import videojs from 'video.js'; +import type Player from 'video.js/dist/types/player'; +import type ComponentType from 'video.js/dist/types/component'; +import { PlaylistMenuItem } from './playlist-menu-item'; +import { Playlist } from './playlist'; +import { IKPlayerOptions } from '../../interfaces'; +import { CleanupRegistry } from '../../utils'; +import { isEqual, pick } from 'lodash'; +import { SOURCE_OPTION_KEYS } from './utils'; + +const Component = videojs.getComponent('Component') as typeof ComponentType; + +interface PlaylistMenuOptions { + horizontal?: boolean; + showDescription?: boolean; + playOnSelect?: boolean; + className?: string; +} + +const addSelectedClass = (el: any) => el.addClass('vjs-selected'); +const removeSelectedClass = (el: any) => el.removeClass('vjs-selected'); + +export class PlaylistMenu extends Component { + private items: PlaylistMenuItem[] = []; + private playlist: Playlist; + private playerOptions: IKPlayerOptions; + private cleanup_ = new CleanupRegistry(); + + constructor( + player: Player, + playlist: Playlist, + options: PlaylistMenuOptions, + playerOptions: IKPlayerOptions + ) { + // Pass `options` into super so Video.js creates `el_` for you: + super(player, options); + + this.playlist = playlist; + this.playerOptions = playerOptions; + + // 1) Add orientation classes to the element Video.js already created: + if (options.horizontal) { + this.addClass('vjs-playlist-horizontal'); + } else { + this.addClass('vjs-playlist-vertical'); + } + if (!videojs.browser.TOUCH_ENABLED) { + this.addClass('vjs-mouse'); + } + + // 2) Listen for ad events + this.cleanup_.registerVideoJsListener(player, 'adstart', () => this.addClass('vjs-ad-playing')); + this.cleanup_.registerVideoJsListener(player, 'adend', () => this.removeClass('vjs-ad-playing')); + + // 3) Listen for playlist events + this.cleanup_.registerVideoJsListener(player, 'playlistchange', () => { + this.update(); + }); + this.cleanup_.registerVideoJsListener(player, 'playlistsorted', () => { + this.update(); + }); + this.cleanup_.registerVideoJsListener(player, 'playlistadd', () => this.update()); + this.cleanup_.registerVideoJsListener(player, 'playlistremove', () => this.update()); + this.cleanup_.registerVideoJsListener(player, '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 }) as HTMLElement; + } + + /** Render or re-render the playlist UI */ + private update(): void { + if (!this.el_) return; + + const items = this.playlist.getItems(); + const currentIndex = this.playlist.getCurrentIndex?.() ?? 0; + + const contentChanged = + this.items.length !== items.length || + this.items.some((mi, i) => { + const menuItem = mi.getItem(); + const playlistItem = items[i]; + return !isEqual( + pick(menuItem, SOURCE_OPTION_KEYS), + pick(playlistItem, SOURCE_OPTION_KEYS) + ); + }); + + if (contentChanged) { + this.empty_(); + + 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; + }); + } + + this.items.forEach((mi, i) => { + const thumbnail = mi.el_.querySelector('.vjs-playlist-thumbnail'); + if (i === currentIndex) { + addSelectedClass(mi); + if (thumbnail) { + videojs.dom.addClass(thumbnail, 'vjs-playlist-now-playing'); + } + } else { + removeSelectedClass(mi); + if (thumbnail) { + videojs.dom.removeClass(thumbnail, 'vjs-playlist-now-playing'); + } + } + }); + } + + /** Remove all menu items */ + private empty_(): void { + if (!this.el_) return; + this.items.forEach(i => i.dispose()); + this.items = []; + this.el_.innerHTML = ''; + } + + dispose(): void { + this.cleanup_.dispose(); + super.dispose(); + } +} +videojs.registerComponent('PlaylistMenu', PlaylistMenu as any); +export default PlaylistMenu; \ No newline at end of file diff --git a/packages/video-player/javascript/modules/playlist/playlist.ts b/packages/video-player/javascript/modules/playlist/playlist.ts new file mode 100644 index 0000000..2c901a6 --- /dev/null +++ b/packages/video-player/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/video-player/javascript/modules/playlist/present-upcoming.ts b/packages/video-player/javascript/modules/playlist/present-upcoming.ts new file mode 100644 index 0000000..1a3b08e --- /dev/null +++ b/packages/video-player/javascript/modules/playlist/present-upcoming.ts @@ -0,0 +1,113 @@ +// src/modules/playlist/present-upcoming.ts + +import videojs from 'video.js'; +import type Player from 'video.js/dist/types/player'; +import type ComponentType from 'video.js/dist/types/component'; +import type { IKPlayerOptions, SourceOptions } from '../../interfaces'; +import { preparePosterSrc, CleanupRegistry } from '../../utils'; +import type { Player as ImageKitPlayer } from '../../interfaces/Player'; + +const Component = videojs.getComponent('Component') as typeof ComponentType; + +export class PresentUpcoming extends Component { + private item_?: SourceOptions; + private playerOptions_: IKPlayerOptions; + private thumbnailEl_: HTMLElement; + // private textEl_: HTMLElement; + private titleEl_: HTMLElement; + private closeButtonEl_: HTMLElement; + private cleanup_: CleanupRegistry; + + + constructor(player: Player, playerOptions: IKPlayerOptions) { + super(player); + // Initialize cleanup_ after super() because createEl() may be called during super() + this.cleanup_ = new CleanupRegistry(); + this.playerOptions_ = playerOptions; + + this.thumbnailEl_ = videojs.dom.createEl('div', { className: 'vjs-up-next-thumbnail' }) as HTMLElement; + // this.textEl_ = videojs.dom.createEl('div', { className: 'vjs-up-next-text-2' }, {}, 'Next up:') as HTMLElement; + this.titleEl_ = videojs.dom.createEl('div', { className: 'vjs-up-next-title' }, {}, "Next up") as HTMLElement; + + this.closeButtonEl_ = videojs.dom.createEl('div', { + className: 'vjs-up-next-close-button', + title: 'Dismiss' // Accessibility: a tooltip for the button + }) as HTMLElement; + + this.closeButtonEl_.innerHTML = "✕" + + this.cleanup_.registerEventListener(this.closeButtonEl_, 'click', (e: Event) => { + e.stopPropagation(); // Stop the click from bubbling up to the parent div + this.trigger('dismiss'); // Fire a custom event to notify the manager + }); + + this.el().appendChild(this.thumbnailEl_); + // this.el().appendChild(this.textEl_); + this.el().appendChild(this.titleEl_); + this.el().appendChild(this.closeButtonEl_); + + + // Start hidden + this.hide(); + } + + createEl(): HTMLElement { + const el = videojs.dom.createEl('div', { + className: 'vjs-present-upcoming' + }) as HTMLElement; + + // Initialize cleanup_ if not already initialized (createEl may be called before constructor completes) + if (!this.cleanup_) { + this.cleanup_ = new CleanupRegistry(); + } + + // Make it clickable to advance to the next video immediately + this.cleanup_.registerEventListener(el, 'click', (e: Event) => { + // Prevent the close button itself from triggering "playNext" + // Note: This handler only executes on click (after construction), so closeButtonEl_ will exist + if (!this.closeButtonEl_ || e.target !== this.closeButtonEl_) { + const player = this.player_ as unknown as ImageKitPlayer; + const playlistManager = player.imagekitVideoPlayer().getPlaylistManager(); + if (playlistManager) { + playlistManager.playNext(); + } + } + }); + + return el; + } + + /** + * Update the component with the details of the next video. + * @param item The next playlist item. + */ + public async update(item: SourceOptions): Promise { + if (!item || this.item_ === item) { + return; + } + this.item_ = item; + + // Clear previous thumbnail + this.thumbnailEl_.innerHTML = ''; + const title = item.info?.title || this.localize('Untitled Video'); + this.titleEl_.textContent = `Next up: ${title}`; + + try { + const posterUrl = await preparePosterSrc(item, this.playerOptions_); + const img = document.createElement('img'); + img.src = posterUrl; + img.alt = `Next up: ${title}`; + this.thumbnailEl_.appendChild(img); + } catch (e) { + this.thumbnailEl_.classList.add('vjs-playlist-thumbnail-placeholder'); + this.player_.log.error('Failed to load "Up Next" poster:', e); + } + } + + dispose(): void { + this.cleanup_.dispose(); + super.dispose(); + } +} + +videojs.registerComponent('PresentUpcoming', PresentUpcoming as any); \ No newline at end of file diff --git a/packages/video-player/javascript/modules/playlist/styles/playlist-ui.scss b/packages/video-player/javascript/modules/playlist/styles/playlist-ui.scss new file mode 100644 index 0000000..8eae47b --- /dev/null +++ b/packages/video-player/javascript/modules/playlist/styles/playlist-ui.scss @@ -0,0 +1,332 @@ +// --- 1. THEME DEFINITION --- +// Contains all color variables for both Light (default) and Dark themes. + +.ik-player-container { + // --- Light Theme (Default) --- + --container-bg: #f0f2f5; // Background for the area around the player/playlist + --playlist-bg: #f7fafc; // Playlist main background + --playlist-border: #e0e0e0; // Border around the playlist + --item-hover-bg: #eee; // Background of a playlist item on hover + --item-selected-bg: #e5e5e5; // Background of the selected ("now playing") item + --text-primary: #0f0f0f; // Primary text color (e.g., video titles) + --text-secondary: #606060; // Secondary text color (e.g., video descriptions) + --icon-color: #656565; // Color of the "now playing" icon + --spinner-track: rgba(0, 0, 0, 0.1); // The faint circle of the loading spinner + --spinner-head: #606060; // The moving part of the loading spinner + --placeholder-bg: #d0d0d0; // Background for thumbnail placeholder + + // --- Dark Theme --- + // When this class is added, the variables below will override the light theme defaults. + &.theme-dark { + --container-bg: #1a1a1a; + --playlist-bg: #212121; + --playlist-border: #383838; + --item-hover-bg: #303030; + --item-selected-bg: #424242; + --text-primary: #ffffff; + --text-secondary: #aaaaaa; + --icon-color: #ffffff; + --spinner-track: rgba(255, 255, 255, 0.2); + --spinner-head: #ffffff; + --placeholder-bg: #0f0f0f; + } +} + + +// --- 2. MAIN PLAYER LAYOUT --- +// This section uses the theme variables. + +.ik-player-container { + display: grid; + margin: 0 auto 20px; + background: var(--container-bg); + grid-template-columns: 4fr 1fr; + grid-template-rows: 1fr; + grid-template-areas: "player playlist"; +} + +.video-js { + grid-area: player; + min-width: 0; + min-height: 0; +} + +.vjs-playlist { + grid-area: playlist; + min-height: 0; + overflow: auto; + padding: 0; + background-color: var(--playlist-bg); + border: 1px solid var(--playlist-border); + border-radius: 0 8px 8px 0; + list-style-type: none; + + -ms-overflow-style: none; + scrollbar-width: none; + &::-webkit-scrollbar { + display: none; + } +} + +.ik-player-container.vjs-playlist-horizontal-container { + grid-template-columns: 1fr; + grid-template-rows: 4fr 1fr; + grid-template-areas: + "player" + "playlist"; +} + +// --- 3. PLAYLIST CONTAINER & LIST STYLING --- + +.vjs-playlist.vjs-playlist-horizontal { + border-radius: 0 0 8px 8px; +} + +.vjs-playlist-item-list { + position: relative; + margin: 0; + padding: 0; + list-style: none; +} + +.vjs-playlist-vertical .vjs-playlist-item-list { + display: block; + height: 100%; +} + +.vjs-playlist-horizontal .vjs-playlist-item-list { + display: flex; + flex-direction: row; + height: 100%; + container-type: size; + + .vjs-playlist-item { + display: flex; + width: 20%; + height: 100%; + flex-shrink: 0; + margin-right: 1%; + box-sizing: border-box; + flex-direction: column; + gap: clamp(4px, 3%, 8px); + align-items: flex-start; + justify-content: center; + padding: clamp(4px, 2%, 6px); + padding-left: clamp(4px, 3%, 28px); + --playlist-item-padding-left: clamp(4px, 3%, 28px); + margin-bottom: 0; + overflow: hidden; + + @container (max-width: 600px) { + padding-left: clamp(4px, 2%, 6px); + --playlist-item-padding-left: clamp(4px, 2%, 6px); + width: 25%; + } + + @container (max-width: 400px) { + width: 33.33%; + } + + @container (max-width: 200px) { + width: 50%; + } + } + + .vjs-playlist-name { + -webkit-line-clamp: 1; + + @container (min-height: 110px) { + -webkit-line-clamp: 2; + } + } + + .vjs-playlist-description { + -webkit-line-clamp: 1; + + @container (min-height: 140px) { + -webkit-line-clamp: 2; + } + } + + .vjs-playlist-thumbnail { + height: calc(100% - clamp(40px, 50%, 80px) - clamp(4px, 3%, 8px) - clamp(4px, 2%, 6px) * 2); + width: auto; + aspect-ratio: 16 / 9; + max-width: 100%; + flex-shrink: 0; + } + + .vjs-playlist-details { + flex: 0 0 clamp(40px, 50%, 80px); + min-width: 0; + } + + .vjs-playlist-item.vjs-selected { + + &::before { + left: calc(var(--playlist-item-padding-left)); + } + } +} + + +// --- 4. INDIVIDUAL PLAYLIST ITEM STYLING --- + +.vjs-playlist-menu { + padding: 1.5%; + background-color: var(--playlist-bg); +} + +.vjs-playlist-item { + display: flex; + align-items: center; + padding: clamp(4px, 3%, 8px); + padding-left: clamp(8px, 10%, 28px); + --playlist-item-padding-left: clamp(8px, 10%, 28px); + margin-bottom: clamp(4px, 1.5%, 8px); + cursor: pointer; + transition: background-color 0.2s ease; + position: relative; + + &:last-child { + margin-bottom: 0; + } + + &:hover { + background-color: var(--item-hover-bg); + } +} + +.vjs-playlist-thumbnail { + flex-shrink: 0; + width: clamp(80px, 40%, 40%); + margin-right: clamp(4px, 3%, 12px); + border-radius: 4px; + overflow: hidden; + position: relative; + + .vjs-playlist-thumbnail-img { + display: block; + width: 100%; + height: auto; + aspect-ratio: 16 / 9; + object-fit: cover; + } +} + +.vjs-playlist-thumbnail-placeholder { + background-color: var(--placeholder-bg); + display: flex; + align-items: center; + justify-content: center; + aspect-ratio: 16 / 9; + + &::before { + content: '▶'; + color: var(--text-secondary, #666); + font-size: 2rem; + opacity: 0.4; + } +} + +.vjs-playlist-details { + flex-grow: 1; + min-width: 0; + line-height: unset; +} + +.vjs-playlist-name { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + font-size: 0.9rem; + color: var(--text-primary); + line-height: 1.3; + margin: 0; + font-weight: 500; + + white-space: normal; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; +} + +.vjs-playlist-description { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + font-size: 0.8rem; + color: var(--text-secondary); + margin-top: 0.75%; + + white-space: normal; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; +} + + +// --- 5. "NOW PLAYING" ITEM STATE --- + +.vjs-playlist-item.vjs-selected { + background-color: var(--item-selected-bg); + + &::before { + content: '▶'; + position: absolute; + left: calc(var(--playlist-item-padding-left) / 3); + top: 50%; + transform: translateY(-50%); + color: var(--icon-color); + font-size: 12px; + } +} + +.vjs-playlist-horizontal .vjs-playlist-item.vjs-selected { + @container (max-width: 600px) { + &::before { + display: none; + } + } +} + +.vjs-playlist-vertical .vjs-playlist-item-list { + container-type: inline-size; +} + +.vjs-playlist-vertical .vjs-playlist-item { + @container (max-width: 300px) { + padding-left: clamp(4px, 3%, 8px); + --playlist-item-padding-left: clamp(4px, 3%, 8px); + } +} + +.vjs-playlist-vertical .vjs-playlist-item.vjs-selected { + @container (max-width: 300px) { + &::before { + display: none; + } + } +} + + +// --- 6. UTILITY STYLES --- + +.vjs-playlist-thumbnail-spinner { + position: absolute; + top: 50%; + left: 50%; + width: 2em; + height: 2em; + margin: -1em 0 0 -1em; + border: 3px solid var(--spinner-track); + border-top-color: var(--spinner-head); + border-radius: 50%; + animation: vjs-playlist-thumbnail-spin 1s linear infinite; +} + +@keyframes vjs-playlist-thumbnail-spin { + to { + transform: rotate(360deg); + } +} \ No newline at end of file diff --git a/packages/video-player/javascript/modules/playlist/styles/present-upcoming.scss b/packages/video-player/javascript/modules/playlist/styles/present-upcoming.scss new file mode 100644 index 0000000..ae69e3b --- /dev/null +++ b/packages/video-player/javascript/modules/playlist/styles/present-upcoming.scss @@ -0,0 +1,105 @@ +// --- "Present Upcoming" Component Styling --- + +.vjs-present-upcoming { + position: absolute; + bottom: 4em; // Position it above the control bar + right: 1.5em; + background-color: rgba(26, 26, 26, 1); + border-radius: 4px; + display: flex; + flex-direction: column; + align-items: center; + box-sizing: border-box; + cursor: pointer; + transition: opacity 0.3s ease-in-out, transform 0.3s ease-in-out; + transform: translateY(20px); + opacity: 0; + z-index: 2; // Ensure it's above the video but below modals + gap: 8px; + transition: bottom 0.3s ease-in-out; + + &.vjs-hidden { + display: none !important; + } + + // When shown, fade and slide in + &:not(.vjs-hidden) { + transform: translateY(0); + opacity: 1; + } + } + + .vjs-up-next-thumbnail { + width: 25em; + flex-shrink: 0; + background-color: "#1a1a1a"; + border-radius: 0.5em; + + img { + width: 100%; + height: 100%; + object-fit: cover; + aspect-ratio: 16 / 9; + border-radius: 0.5em; + } + } + + .vjs-up-next-text-2 { + position: absolute; + bottom: 2em; + left: 0; + right: 0; + padding: 1.5em 0.8em 0.8em 0.8em; + font-size: 1.6em; + /* Relative to player font size */ + text-align: left; + color: #fff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .vjs-up-next-title { + position: absolute; + bottom: 0; + left: 0; + right: 0; + padding: 1.5em 0.8em 0.8em 0.8em; + font-size: 1.6em; + /* Relative to player font size */ + text-align: left; + color: #fff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-shadow: 1px 1px 2px black, -1px 1px 2px black, 1px -1px 2px black, -1px -1px 2px black; + } + + // --- "Present Upcoming" Close Button Styling (Improved) --- + + .vjs-up-next-close-button { + position: absolute; + top: 0.5em; + right: 0.5em; + background: rgba(43, 51, 63, 0.7); + border: none; + color: #fff; + font-size: 1.5em; + line-height: 1.5em; + width: 1.5em; + height: 1.5em; + border-radius: 50%; + cursor: pointer; + z-index: 1001; + /* Must be above the grid */ + transition: background-color 0.2s, transform 0.2s; + display: flex; + align-items: center; + justify-content: center; + + // 5. Improved Hover Effect + &:hover { + background-color: rgba(60, 70, 80, 0.9); // More noticeable background change + transform: scale(1.1); // Subtle zoom adds a premium feel + } + } \ No newline at end of file diff --git a/packages/video-player/javascript/modules/playlist/thumbnail.ts b/packages/video-player/javascript/modules/playlist/thumbnail.ts new file mode 100644 index 0000000..7559f1a --- /dev/null +++ b/packages/video-player/javascript/modules/playlist/thumbnail.ts @@ -0,0 +1,127 @@ +import videojs from 'video.js'; +import type { Transformation } from '@imagekit/javascript' +import { preparePosterSrc } from '../../utils'; +import { AugmentedSourceOptions } from '../../interfaces/AugementedSourceOptions'; +import type Player from 'video.js/dist/types/player'; +import type ClickableComponentType from 'video.js/dist/types/clickable-component'; + + +// Get the ClickableComponent base class from Video.js +const ClickableComponent = videojs.getComponent('ClickableComponent') as typeof ClickableComponentType; + +interface ThumbnailInitOptions { + item?: AugmentedSourceOptions + transformation?: Transformation; // Transformation options for the thumbnail, if provided will override the default + classes?: { + thumbnail?: string; + spinner?: string; + placeholder?: string; + } +} + +const THUMB_DEFAULT_WIDTH = 300; + +const DEFAULT_TRANSFORMATION: Transformation = { + width: THUMB_DEFAULT_WIDTH, + aspectRatio: '16-9', + cropMode: 'pad_resize', + background: 'black', +} + +const DEFAULT_OPTIONS: ThumbnailInitOptions = { + item: null, + transformation: DEFAULT_TRANSFORMATION, +}; + +class Thumbnail extends ClickableComponent { + private getItem(): AugmentedSourceOptions | undefined { + return (this.options_ as ThumbnailInitOptions).item; + } + + constructor(player: Player, initOptions: ThumbnailInitOptions) { + const options = videojs.obj.merge(DEFAULT_OPTIONS, initOptions); + super(player, options); + } + + getTitle() { + return this.getItem()?.info?.title; + } + + async getThumbnail() { + const item = this.getItem(); + if (!item) { + throw new Error('No item provided for thumbnail'); + } + if (item.prepared.playlistThumbnail) { + return item.prepared.playlistThumbnail; + } + if (!item.poster?.transformation) { + if (!item.poster) { + item.poster = {}; + } + item.poster.transformation = [DEFAULT_TRANSFORMATION] + + } + const preparedUrl = await preparePosterSrc(item, this.options_.playerOptions) + item.prepared.playlistThumbnail = preparedUrl; // Store the prepared URL in the item + return preparedUrl; + } + + handleClick(e: Event): void { + e.preventDefault(); + } + + createControlTextEl(): Element | undefined { + return undefined; + } + + createEl(tag = 'a') { + + // Thumbnail + const thumbnail = super.createEl(tag, { + className: this.options_?.classes?.thumbnail ?? 'vjs-playlist-thumbnail', + href: '#' + }); + + + // Spinner + const spinner = super.createEl('div', { + className: this.options_?.classes?.spinner ?? 'vjs-playlist-thumbnail-spinner', + }); + + thumbnail.appendChild(spinner); + + this.getThumbnail().then((url) => { + if (!this.el_) { + return; + } + if (spinner) { + spinner.remove(); + } + + const imgEl = super.createEl('img', { + loading: 'lazy', + src: url, + alt: this.getTitle() || '', + }); + thumbnail.appendChild(imgEl); + }) + .catch((err) => { + if (!this.el_) { + return; + } + this.player_.log.error(`Failed to load thumbnail for playlist item: ${err.message}`); + if (spinner) { + spinner.remove(); + } + thumbnail.classList.add(this.options_?.classes?.placeholder ?? 'vjs-playlist-thumbnail-placeholder'); + }); + + return thumbnail; + } + +} + +videojs.registerComponent('Thumbnail', Thumbnail as any); + +export default Thumbnail; diff --git a/packages/video-player/javascript/modules/playlist/utils.ts b/packages/video-player/javascript/modules/playlist/utils.ts new file mode 100644 index 0000000..0befe6c --- /dev/null +++ b/packages/video-player/javascript/modules/playlist/utils.ts @@ -0,0 +1,24 @@ +import { SourceOptions } from "../../interfaces/SourceOptions"; + +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]]; + } +} + +export const SOURCE_OPTION_KEYS: (keyof SourceOptions)[] = [ + 'src', + 'chapters', + 'info', + 'poster', + 'abs', + 'transformation', + 'recommendations', + 'shoppable', + 'textTracks' +]; \ No newline at end of file diff --git a/packages/video-player/javascript/modules/recommendations-overlay/recommendation-overlay.css b/packages/video-player/javascript/modules/recommendations-overlay/recommendation-overlay.css new file mode 100644 index 0000000..4a53b21 --- /dev/null +++ b/packages/video-player/javascript/modules/recommendations-overlay/recommendation-overlay.css @@ -0,0 +1,137 @@ +/** + * NEW STYLESHEET for Recommendations Overlay + * Implements a responsive, 2-column, scrollable grid. + */ + +/* 1. Main Overlay: This is now the scroll container */ +.vjs-recommendations-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.85); /* Darker for more focus */ + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; /* Align grid to the top */ + z-index: 1000; + padding: 4vw; /* Use responsive padding */ + box-sizing: border-box; + + /* --- Standard scrolling enabled --- */ + overflow-y: auto; + -ms-overflow-style: none; /* Hide scrollbar on IE/Edge */ + scrollbar-width: none; /* Hide scrollbar on Firefox */ + } + + .vjs-recommendations-overlay::-webkit-scrollbar { + display: none; /* Hide scrollbar on Chrome/Safari */ + } + + /* 2. Close Button: Styled for consistency */ + .vjs-rec-close { + position: absolute; + top: 0.5em; + right: 0.5em; + background: rgba(43, 51, 63, 0.7); + border: none; + color: #fff; + font-size: 1.5em; + line-height: 1.5em; + width: 1.5em; + height: 1.5em; + border-radius: 50%; + cursor: pointer; + z-index: 1001; /* Must be above the grid */ + transition: background-color 0.2s, transform 0.2s; + display: flex; + align-items: center; + justify-content: center; + } + .vjs-rec-close:hover { + background-color: rgba(60, 70, 80, 0.9); + transform: scale(1.1); + } + + /* 3. Grid Container: The heart of the new layout */ + .vjs-rec-list { + display: grid; + /* Two equal-width columns */ + grid-template-columns: repeat(2, 1fr); + /* Vertical gap is less than horizontal, using responsive units */ + gap: 2vw 3vw; + width: 100%; + max-width: 1000px; /* Prevents grid from looking too sparse on ultra-wide screens */ + } + + /* 4. Grid Item: Each recommendation card */ + .vjs-rec-item { + position: relative; /* For the title overlay */ + width: 100%; /* Takes full width of its grid column */ + color: #fff; + text-decoration: none; + border-radius: 4px; + overflow: hidden; + cursor: pointer; + transition: transform 0.25s ease-in-out; + border: 1px solid rgba(255, 255, 255, 0.4); + } + .vjs-rec-item:hover { + transform: scale(1.04); /* Subtle zoom hover effect */ + border: 1px solid rgba(255, 255, 255, 1); + } + + /* 5. Thumbnail: Uses aspect-ratio to prevent layout shift */ + .vjs-rec-item .vjs-rec-thumb { + width: 100%; + background-size: cover; + background-position: center; + aspect-ratio: 16/9; /* Ensures consistent shape */ + } + + /* 6. Title Label: Styled to overlay the thumbnail */ + .vjs-rec-item .vjs-rec-title { + position: absolute; + bottom: 0; + left: 0; + right: 0; + padding: 1.5em 0.8em 0.8em 0.8em; + font-size: 1.6em; /* Relative to player font size */ + text-align: left; + color: #fff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-shadow: 1px 1px 2px black, -1px 1px 2px black, 1px -1px 2px black, -1px -1px 2px black; + } + + + /* Add these new rules to your stylesheet */ + +/* 5a. Spinner for loading thumbnail */ +.vjs-rec-thumb-spinner { + position: absolute; + top: 50%; + left: 50%; + width: 2em; + height: 2em; + margin: -1em 0 0 -1em; + border: 3px solid rgba(255, 255, 255, 0.2); + border-top-color: #fff; + border-radius: 50%; + animation: vjs-rec-spin 1s linear infinite; +} + +/* 5b. Placeholder for failed thumbnail */ +.vjs-rec-thumb-placeholder { + background-color: #2a2a2a; + /* You could also add a placeholder icon using a ::before pseudo-element */ +} + +/* Animation keyframes for the spinner */ +@keyframes vjs-rec-spin { + to { + transform: rotate(360deg); + } +} \ No newline at end of file diff --git a/packages/video-player/javascript/modules/recommendations-overlay/recommendations-overlay.ts b/packages/video-player/javascript/modules/recommendations-overlay/recommendations-overlay.ts new file mode 100644 index 0000000..c53c261 --- /dev/null +++ b/packages/video-player/javascript/modules/recommendations-overlay/recommendations-overlay.ts @@ -0,0 +1,118 @@ +import videojs from 'video.js'; +import type Player from 'video.js/dist/types/player'; +import { IKPlayerOptions, SourceOptions } from '../../interfaces'; +import { preparePosterSrc, CleanupRegistry } from '../../utils'; + +const Component = videojs.getComponent('Component'); + +interface RecommendationsOverlayOptions { + recommendations: SourceOptions[]; + playerOptions: IKPlayerOptions; + children?: any[]; + className?: string; +} + +export class RecommendationsOverlay extends Component { + private recommendations: SourceOptions[]; + private playerOptions: IKPlayerOptions; + private gridEl!: HTMLDivElement; + private closeBtn!: HTMLButtonElement; + private cleanup_ = new CleanupRegistry(); + + constructor(player: Player, options: RecommendationsOverlayOptions) { + super(player, options); + this.recommendations = options.recommendations || []; + this.playerOptions = options.playerOptions; + + this.hide(); // start hidden + + // Build elements using the correct static method + this.gridEl = videojs.dom.createEl('div', { className: 'vjs-rec-list' }) as HTMLDivElement; + this.closeBtn = videojs.dom.createEl('div', { className: 'vjs-rec-close' }) as HTMLButtonElement; + this.closeBtn.innerHTML = '✕'; // × + + // Assemble + this.el().appendChild(this.closeBtn); + this.el().appendChild(this.gridEl); + + // Listeners + this.cleanup_.registerVideoJsListener(player, 'ended', this.onEnded); + this.cleanup_.registerEventListener(this.closeBtn, 'click', () => this.hide()); + } + + createEl() { + return super.createEl('div', { className: 'vjs-recommendations-overlay' }); + } + + private onEnded = () => { + this.renderRecommendations(); + this.show(); + }; + + private renderRecommendations() { + this.gridEl.innerHTML = ''; + this.recommendations.forEach(rec => { + const card = this._createRecommendationItem(rec); + this.gridEl.appendChild(card); + }); + } + + // Add this new private helper method to the RecommendationsOverlay class + + /** + * Creates a single recommendation item element, including the logic + * for asynchronously loading its poster. + * @param rec - The source options for the recommendation item. + * @returns A complete HTML element for the card. + * @private + */ + private _createRecommendationItem(rec: SourceOptions): HTMLDivElement { + const card = videojs.dom.createEl('div', { className: 'vjs-rec-item' }) as HTMLDivElement; + const thumb = videojs.dom.createEl('div', { className: 'vjs-rec-thumb' }) as HTMLDivElement; + + // Create and add the title label INSIDE the thumbnail container. + // This is crucial for the overlay styling to work correctly. + const label = videojs.dom.createEl('div', { className: 'vjs-rec-title' }) as HTMLDivElement; + label.textContent = rec.info?.title || ''; + + // Create and add the spinner. + const spinner = videojs.dom.createEl('div', { className: 'vjs-rec-thumb-spinner' }); + thumb.appendChild(spinner); + + // Assemble the card structure correctly. + card.appendChild(thumb); + thumb.appendChild(label); // Append label to thumb for overlay effect + + // Asynchronously load the poster. + preparePosterSrc(rec, this.playerOptions) + .then((url) => { + // On success, remove spinner and set the background image. + spinner.remove(); + thumb.style.backgroundImage = `url('${url}')`; + }) + .catch((err) => { + // On failure, remove spinner and apply a placeholder style to the thumbnail. + this.player_.log.error(`Failed to load poster for recommendation item: ${err.message}`); + spinner.remove(); + // CORRECTED: Apply the placeholder class to the 'thumb' element, not the removed spinner. + thumb.classList.add('vjs-rec-thumb-placeholder'); + }); + + card.onclick = () => this.onClickHandler(rec); + return card; + } + + private onClickHandler(source: SourceOptions) { + this.player().src(source); + this.hide(); + } + + dispose(): void { + this.cleanup_.dispose(); + super.dispose(); + } +} + +// register component +videojs.registerComponent('RecommendationsOverlay', RecommendationsOverlay); +export default RecommendationsOverlay; \ No newline at end of file diff --git a/packages/video-player/javascript/modules/seek-thumbnails/seek-thumbnails-manager.ts b/packages/video-player/javascript/modules/seek-thumbnails/seek-thumbnails-manager.ts new file mode 100644 index 0000000..42f985c --- /dev/null +++ b/packages/video-player/javascript/modules/seek-thumbnails/seek-thumbnails-manager.ts @@ -0,0 +1,287 @@ +import videojs from 'video.js'; +import type Player from 'video.js/dist/types/player'; +import type { IKPlayerOptions, 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: IKPlayerOptions + ): 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 playerWithControlBar = player as unknown as { + controlBar: { + progressControl: { + on(event: string, handler: (e: MouseEvent) => void): void; + off(event: string, handler: (e: MouseEvent) => void): void; + el(): HTMLElement; + }; + }; + }; + const progress = playerWithControlBar.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.error('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 playerWithControlBar = player as unknown as { + controlBar: { + progressControl: { + off(event: string, handler: (e: MouseEvent) => void | (() => void)): void; + }; + }; + }; + const progress = playerWithControlBar.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_ = []; + } +} + +/** + * Extracts the thumbnail width from the VTT URL hash, or returns a default. + * @param url The thumbnail URL. + * @returns The width of the thumbnail frame. + */ +function getThumbnailWidthFromUrl(url: URL): number { + const m = url.hash.match(/xywh=(\d+),(\d+),(\d+),(\d+)/); + if (m && m[3]) { + // The 'w' value from xywh=x,y,w,h + return parseInt(m[3], 10); + } + // Default width if not specified in the URL hash + return 160; +} + +/** + * Handle hover over the progress bar. + * Looks up the nearest thumbnail for the hovered time and renders it. + */ +/** + * Handle hover over the progress bar. + * Renders the thumbnail and ensures it stays within the player bounds, + * using the dynamic width from the VTT file. + */ +function onMouseMove(e: MouseEvent, player: Player, mgr: SeekThumbnailsManager) { + if (!mgr['container_']) return; + + const playerEl = player.el(); + const playerRect = playerEl.getBoundingClientRect(); // Get player container position + const playerWidth = (playerEl as HTMLElement).offsetWidth; + const playerWithControlBar = player as unknown as { + controlBar: { + progressControl: { + el(): HTMLElement; + }; + }; + }; + const barRect = playerWithControlBar.controlBar.progressControl.el().getBoundingClientRect(); + + const pct = Math.max(0, Math.min(1, (e.clientX - barRect.left) / barRect.width)); + const time = pct * player.duration(); + + const url = nearestThumbnail(mgr['thumbnails_'], time); + if (!url) return; + + // --- START: NEW LOGIC --- + + // 1. Get the DYNAMIC width for this specific thumbnail from its URL + const thumbnailWidth = getThumbnailWidthFromUrl(url); + const thumbnailHalfWidth = thumbnailWidth / 2; + + // 2. Calculate the hover position relative to the PLAYER container, not just progress bar + // Account for progress bar's offset from player's left edge + const progressBarLeftOffset = barRect.left - playerRect.left; + const hoverPositionInPlayer = progressBarLeftOffset + (pct * barRect.width); + + // 3. Clamp the position using the dynamic width + let newLeft = hoverPositionInPlayer; + + if (newLeft < thumbnailHalfWidth) { + newLeft = thumbnailHalfWidth; + } else if (newLeft > playerWidth - thumbnailHalfWidth) { + newLeft = playerWidth - thumbnailHalfWidth; + } + + // 4. Update the thumbnail element + const container = mgr['container_']!; + container.innerHTML = ''; + const thumbEl = createThumbnailElement(document, url); + thumbEl.className = 'thumbnail'; + + container.style.left = `${newLeft}px`; + container.style.display = 'block'; + container.appendChild(thumbEl); + + // --- END: NEW LOGIC --- +} + +/** 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: '160px', + height: '90px', + 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`; + // Removed bottom style - translateY(-100%) already positions it correctly above container + } + 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/video-player/javascript/modules/seek-thumbnails/seek-thumbnails.css b/packages/video-player/javascript/modules/seek-thumbnails/seek-thumbnails.css new file mode 100644 index 0000000..6370cb0 --- /dev/null +++ b/packages/video-player/javascript/modules/seek-thumbnails/seek-thumbnails.css @@ -0,0 +1,22 @@ +.thumbnail-preview { + position: absolute; + bottom: 10em; + pointer-events: none; + display: none; + z-index: 0; + } + + .thumbnail { + position: absolute; + height: 100%; + background-size: cover; + 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/video-player/javascript/modules/seek-thumbnails/vtt-parser.ts b/packages/video-player/javascript/modules/seek-thumbnails/vtt-parser.ts new file mode 100644 index 0000000..b91aefe --- /dev/null +++ b/packages/video-player/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/video-player/javascript/modules/shoppable/shoppable-item.ts b/packages/video-player/javascript/modules/shoppable/shoppable-item.ts new file mode 100644 index 0000000..988318e --- /dev/null +++ b/packages/video-player/javascript/modules/shoppable/shoppable-item.ts @@ -0,0 +1,197 @@ +import { ProductProps, Transformation, SourceOptions } from 'javascript/interfaces'; +import { AugmentedSourceOptions } from 'javascript/interfaces/AugementedSourceOptions'; +import { preparePosterSrc } from 'javascript/utils'; +import * as _ from 'lodash'; +import videojs from 'video.js'; +import type Player from 'video.js/dist/types/player'; + +const ClickableComponent = videojs.getComponent('ClickableComponent'); +const dom = videojs.dom || videojs; + +const DEFAULT_TRANSFORMATION: Transformation = { + width: 400, + height: 400, + cropMode: 'pad_resize', + background: 'white', +} + +interface ShoppablePanelItemOptions { + source: AugmentedSourceOptions; + item: ProductProps; + transformation?: Transformation[]; + index: number; + clickHandler: Function; + children?: any[]; + className?: string; +} + +class ShoppablePanelItem extends ClickableComponent { + private spinnerEl!: HTMLElement; + private imgEl?: HTMLImageElement; + private altImgEl?: HTMLImageElement; + + constructor(player: Player, initOptions: ShoppablePanelItemOptions) { + super(player, initOptions); + + this.on('mouseenter', () => this.handleMouseEnter()); + } + + handleClick(event: Event) { + event.preventDefault(); + event.stopPropagation(); + this.options_.clickHandler(event); + } + + handleMouseEnter() { + this.player_.trigger('productHover', { product: this.getItem() }); + } + + private getItem() { + return this.options_.item; + } + + private getTransformation() { + return this.options_.transformation || [DEFAULT_TRANSFORMATION]; + } + + private getItemIndex() { + return this.options_.index; + } + + private async getThumbnail(altImg?: boolean): Promise { + const item = this.getItem(); + const index = this.getItemIndex(); + + if (!item) { + throw new Error('No item provided for shoppable item thumbnail'); + } + + // 1. Determine which image URL and cache position to use. + const isAlt = !!altImg; + const cacheIndex = isAlt ? 1 : 0; + // Type-safe: when isAlt is true, we know onHover.action is 'switch' or 'goto', so args is an object with url + const imageUrl = isAlt && item.onHover && (item.onHover.action === 'switch' || item.onHover.action === 'goto') && typeof item.onHover.args === 'object' && item.onHover.args?.url + ? item.onHover.args.url + : item.imageUrl; + + // 2. Validate that we found a URL to process. + if (!imageUrl) { + const imageType = isAlt ? 'alternate' : 'main'; + throw new Error(`No ${imageType} image URL found.`); + } + + const source = this.options_.source as AugmentedSourceOptions; + + // 3. Ensure the nested cache structure exists. + source.prepared = source?.prepared || {}; + source.prepared.shoppableThumbnails = source?.prepared?.shoppableThumbnails || {}; + source.prepared.shoppableThumbnails[index] = source?.prepared?.shoppableThumbnails?.[index] || []; + + // 4. Check the cache and return the URL if it already exists. + if (source.prepared.shoppableThumbnails[index][cacheIndex]) { + return source.prepared.shoppableThumbnails[index][cacheIndex]; + } + + // 5. If not cached, prepare the URL once. + const tempSrc: SourceOptions = { + src: 'https://dummyimage.com/400x225/000/fff&text=Loading+Thumbnail', + poster: { + src: imageUrl, + transformation: this.getTransformation() + } + }; + + const preparedUrl = await preparePosterSrc(tempSrc, (this.player_ as any).imagekitVideoPlayer().getPlayerOptions()); + + // 6. Store the newly prepared URL in the cache and return it. + source.prepared.shoppableThumbnails[index][cacheIndex] = preparedUrl; + return preparedUrl; + } + + createEl() { + const prod = this.getItem(); + const el = document.createElement('a'); + el.className = 'vjs-shoppable-item'; + el.setAttribute('data-product-id', String(this.getItem().productId)); + + const imageContainer = document.createElement('div'); + imageContainer.className = 'vjs-shoppable-image-container'; + el.appendChild(imageContainer); + + // spinner + this.spinnerEl = document.createElement('div'); + this.spinnerEl.className = 'vjs-shoppable-item-spinner'; + // you can style this in your SCSS to show a CSS spinner + imageContainer.appendChild(this.spinnerEl); + + this.getThumbnail() + // FIX: Use an arrow function to preserve `this` context + .then((url) => { + if (!this.el_) { + return; + } + + if (this.spinnerEl) { + this.spinnerEl.remove(); + } + + this.imgEl = document.createElement('img'); + this.imgEl.className = 'vjs-shoppable-item-img'; + this.imgEl.loading = 'lazy'; + this.imgEl.src = url; + this.imgEl.alt = this.getItem().productName || ''; + imageContainer.appendChild(this.imgEl); + + if (prod.onHover?.action === 'switch' && prod.onHover.args?.url) { + this.getThumbnail(true) + .then((altUrl) => { + this.altImgEl = document.createElement('img'); + this.altImgEl.className = 'vjs-shoppable-item-img vjs-shoppable-item-img-alt'; + this.altImgEl.src = altUrl; // Use the prepared URL + this.altImgEl.alt = prod.productName || ''; + this.altImgEl.loading = 'lazy'; + this.altImgEl.setAttribute('aria-hidden', 'true'); + imageContainer.appendChild(this.altImgEl); + }) + .catch((err) => { + this.player_.log('Could not load alternate image for shoppable item.', err); + }); + } + }) + .catch((err) => { + if (!this.el_) { + return; + } + this.player_.log.error(`Failed to load poster for shoppable item: ${err.message}`); + if (this.spinnerEl) { + this.spinnerEl.remove(); + } + el.classList.add('vjs-shoppable-item-placeholder'); + }); + + const info = document.createElement('div'); + info.className = 'vjs-shoppable-item-info'; + info.textContent = prod.productName; + el.appendChild(info); + + // --- CHANGE: Centralized and improved onHover logic --- + if (prod.onHover) { + // If the action is 'overlay', create the overlay element ONCE and append it. + // It will be hidden by default via CSS and shown on hover. + if (prod.onHover.action === 'overlay' && prod.onHover.args) { + const hoverOverlay = document.createElement('div'); + hoverOverlay.className = 'vjs-shoppable-item-overlay'; + hoverOverlay.textContent = prod.onHover.args; + el.appendChild(hoverOverlay); + } + } + + return el; + } +} + + + +videojs.registerComponent('shoppablePanelItem', ShoppablePanelItem); + +export default ShoppablePanelItem; diff --git a/packages/video-player/javascript/modules/shoppable/shoppable-manager.ts b/packages/video-player/javascript/modules/shoppable/shoppable-manager.ts new file mode 100644 index 0000000..00b8cd7 --- /dev/null +++ b/packages/video-player/javascript/modules/shoppable/shoppable-manager.ts @@ -0,0 +1,493 @@ +import type Player from 'video.js/dist/types/player'; +import type { + ShoppableProps, + ProductProps, + Hotspot, + InteractionProps, +} from '../../interfaces'; +import { AugmentedSourceOptions } from 'javascript/interfaces/AugementedSourceOptions'; +import { CleanupRegistry } from '../../utils'; +import ShoppablePanelItem from './shoppable-item'; + +export class ShoppableManager { + private player_: Player; + private shoppable_: ShoppableProps; + private barContainer_: HTMLDivElement | null = null; + private panelEl_: HTMLDivElement | null = null; + private toggleButton_: HTMLDivElement | null = null; + private hotspotElements_: { element: HTMLDivElement; hotspot: Hotspot; product: ProductProps }[] = []; + private postPlayOverlay_: HTMLDivElement | null = null; + private tickHandler_: (() => void) | null = null; + private endedHandler_: (() => void) | null = null; + private autoCloseTimeout_: any = null; + private initialAnimationTimeout_: any = null; + private pauseTimeout_: any = null; + private sourceItem_: AugmentedSourceOptions; + private currentActiveProductId_: string | number | null = null; + private cleanup_ = new CleanupRegistry(); + + constructor(player: Player, src: AugmentedSourceOptions) { + this.player_ = player; + this.sourceItem_ = src; + this.shoppable_ = src.shoppable; + // Build all the necessary DOM elements first. + this.buildProductBar(); + this.buildHotspots(); + this.player_.ready(() => { + // Attach the time-update listener for highlights and hotspots. + this.tickHandler_ = this.onTimeUpdate.bind(this); + this.cleanup_.registerVideoJsListener(this.player_, 'timeupdate', this.tickHandler_); + + // Set up the post-play overlay if configured. + if (this.shoppable_.showPostPlayOverlay) { + this.buildPostPlayOverlay(); + this.endedHandler_ = this.onEnded.bind(this); + this.cleanup_.registerVideoJsListener(this.player_, 'ended', this.endedHandler_); + } + + const startState = this.shoppable_.startState || 'openOnPlay'; + + this.player_.one('play', () => { + // Always show the toggle button on the first play. + this.toggleButton_?.classList.remove('vjs-hidden'); + + // If the state is 'openOnPlay', open the bar now. + if (startState === 'openOnPlay') { + this.openBar(); + } + }); + + if (startState === 'open') { + // If the state is 'open', open the bar and show the button immediately. + // Because this is inside ready(), it's safe to do now. + this.openBar(); + this.toggleButton_?.classList.remove('vjs-hidden'); + } else if (startState === 'closed') { + // If the state is 'closed', hide the bar and set the animation timeout. + this.closeBar(true); + this.initialAnimationTimeout_ = this.cleanup_.registerTimeout(() => { + // The 'play' listener above will handle unhiding the button. + this.toggleButton_?.classList.add('animate'); + }, 3000); + } + }); + } + + private scrollToActiveItem(activeItem: HTMLElement) { + if (!this.panelEl_) { + return; + } + + const toScroll = activeItem.offsetTop - 12; // 12px offset for better spacing + + // Use the modern, smooth scroll behavior where available + if ('scrollBehavior' in document.documentElement.style) { + this.panelEl_.scrollTo({ + top: toScroll, + behavior: 'smooth' + }); + } else { + // Fallback for older browsers + this.panelEl_.scrollTop = toScroll; + } + } + + private buildProductBar() { + const playerEl = this.player_.el(); + + const bar = document.createElement('div'); + bar.className = 'vjs-shoppable-bar'; + + const inner = document.createElement('div'); + inner.className = 'vjs-shoppable-bar-inner'; + bar.appendChild(inner); + + const panel = document.createElement('div'); + panel.className = 'vjs-shoppable-panel'; + inner.appendChild(panel); + this.panelEl_ = panel; + + const toggle = document.createElement('div'); + toggle.className = 'vjs-shoppable-toggle'; + + const openIconUrl = this.shoppable_.toggleIconUrl || 'https://imagekit.io/icons/icon-144x144.png'; + const closeIconUrl = 'https://imagekit.io/icons/icon-144x144.png'; + + const openIcon = document.createElement('div'); + openIcon.className = 'vjs-shoppable-toggle-icon icon-open'; + openIcon.style.backgroundImage = `url('${openIconUrl}')`; + + const closeIcon = document.createElement('div'); + closeIcon.className = 'vjs-shoppable-toggle-icon icon-close'; + closeIcon.style.backgroundImage = `url('${closeIconUrl}')`; + + toggle.appendChild(openIcon); + toggle.appendChild(closeIcon); + this.cleanup_.registerEventListener(toggle, 'click', () => this.toggleBar()); + + this.toggleButton_ = toggle; + this.toggleButton_?.classList.add('vjs-hidden'); + inner.appendChild(this.toggleButton_); + + this.shoppable_.products.forEach((prod, index) => { + + const clickhandler_ = (e: MouseEvent) => { + e.preventDefault(); + this.player_.trigger('productClick', { product: prod }); + this.resetAutoClose(); + if (prod.onClick) this.handleClickInteraction(prod.onClick, prod); + }; + + const item = new ShoppablePanelItem(this.player_, { + item: prod, + index: index, + transformation: this.shoppable_.transformation, + source: this.sourceItem_, + clickHandler: clickhandler_, + }); + panel.appendChild(item.el()); + }); + + this.barContainer_ = bar; + playerEl.appendChild(this.barContainer_); + } + + private buildHotspots() { + this.shoppable_.products.forEach(product => { + if (!product.hotspots) return; + + product.hotspots.forEach(hotspot => { + const hsElement = document.createElement('div'); + hsElement.className = 'vjs-shoppable-hotspot vjs-hidden'; + hsElement.style.left = hotspot.x; + hsElement.style.top = hotspot.y; + + const tooltip = document.createElement('div'); + tooltip.className = 'vjs-shoppable-hotspot-tooltip vjs-hidden'; + tooltip.textContent = product.productName; + const position = hotspot.tooltipPosition || 'top'; + tooltip.classList.add(`tooltip-position-${position}`); + hsElement.appendChild(tooltip); + + this.cleanup_.registerEventListener(hsElement, 'mouseenter', () => tooltip.classList.remove('vjs-hidden')); + this.cleanup_.registerEventListener(hsElement, 'mouseleave', () => tooltip.classList.add('vjs-hidden')); + this.cleanup_.registerEventListener(hsElement, 'click', () => { + this.player_.trigger('productClick', { product }); + if (product.onClick) this.handleClickInteraction(product.onClick, product); + }); + + this.player_.el().appendChild(hsElement); + this.hotspotElements_.push({ element: hsElement, hotspot, product }); + }); + }); + } + + private onTimeUpdate() { + const currentTime = this.player_.currentTime(); + if (typeof currentTime !== 'number') return; + + this.shoppable_.products.forEach(prod => { + const itemEl = this.panelEl_?.querySelector(`[data-product-id="${prod.productId}"]`); + if (!itemEl) return; + + if (prod.highlightTime) { + const { start, end } = prod.highlightTime; + const isActive = currentTime >= start && currentTime <= end; + + if (isActive) { + // REFINED: Only scroll if the active item is new. + if (prod.productId !== this.currentActiveProductId_) { + itemEl.classList.add('active'); + this.currentActiveProductId_ = prod.productId; + this.scrollToActiveItem(itemEl as HTMLElement); + } + } else { + // If the item is no longer active, remove the class and reset tracking if it was the one being tracked. + if (itemEl.classList.contains('active')) { + itemEl.classList.remove('active'); + if (prod.productId === this.currentActiveProductId_) { + this.currentActiveProductId_ = null; + } + } + } + } + }); + + this.hotspotElements_.forEach(hsData => { + const hotspotTime = ShoppableManager.toSeconds(hsData.hotspot.time); + if (Math.abs(currentTime - hotspotTime) < 0.5) { + hsData.element.classList.remove('vjs-hidden'); + } else { + hsData.element.classList.add('vjs-hidden'); + } + }); + } + + private onEnded() { + if (this.closeBar) { + this.closeBar(true); + } + if (this.postPlayOverlay_) { + this.postPlayOverlay_.classList.remove('vjs-hidden'); + if (this.toggleButton_) this.toggleButton_.classList.add('vjs-hidden'); + this.player_.trigger('productHoverPost', {}); + } + } + + // Replace the existing buildPostPlayOverlay method with this one. + + private buildPostPlayOverlay() { + const overlay = document.createElement('div'); + overlay.className = 'vjs-shoppable-postplay-overlay vjs-hidden'; + + const title = document.createElement('div'); + title.className = 'vjs-shoppable-postplay-title'; + title.textContent = 'Shop the Video'; + overlay.appendChild(title); + + const carousel = document.createElement('div'); + carousel.className = 'vjs-shoppable-postplay-carousel'; + overlay.appendChild(carousel); + + // --- START: Make carousel grabbable --- + let isDown = false; + let startX: number; + let scrollLeft: number; + + this.cleanup_.registerEventListener(carousel, 'mousedown', (e: MouseEvent) => { + isDown = true; + carousel.classList.add('is-grabbing'); + // Get initial mouse position and scroll position + startX = e.pageX - carousel.offsetLeft; + scrollLeft = carousel.scrollLeft; + }); + + this.cleanup_.registerEventListener(carousel, 'mouseleave', () => { + isDown = false; + carousel.classList.remove('is-grabbing'); + }); + + this.cleanup_.registerEventListener(carousel, 'mouseup', () => { + isDown = false; + carousel.classList.remove('is-grabbing'); + }); + + this.cleanup_.registerEventListener(carousel, 'mousemove', (e: MouseEvent) => { + if (!isDown) return; // Stop if mouse is not clicked down + e.preventDefault(); // Prevent default dragging behavior (like text selection) + const x = e.pageX - carousel.offsetLeft; + const walk = (x - startX) * 2; // The multiplier makes scrolling feel faster + carousel.scrollLeft = scrollLeft - walk; + }); + // --- END: Make carousel grabbable --- + + this.shoppable_.products.forEach((prod, index) => { + // Define the specific click handler for items in the post-play overlay. + const postPlayClickHandler = () => { + this.player_.trigger('productClickPost', { product: prod }); + if (!prod.onClick) return; + + // Special handling for 'seek': close overlay and start playing. + if (prod.onClick.action === 'seek' && prod.onClick.args?.time) { + this.postPlayOverlay_?.classList.add('vjs-hidden'); + if (this.toggleButton_) { + this.toggleButton_.classList.remove('vjs-hidden'); + } + this.player_.currentTime(ShoppableManager.toSeconds(prod.onClick.args.time)); + this.player_.play(); + } else { + // Handle other actions like 'goto' normally. + this.handleClickInteraction(prod.onClick, prod); + } + }; + + // Create an instance of the component for the post-play screen. + const item = new ShoppablePanelItem(this.player_, { + item: prod, + index: index, + transformation: this.shoppable_.transformation, + source: this.sourceItem_, + clickHandler: postPlayClickHandler, + }); + + carousel.appendChild(item.el()); + + }); + + const replayBtn = document.createElement('div'); + replayBtn.className = 'vjs-shoppable-replay-btn'; + replayBtn.setAttribute('role', 'button'); + replayBtn.setAttribute('tabindex', '0'); + + // Create a span for the icon and add the Video.js icon class to it + const replayIcon = document.createElement('span'); + replayIcon.className = 'vjs-icon-replay'; + replayIcon.setAttribute('aria-hidden', 'true'); // Hide decorative icon from screen readers + + // Create a span for the text + const replayText = document.createElement('span'); + replayText.textContent = 'Replay'; + + // Add the new icon and text spans to the button + replayBtn.appendChild(replayIcon); + replayBtn.appendChild(replayText); + + const replayAction = () => { + overlay.classList.add('vjs-hidden'); + if (this.toggleButton_) this.toggleButton_.classList.remove('vjs-hidden'); + this.player_.play(); + }; + + replayBtn.onclick = replayAction; + replayBtn.onkeydown = (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + replayAction(); + } + }; + + overlay.appendChild(replayBtn); + + this.postPlayOverlay_ = overlay; + this.player_.el().appendChild(overlay); + } + + private toggleBar() { + if (this.initialAnimationTimeout_) clearTimeout(this.initialAnimationTimeout_); + this.toggleButton_?.classList.remove('animate'); + + const playerEl = this.player_.el(); + if (playerEl.classList.contains('shoppable-panel-visible')) { + this.closeBar(); + } else { + this.openBar(); + } + } + + private openBar() { + const playerEl = this.player_.el(); + playerEl.classList.remove('shoppable-panel-hidden'); + playerEl.classList.add('shoppable-panel-visible'); + this.player_.trigger('productBarMax'); + this.resetAutoClose(); + } + + private closeBar(immediate = false) { + const playerEl = this.player_.el(); + playerEl.classList.remove('shoppable-panel-visible'); + if (immediate) { + playerEl.classList.add('shoppable-panel-hidden'); + } + this.player_.trigger('productBarMin'); + if (this.autoCloseTimeout_) clearTimeout(this.autoCloseTimeout_); + } + + private resetAutoClose() { + if (this.autoCloseTimeout_) clearTimeout(this.autoCloseTimeout_); + const autoCloseTime = this.shoppable_.autoClose; + if (typeof autoCloseTime === 'number' && autoCloseTime > 0) { + this.autoCloseTimeout_ = this.cleanup_.registerTimeout(() => { + this.closeBar(); + }, autoCloseTime * 1000); + } + } + + 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]; + if (parts.length === 2) return parts[0] * 60 + parts[1]; + return parseFloat(time) || 0; + } + + private handleClickInteraction(interaction: InteractionProps, product: ProductProps) { + const { action, pause, args } = interaction; + switch (action) { + case 'seek': + if (args?.time) { + this.player_.currentTime(ShoppableManager.toSeconds(args.time)); + // This logic runs first for any interaction that includes the 'pause' property. + if (pause) { + // Always pause the player immediately if 'pause' is true or a number. + this.player_.pause(); + + // If 'pause' is a number, it specifies a duration in seconds to pause. + // After this duration, the video will automatically resume playing. + if (typeof pause === 'number' && pause > 0) { + // Before setting a new timeout, clear any existing one. This prevents + // multiple resume timers from running if the user clicks rapidly. + if (this.pauseTimeout_) { + clearTimeout(this.pauseTimeout_); + } + + // Schedule the player to resume playback after the specified duration. + this.pauseTimeout_ = this.cleanup_.registerTimeout(() => { + this.player_.play(); + }, pause * 1000); + } + // If 'pause' is simply `true`, the video will remain paused indefinitely, + // waiting for the user to manually click play. + } + } + break; + case 'goto': + if (args?.url) { + if (pause) { + // Always pause the player immediately if 'pause' is true or a number. + this.player_.pause(); + + // If 'pause' is a number, it specifies a duration in seconds to pause. + // After this duration, the video will automatically resume playing. + if (typeof pause === 'number' && pause > 0) { + // Before setting a new timeout, clear any existing one. This prevents + // multiple resume timers from running if the user clicks rapidly. + if (this.pauseTimeout_) { + clearTimeout(this.pauseTimeout_); + } + + // Schedule the player to resume playback after the specified duration. + this.pauseTimeout_ = this.cleanup_.registerTimeout(() => { + this.player_.play(); + }, pause * 1000); + } + // If 'pause' is simply `true`, the video will remain paused indefinitely, + // waiting for the user to manually click play. + } + window.open(args.url, '_blank'); + } + break; + default: + break; + } + } + + public destroy() { + if (this.closeBar) { + this.closeBar(true); + } + + // Register DOM elements for cleanup + if (this.barContainer_) { + this.cleanup_.registerElement(this.barContainer_); + } + this.hotspotElements_.forEach(hs => { + this.cleanup_.registerElement(hs.element); + }); + if (this.postPlayOverlay_) { + this.cleanup_.registerElement(this.postPlayOverlay_); + } + + // Register remaining timeouts for cleanup + if (this.autoCloseTimeout_) { + this.cleanup_.register(() => clearTimeout(this.autoCloseTimeout_!)); + } + if (this.initialAnimationTimeout_) { + this.cleanup_.register(() => clearTimeout(this.initialAnimationTimeout_!)); + } + if (this.pauseTimeout_) { + this.cleanup_.register(() => clearTimeout(this.pauseTimeout_!)); + } + + // Clean up everything + this.cleanup_.dispose(); + } +} diff --git a/packages/video-player/javascript/modules/shoppable/shoppable.css b/packages/video-player/javascript/modules/shoppable/shoppable.css new file mode 100644 index 0000000..b4a7bbf --- /dev/null +++ b/packages/video-player/javascript/modules/shoppable/shoppable.css @@ -0,0 +1,416 @@ +.video-js.shoppable-panel-visible .vjs-control-bar { + width: 80% !important; + transition: width 0.35s ease-in-out; +} + +.vjs-shoppable-bar { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + pointer-events: none; + overflow: hidden; + z-index: 10; +} + +.vjs-shoppable-bar-inner { + position: absolute; + top: 0; + left: 80%; + width: 20%; + height: 100%; + transform: translateX(100%); + transition: transform 0.35s ease-in-out; + pointer-events: all; +} + +.video-js.shoppable-panel-visible .vjs-shoppable-bar-inner { + transform: translateX(0%); +} + +.video-js.shoppable-panel-hidden .vjs-shoppable-bar-inner { + transform: translateX(100%); + transition: none; +} + +.vjs-shoppable-panel { + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: 100%; + overflow-y: auto; + background: rgba(255, 255, 255, 0.5); + -ms-overflow-style: none; + scrollbar-width: none; + container-type: inline-size; +} + +.vjs-shoppable-panel::-webkit-scrollbar { + display: none; +} + +.vjs-shoppable-toggle { + position: absolute; + top: 15px; + right: 100%; + width: 40px; + height: 40px; + background: rgba(255, 255, 255, 0.5); + border-radius: 5px 0 0 5px; + cursor: pointer; + z-index: 11; + display: flex; + align-items: center; + justify-content: center; + transition: background-color 0.2s; +} + +.vjs-shoppable-toggle-icon { + width: 50%; + height: 50%; + background-size: contain; + background-repeat: no-repeat; + background-position: center; + position: absolute; + opacity: 0; + transition: opacity 0.2s; +} + +.vjs-shoppable-toggle .icon-open { + opacity: 1; +} + +.vjs-shoppable-toggle .icon-close { + opacity: 1; +} + +.vjs-shoppable-toggle-icon.animate, +.vjs-shoppable-toggle-icon:hover { + animation: tada 1s infinite; +} + +@keyframes tada { + 0% { transform: scale3d(1, 1, 1); } + 10%, 20% { transform: scale3d(0.9, 0.9, 0.9) rotate3d(0, 0, 1, -3deg); } + 30%, 50%, 70%, 90% { transform: scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, 3deg); } + 40%, 60%, 80% { transform: scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, -3deg); } + 100% { transform: scale3d(1, 1, 1); } +} + +.video-js.shoppable-panel-visible .vjs-shoppable-toggle .icon-open { + opacity: 0; +} + +.video-js.shoppable-panel-visible .vjs-shoppable-toggle .icon-close { + opacity: 1; +} + +/* --- Item Layout --- */ + +.vjs-shoppable-item { + display: flex; + flex-direction: column; + margin: 10px; + border: 2px solid transparent; + border-radius: 8px; + overflow: hidden; + cursor: pointer; + transition: border-color 0.3s, transform 0.2s; + background-color: rgba(0, 0, 0, 0.05); + text-decoration: none !important; /* Removes the underline */ +} + +.vjs-shoppable-item:hover { + transform: scale(1.03); +} + +.vjs-shoppable-item.active { + border-color: #2563eb; +} + +.vjs-shoppable-image-container { + position: relative; + width: 100%; + aspect-ratio: 1 / 1; +} + +.vjs-shoppable-item-img { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + object-fit: cover; + transition: opacity 0.3s ease-in-out; + z-index: 1; +} + +.vjs-shoppable-item-img-alt { + opacity: 0; +} + +.vjs-shoppable-item:hover .vjs-shoppable-item-img-alt { + opacity: 1; +} + +.vjs-shoppable-item-info { + position: static; + width: 100%; + background: rgba(0, 0, 0, 1); + color: #fff; + padding: 8px; + font-size: 10px; + text-align: center; + white-space: normal; + overflow: hidden; + text-overflow: ellipsis; + z-index: 2; + min-height: 48px; + display: flex; + align-items: center; + justify-content: center; +} + +@container (min-width: 120px) { + .vjs-shoppable-item-info { + font-size: 12px; + } +} + +@container (min-width: 140px) { + .vjs-shoppable-item-info { + font-size: 14px; + } +} + +.vjs-shoppable-postplay-carousel .vjs-shoppable-item-info { + font-size: 10px; + font-weight: 500; + line-height: 1.3; + background: rgba(0, 0, 0, 1); + padding: 6px; + padding-bottom: 0px; + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + min-height: unset; + margin-bottom: 8px; +} + +@container (min-width: 600px) { + .vjs-shoppable-postplay-carousel .vjs-shoppable-item-info { + font-size: 12px; + padding: 7px; + padding-bottom: 0px; + margin-bottom: 8px; + } +} + +@container (min-width: 700px) { + .vjs-shoppable-postplay-carousel .vjs-shoppable-item-info { + font-size: 14px; + padding: 8px; + padding-bottom: 0px; + margin-bottom: 8px; + } +} + +.vjs-shoppable-postplay-carousel .vjs-shoppable-item { + width: clamp(100px, 25%, 200px); + flex-shrink: 0; + border: 1px solid rgba(255, 255, 255, 0.4); +} + +.vjs-shoppable-postplay-carousel .vjs-shoppable-item:hover { + border: 1px solid rgba(255, 255, 255, 1); +} + +.vjs-shoppable-item-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.65); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + padding: 10px; + box-sizing: border-box; + opacity: 0; + transition: opacity 0.25s ease-in-out; + pointer-events: none; + z-index: 3; +} + +.vjs-shoppable-item:hover .vjs-shoppable-item-overlay { + opacity: 1; +} + +/* --- Hotspot Styles --- */ + +.vjs-shoppable-hotspot { + position: absolute; + width: 24px; + height: 24px; + transform: translate(-50%, -50%); + cursor: pointer; + z-index: 5; +} + +.vjs-shoppable-hotspot::after { + content: ""; + display: block; + width: 12px; + height: 12px; + background: #fff; + border-radius: 50%; + box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.9); + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + animation: pulse 2s infinite; +} + +.vjs-shoppable-hotspot-tooltip { + position: absolute; + background: rgba(0, 0, 0, 0.8); + color: #fff; + padding: 4px 8px; + border-radius: 4px; + font-size: 12px; + white-space: nowrap; + top: -12px; + left: 50%; + transform: translate(-50%, -100%); + pointer-events: none; +} + +.vjs-shoppable-hotspot-tooltip.tooltip-position-top { + top: -8px; + left: 50%; + transform: translate(-50%, -100%); +} + +.vjs-shoppable-hotspot-tooltip.tooltip-position-bottom { + bottom: -8px; + left: 50%; + transform: translate(-50%, 100%); +} + +.vjs-shoppable-hotspot-tooltip.tooltip-position-left { + top: 50%; + left: -8px; + transform: translate(-100%, -50%); +} + +.vjs-shoppable-hotspot-tooltip.tooltip-position-right { + top: 50%; + right: -8px; + transform: translate(100%, -50%); +} + +/* --- Post-Play Overlay Styles --- */ + +.vjs-shoppable-postplay-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 1); + z-index: 20; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: clamp(12px, 1.5%, 24px); + box-sizing: border-box; + container-type: inline-size; + row-gap: clamp(12px, 1.5%, 24px); +} + +.vjs-shoppable-postplay-title { + color: #fff; + font-size: 24px; + font-weight: bold; +} + +.vjs-shoppable-postplay-carousel { + display: flex; + gap: clamp(12px, 1.5%, 24px); + overflow-x: auto; + max-width: 100%; + cursor: grab; + user-select: none; + -ms-overflow-style: none; + scrollbar-width: none; +} + +.vjs-shoppable-postplay-carousel::-webkit-scrollbar { + display: none; +} + +.vjs-shoppable-postplay-carousel.is-grabbing { + cursor: grabbing; +} + +.vjs-shoppable-replay-btn { + padding: 12px 24px; + background-color: rgba(255, 255, 255, 0.9); + color: #111; + border: none; + border-radius: 25px; + cursor: pointer; + font-size: 16px; + font-weight: 600; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + transition: background-color 0.2s, transform 0.2s; +} + +.vjs-shoppable-replay-btn:hover { + background-color: #fff; + transform: scale(1.05); +} + +/* --- Utility and Spinner Styles --- */ + +.vjs-hidden { + display: none !important; +} + +.vjs-shoppable-item-spinner { + position: absolute; + top: 50%; + left: 50%; + width: 2em; + height: 2em; + margin: -1em 0 0 -1em; + border: 3px solid rgba(255, 255, 255, 0.2); + border-top-color: #fff; + border-radius: 50%; + animation: vjs-shoppable-item-spinner 1s linear infinite; + z-index: 2; +} + +@keyframes vjs-shoppable-item-spinner { + to { + transform: rotate(360deg); + } +} + +.vjs-shoppable-item-placeholder { + background: #303030; + height: 100%; +} \ No newline at end of file diff --git a/packages/video-player/javascript/modules/source-handler/index.ts b/packages/video-player/javascript/modules/source-handler/index.ts new file mode 100644 index 0000000..e5768d3 --- /dev/null +++ b/packages/video-player/javascript/modules/source-handler/index.ts @@ -0,0 +1,2 @@ +export { createSourceOverride } from './source-handler'; +export type { SourceOverrideOptions } from './source-handler'; diff --git a/packages/video-player/javascript/modules/source-handler/source-handler.ts b/packages/video-player/javascript/modules/source-handler/source-handler.ts new file mode 100644 index 0000000..64bdc3e --- /dev/null +++ b/packages/video-player/javascript/modules/source-handler/source-handler.ts @@ -0,0 +1,228 @@ +import type Player from 'video.js/dist/types/player'; +import type { IKPlayerOptions, RemoteTextTrackOptions, SourceOptions, ShoppableProps } from '../../interfaces'; +import type { AugmentedSourceOptions } from '../../interfaces/AugementedSourceOptions'; +import { waitForVideoReady, prepareSource, preparePosterSrc } from '../../utils'; +import { setTextTracks, validateRemoteTextTrackOptions } from '../subtitles/subtitles'; +import { hasPreparedSrc, ensurePrepared } from './source-helpers'; + +/** + * Options for creating a source override handler. + */ +export interface SourceOverrideOptions { + /** ImageKit player options */ + options: IKPlayerOptions; + /** Callback to get the current source */ + getCurrentSource: () => SourceOptions | null; + /** Callback to get the original current source */ + getOriginalCurrentSource: () => SourceOptions | null; + /** Callback to update the current source */ + onSourceUpdate: (source: SourceOptions) => void; + /** Callback to update the original source */ + onOriginalSourceUpdate: (source: SourceOptions) => void; + /** Function to check if a source has prepared src */ + hasPreparedSrc: (opts: SourceOptions) => opts is AugmentedSourceOptions; +} + +function showLoadingState(player: Player): void { + const bigPlay = player.getChild('BigPlayButton'); + bigPlay && bigPlay.hide(); + const spinner = player.getChild('LoadingSpinner'); + player.addClass('vjs-waiting'); + spinner?.el()?.setAttribute('aria-hidden', 'false'); +} + +function hideLoadingState(player: Player): void { + const bigPlay = player.getChild('BigPlayButton'); + bigPlay && bigPlay.show(); + player.removeClass('vjs-waiting'); + const spinner = player.getChild('LoadingSpinner'); + spinner?.el()?.setAttribute('aria-hidden', 'true'); +} + +function validateSourceOptions(source: SourceOptions): void { + if (!source.src || typeof source.src !== 'string') { + throw new Error('`src` is required and must be a non-empty string.'); + } + + if (source.textTracks) { + if (!Array.isArray(source.textTracks)) { + throw new Error('`textTracks` must be an array.'); + } + source.textTracks.forEach((track, index) => { + try { + validateRemoteTextTrackOptions(track); + } catch (err) { + throw new Error(`\`textTracks[${index}]\`: ${err instanceof Error ? err.message : String(err)}`); + } + }); + } + + if (source.recommendations) { + if (!Array.isArray(source.recommendations)) { + throw new Error('`recommendations` must be an array.'); + } + source.recommendations.forEach((rec, index) => { + try { + validateSourceOptions(rec); + } catch (err) { + throw new Error(`\`recommendations[${index}]\`: ${err instanceof Error ? err.message : String(err)}`); + } + }); + } + + if (source.shoppable) { + const shoppable = source.shoppable as ShoppableProps; + if (!Array.isArray(shoppable.products)) { + throw new Error('`shoppable.products` is required and must be an array.'); + } + if (shoppable.products.length === 0) { + throw new Error('`shoppable.products` must contain at least one product.'); + } + if (shoppable.autoClose !== undefined && shoppable.autoClose !== false && (typeof shoppable.autoClose !== 'number' || shoppable.autoClose < 0)) { + throw new Error('`shoppable.autoClose` must be a non-negative number or false.'); + } + if (shoppable.startState && !['closed', 'open', 'openOnPlay'].includes(shoppable.startState)) { + throw new Error("`shoppable.startState` must be 'closed', 'open', or 'openOnPlay'."); + } + if (shoppable.width !== undefined && (typeof shoppable.width !== 'number' || shoppable.width < 0 || shoppable.width > 100)) { + throw new Error('`shoppable.width` must be a number between 0 and 100.'); + } + shoppable.products.forEach((product, index) => { + if (typeof product.productId !== 'number') { + throw new Error(`\`shoppable.products[${index}].productId\` is required and must be a number.`); + } + if (!product.productName || typeof product.productName !== 'string') { + throw new Error(`\`shoppable.products[${index}].productName\` is required and must be a non-empty string.`); + } + if (!product.imageUrl || typeof product.imageUrl !== 'string') { + throw new Error(`\`shoppable.products[${index}].imageUrl\` is required and must be a non-empty string.`); + } + }); + } +} + +async function prepareSourceIfNeeded( + source: SourceOptions, + options: IKPlayerOptions +): Promise { + if (hasPreparedSrc(source)) { + return source; + } + return prepareSource(source, options); +} + +/** + * Sets up text tracks (subtitles/captions) for the player from the current source. + */ +function setupTextTracks( + player: Player, + source: SourceOptions, + options: IKPlayerOptions +): void { + const textTracks = source.textTracks || []; + if (textTracks.length) { + setTextTracks(player, textTracks as RemoteTextTrackOptions[], source, options.signerFn); + } +} + +/** + * Sets up the poster image for the player from the current source. + */ +function setupPoster( + player: Player, + source: SourceOptions, + options: IKPlayerOptions +): void { + const preparedPoster = (source as AugmentedSourceOptions | null | undefined)?.prepared?.poster; + + if (preparedPoster) { + player.poster(preparedPoster); + } else { + preparePosterSrc(source, options).then( + poster => { + if (poster) { + player.poster(poster); + } + ensurePrepared(source as AugmentedSourceOptions).poster = poster ?? undefined; + } + ).catch(err => { + player.log.error(`Failed to load poster: ${err.message}`); + }); + } +} + +/** + * Creates a source override function that replaces Video.js's native src method. + * This handles ImageKit source preparation, validation, and setup. + * + * @param player - The Video.js player instance + * @param overrideOptions - Options for the source override + * @returns A function that can be assigned to player.src + */ +export function createSourceOverride( + player: Player, + overrideOptions: SourceOverrideOptions +): any { + const { options, getCurrentSource, getOriginalCurrentSource, onSourceUpdate, onOriginalSourceUpdate, hasPreparedSrc } = overrideOptions; + const nativeSrc = player.src.bind(player); + let srcCallVersion = 0; + + return (source?: SourceOptions) => { + if (source === undefined) { + return getOriginalCurrentSource(); + } + + validateSourceOptions(source); + + // Clone the source immediately after validation to prevent mutating the original + const sourceClone = { ...source }; + onOriginalSourceUpdate({ ...sourceClone }); + onSourceUpdate({ ...sourceClone }); + + const myCallId = ++srcCallVersion; + + showLoadingState(player); + + const currentSource = getCurrentSource(); + if (!currentSource) { + return; + } + + prepareSourceIfNeeded(currentSource, options) + .then(async (prepared: SourceOptions) => { + if (myCallId !== srcCallVersion) { + return; + } + + const { maxTries, videoTimeoutInMS, delayInMS } = options; + + if (!hasPreparedSrc(currentSource)) { + ensurePrepared(currentSource as AugmentedSourceOptions).src = prepared.src; + } + + onSourceUpdate(prepared); + + await waitForVideoReady( + prepared.src, + maxTries!, + videoTimeoutInMS!, + delayInMS + ); + + nativeSrc(prepared as any); + + setupTextTracks(player, prepared, options); + setupPoster(player, prepared, options); + }) + .catch(err => { + if (myCallId === srcCallVersion) { + player.error(err.message); + } + }) + .finally(() => { + if (myCallId === srcCallVersion) { + hideLoadingState(player); + } + }); + }; +} diff --git a/packages/video-player/javascript/modules/source-handler/source-helpers.ts b/packages/video-player/javascript/modules/source-handler/source-helpers.ts new file mode 100644 index 0000000..5a988d3 --- /dev/null +++ b/packages/video-player/javascript/modules/source-handler/source-helpers.ts @@ -0,0 +1,17 @@ +import type { SourceOptions } from '../../interfaces'; +import type { AugmentedSourceOptions } from '../../interfaces/AugementedSourceOptions'; + +/** + * Ensures that a source has a prepared object for caching prepared values. + */ +export function ensurePrepared(src: AugmentedSourceOptions): NonNullable { + if (!src.prepared) src.prepared = {}; + return src.prepared; +} + +/** + * Checks if a source already has a prepared src URL. + */ +export function hasPreparedSrc(opts: SourceOptions): opts is AugmentedSourceOptions { + return (opts as any).prepared && typeof (opts as any).prepared.src === 'string'; +} diff --git a/packages/video-player/javascript/modules/subtitles/samples/de.vtt b/packages/video-player/javascript/modules/subtitles/samples/de.vtt new file mode 100644 index 0000000..4fc5889 --- /dev/null +++ b/packages/video-player/javascript/modules/subtitles/samples/de.vtt @@ -0,0 +1,31 @@ +WEBVTT + +00:00.080 --> 00:03.620 +Wir werden hier in all unseren Matches einen früheren EVO-Champion haben. + +00:03.903 --> 00:05.240 +Das ist eigentlich eine sehr ähnliche Situation, oder? + +00:05.601 --> 00:08.460 +Früherer EVO-Champion gegen einen Spieler aus einem anderen Land + +00:08.661 --> 00:12.060 +der immer wieder bewiesen hat, dass er einer der besten Spieler der ganzen Welt ist + +00:12.783 --> 00:13.360 +immer und immer wieder. + +00:13.601 --> 00:15.460 +Also das ist eine tolle Sache zu sehen. + +00:16.071 --> 00:21.650 +Und Sonic Fox, wissen Sie, irgendwie, ich glaube, viele Leute waren sich nicht sicher, wie sie heute spielen würden. + +00:22.311 --> 00:32.450 +Es gibt eine Menge Spiele, auf die man sich bei dieser EVO konzentrieren muss, einschließlich Skullgirls natürlich, was, Sonic Fox ist wie bewährt, aber offensichtlich ein Match von den Top Acht der Gewinner entfernt, also machen sie sich ziemlich gut. + +00:32.583 --> 00:33.220 +Nun, würde ich sagen. + +00:33.843 --> 00:36.179 +Ja, SonicFox, es ist interessant, denn wenn \ No newline at end of file diff --git a/packages/video-player/javascript/modules/subtitles/samples/en.vtt b/packages/video-player/javascript/modules/subtitles/samples/en.vtt new file mode 100644 index 0000000..21ff710 --- /dev/null +++ b/packages/video-player/javascript/modules/subtitles/samples/en.vtt @@ -0,0 +1,31 @@ +WEBVTT + +00:00.080 --> 00:03.620 +We're going to have a previous EVO Champion in all of our matches here. + +00:03.903 --> 00:05.240 +This is actually a very similar situation, right? + +00:05.601 --> 00:08.460 +Previous EVO Champion versus a player from another country + +00:08.661 --> 00:12.060 +that has been proven to be one of the best players in the entire world + +00:12.783 --> 00:13.360 +time and time again. + +00:13.601 --> 00:15.460 +So this is a great one to see. + +00:16.071 --> 00:21.650 +And Sonic Fox, you know, kind of, I think a lot of people were unsure how they were going to be playing throughout today. + +00:22.311 --> 00:32.450 +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. + +00:32.583 --> 00:33.220 +Well, I would say. + +00:33.843 --> 00:36.179 +Yeah, SonicFox, it's interesting because when \ No newline at end of file diff --git a/packages/video-player/javascript/modules/subtitles/samples/fr.vtt b/packages/video-player/javascript/modules/subtitles/samples/fr.vtt new file mode 100644 index 0000000..3d276b6 --- /dev/null +++ b/packages/video-player/javascript/modules/subtitles/samples/fr.vtt @@ -0,0 +1,31 @@ +WEBVTT + +00:00.080 --> 00:03.620 +Nous allons avoir un ancien champion de l'EVO dans tous nos matchs ici. + +00:03.903 --> 00:05.240 +C'est en fait une situation très similaire, n'est-ce pas ? + +00:05.601 --> 00:08.460 +Ancien champion de l'EVO contre un joueur d'un autre pays + +00:08.661 --> 00:12.060 +qui a prouvé à maintes reprises qu'il était l'un des meilleurs joueurs du monde entier + +00:12.783 --> 00:13.360 +encore et encore. + +00:13.601 --> 00:15.460 +C'est donc un excellent match à voir. + +00:16.071 --> 00:21.650 +Et Sonic Fox, vous savez, en quelque sorte, je pense que beaucoup de gens n'étaient pas sûrs de la façon dont ils allaient jouer aujourd'hui. + +00:22.311 --> 00:32.450 +Il y a beaucoup de jeux sur lesquels se concentrer à cet EVO, y compris Skullgirls, bien sûr, qui est, Sonic Fox est comme éprouvé, mais évidemment à un match du top huit des vainqueurs, donc ils se débrouillent plutôt bien. + +00:32.583 --> 00:33.220 +Eh bien, je dirais. + +00:33.843 --> 00:36.179 +Ouais, SonicFox, c'est intéressant parce que quand \ No newline at end of file diff --git a/packages/video-player/javascript/modules/subtitles/samples/sample.transcript b/packages/video-player/javascript/modules/subtitles/samples/sample.transcript new file mode 100644 index 0000000..0a2756a --- /dev/null +++ b/packages/video-player/javascript/modules/subtitles/samples/sample.transcript @@ -0,0 +1,747 @@ +{ + "transcript": [ + { + "transcript": "If everybody say, this is going to be very tough, I'm very interested in that tough question.", + "confidence": 0.9951172, + "words": [ + { + "word": "If", + "start": 0.48, + "end": 0.72 + }, + { + "word": "everybody", + "start": 0.72, + "end": 1.32 + }, + { + "word": "say,", + "start": 1.32, + "end": 1.68 + }, + { + "word": "this", + "start": 1.68, + "end": 2 + }, + { + "word": "is", + "start": 2, + "end": 2.24 + }, + { + "word": "going", + "start": 2.24, + "end": 2.44 + }, + { + "word": "to", + "start": 2.44, + "end": 2.56 + }, + { + "word": "be", + "start": 2.56, + "end": 2.68 + }, + { + "word": "very", + "start": 2.68, + "end": 2.92 + }, + { + "word": "tough,", + "start": 2.92, + "end": 3.36 + }, + { + "word": "I'm", + "start": 3.68, + "end": 4.32 + }, + { + "word": "very", + "start": 4.4, + "end": 4.8 + }, + { + "word": "interested", + "start": 4.8, + "end": 5.32 + }, + { + "word": "in", + "start": 5.32, + "end": 5.48 + }, + { + "word": "that", + "start": 5.48, + "end": 5.64 + }, + { + "word": "tough", + "start": 5.64, + "end": 5.92 + }, + { + "word": "question.", + "start": 5.92, + "end": 6.24 + } + ] + }, + { + "transcript": "And I pick up and say, how can we do in a different way?", + "confidence": 0.76464844, + "words": [ + { + "word": "And", + "start": 6.32, + "end": 6.6 + }, + { + "word": "I", + "start": 6.6, + "end": 6.76 + }, + { + "word": "pick", + "start": 6.76, + "end": 6.96 + }, + { + "word": "up", + "start": 6.96, + "end": 7.28 + }, + { + "word": "and", + "start": 7.28, + "end": 7.6 + }, + { + "word": "say,", + "start": 7.6, + "end": 7.92 + }, + { + "word": "how", + "start": 8.08, + "end": 8.44 + }, + { + "word": "can", + "start": 8.44, + "end": 8.72 + }, + { + "word": "we", + "start": 8.72, + "end": 8.92 + }, + { + "word": "do", + "start": 8.92, + "end": 9.16 + }, + { + "word": "in", + "start": 9.16, + "end": 9.4 + }, + { + "word": "a", + "start": 9.4, + "end": 9.52 + }, + { + "word": "different", + "start": 9.52, + "end": 9.76 + }, + { + "word": "way?", + "start": 9.76, + "end": 10.12 + } + ] + }, + { + "transcript": "Because nothing is easy, nothing is free.", + "confidence": 0.9609375, + "words": [ + { + "word": "Because", + "start": 10.12, + "end": 10.48 + }, + { + "word": "nothing", + "start": 10.88, + "end": 11.44 + }, + { + "word": "is", + "start": 11.44, + "end": 11.84 + }, + { + "word": "easy,", + "start": 11.84, + "end": 12.48 + }, + { + "word": "nothing", + "start": 12.8, + "end": 13.28 + }, + { + "word": "is", + "start": 13.28, + "end": 13.56 + }, + { + "word": "free.", + "start": 13.56, + "end": 13.92 + } + ] + }, + { + "transcript": "If you want to be successful, you have to think different, you have to do different.", + "confidence": 0.99902344, + "words": [ + { + "word": "If", + "start": 14.64, + "end": 14.92 + }, + { + "word": "you", + "start": 14.92, + "end": 15.08 + }, + { + "word": "want", + "start": 15.08, + "end": 15.28 + }, + { + "word": "to", + "start": 15.28, + "end": 15.44 + }, + { + "word": "be", + "start": 15.44, + "end": 15.56 + }, + { + "word": "successful,", + "start": 15.56, + "end": 16.32 + }, + { + "word": "you", + "start": 16.72, + "end": 17.08 + }, + { + "word": "have", + "start": 17.08, + "end": 17.32 + }, + { + "word": "to", + "start": 17.32, + "end": 17.56 + }, + { + "word": "think", + "start": 17.56, + "end": 17.92 + }, + { + "word": "different,", + "start": 17.92, + "end": 18.32 + }, + { + "word": "you", + "start": 19.44, + "end": 19.76 + }, + { + "word": "have", + "start": 19.76, + "end": 19.96 + }, + { + "word": "to", + "start": 19.96, + "end": 20.16 + }, + { + "word": "do", + "start": 20.16, + "end": 20.44 + }, + { + "word": "different.", + "start": 20.44, + "end": 20.8 + } + ] + }, + { + "transcript": "You have to pay the price.", + "confidence": 0.9975586, + "words": [ + { + "word": "You", + "start": 21.76, + "end": 22.16 + }, + { + "word": "have", + "start": 22.24, + "end": 22.56 + }, + { + "word": "to", + "start": 22.56, + "end": 22.84 + }, + { + "word": "pay", + "start": 22.84, + "end": 23.12 + }, + { + "word": "the", + "start": 23.12, + "end": 23.4 + }, + { + "word": "price.", + "start": 23.4, + "end": 23.76 + } + ] + }, + { + "transcript": "Yeah, we are lucky.", + "confidence": 0.93098956, + "words": [ + { + "word": "Yeah,", + "start": 29.76, + "end": 30.12 + }, + { + "word": "we", + "start": 30.12, + "end": 30.28 + }, + { + "word": "are", + "start": 30.28, + "end": 30.44 + }, + { + "word": "lucky.", + "start": 30.44, + "end": 30.96 + } + ] + }, + { + "transcript": "We work much harder than most of the people.", + "confidence": 0.9970703, + "words": [ + { + "word": "We", + "start": 32.14, + "end": 32.38 + }, + { + "word": "work", + "start": 32.78, + "end": 33.18 + }, + { + "word": "much", + "start": 33.58, + "end": 33.98 + }, + { + "word": "harder", + "start": 33.98, + "end": 34.54 + }, + { + "word": "than", + "start": 34.54, + "end": 34.86 + }, + { + "word": "most", + "start": 34.86, + "end": 35.1 + }, + { + "word": "of", + "start": 35.1, + "end": 35.3 + }, + { + "word": "the", + "start": 35.3, + "end": 35.46 + }, + { + "word": "people.", + "start": 35.46, + "end": 35.74 + } + ] + }, + { + "transcript": "We never sleep well and sound in the evening.", + "confidence": 0.9921875, + "words": [ + { + "word": "We", + "start": 36.46, + "end": 36.82 + }, + { + "word": "never", + "start": 36.82, + "end": 37.18 + }, + { + "word": "sleep", + "start": 37.18, + "end": 37.66 + }, + { + "word": "well", + "start": 37.66, + "end": 38.02 + }, + { + "word": "and", + "start": 38.02, + "end": 38.3 + }, + { + "word": "sound", + "start": 38.3, + "end": 38.7 + }, + { + "word": "in", + "start": 38.7, + "end": 38.86 + }, + { + "word": "the", + "start": 38.86, + "end": 38.98 + }, + { + "word": "evening.", + "start": 38.98, + "end": 39.5 + } + ] + }, + { + "transcript": "I Travel last year 867 hours a year in the flat plane.", + "confidence": 0.9995117, + "words": [ + { + "word": "I", + "start": 40.38, + "end": 40.74 + }, + { + "word": "Travel", + "start": 40.74, + "end": 41.34 + }, + { + "word": "last", + "start": 41.58, + "end": 41.9 + }, + { + "word": "year", + "start": 41.9, + "end": 42.22 + }, + { + "word": "867", + "start": 42.54, + "end": 44.78 + }, + { + "word": "hours", + "start": 44.94, + "end": 45.34 + }, + { + "word": "a", + "start": 45.34, + "end": 45.66 + }, + { + "word": "year", + "start": 45.66, + "end": 45.98 + }, + { + "word": "in", + "start": 46.22, + "end": 46.5 + }, + { + "word": "the", + "start": 46.5, + "end": 46.74 + }, + { + "word": "flat", + "start": 46.74, + "end": 47.22 + }, + { + "word": "plane.", + "start": 47.22, + "end": 47.66 + } + ] + }, + { + "transcript": "In the plane.", + "confidence": 0.97753906, + "words": [ + { + "word": "In", + "start": 47.979, + "end": 48.26 + }, + { + "word": "the", + "start": 48.26, + "end": 48.46 + }, + { + "word": "plane.", + "start": 48.46, + "end": 48.94 + } + ] + }, + { + "transcript": "I'm working hard.", + "confidence": 0.9029948, + "words": [ + { + "word": "I'm", + "start": 49.34, + "end": 49.74 + }, + { + "word": "working", + "start": 49.74, + "end": 50.06 + }, + { + "word": "hard.", + "start": 50.06, + "end": 50.46 + } + ] + }, + { + "transcript": "My team working very hard.", + "confidence": 0.9995117, + "words": [ + { + "word": "My", + "start": 50.78, + "end": 51.18 + }, + { + "word": "team", + "start": 51.18, + "end": 51.58 + }, + { + "word": "working", + "start": 51.98, + "end": 52.38 + }, + { + "word": "very", + "start": 52.38, + "end": 52.74 + }, + { + "word": "hard.", + "start": 52.74, + "end": 53.1 + } + ] + }, + { + "transcript": "18 years.", + "confidence": 0.99072, + "words": [ + { + "word": "18", + "start": 53.82, + "end": 54.22 + }, + { + "word": "years.", + "start": 54.22, + "end": 54.62 + } + ] + }, + { + "transcript": "We work like a normal company.", + "confidence": 0.99609375, + "words": [ + { + "word": "We", + "start": 54.78, + "end": 55.14 + }, + { + "word": "work", + "start": 55.14, + "end": 55.5 + }, + { + "word": "like", + "start": 55.58, + "end": 55.9 + }, + { + "word": "a", + "start": 55.9, + "end": 56.1 + }, + { + "word": "normal", + "start": 56.1, + "end": 56.5 + }, + { + "word": "company.", + "start": 56.5, + "end": 56.86 + } + ] + }, + { + "transcript": "70 years, day and night.", + "confidence": 0.8833, + "words": [ + { + "word": "70", + "start": 57.82, + "end": 58.22 + }, + { + "word": "years,", + "start": 58.3, + "end": 58.7 + }, + { + "word": "day", + "start": 59.42, + "end": 59.82 + }, + { + "word": "and", + "start": 59.9, + "end": 60.26 + }, + { + "word": "night.", + "start": 60.26, + "end": 60.62 + } + ] + }, + { + "transcript": "Nothing is free, nothing is easy.", + "confidence": 0.99975586, + "words": [ + { + "word": "Nothing", + "start": 61.34, + "end": 61.98 + }, + { + "word": "is", + "start": 62.38, + "end": 62.78 + }, + { + "word": "free,", + "start": 62.78, + "end": 63.18 + }, + { + "word": "nothing", + "start": 64.36, + "end": 64.76 + }, + { + "word": "is", + "start": 64.76, + "end": 65.12 + }, + { + "word": "easy.", + "start": 65.12, + "end": 65.8 + } + ] + } + ], + "languageCode": "en", + "paragraphs": [ + { + "text": "If everybody say, this is going to be very tough, I'm very interested in that tough question. And I pick up and say, how can we do in a different way? Because nothing is easy, nothing is free. If you want to be successful, you have to think different, you have to do different. You have to pay the price.", + "start": 0.48, + "end": 23.76, + "confidence": 0.9951172 + }, + { + "text": "I live up by 18 years to today's size. Yeah, we are lucky. We work much harder than most of the people. We never sleep well and sound in the evening. I Travel last year 867 hours a year in the flat plane.", + "start": 24.88, + "end": 47.66, + "confidence": 0.45092773 + }, + { + "text": "In the plane. I'm working hard. My team working very hard. 18 years. We work like a normal company.", + "start": 47.979, + "end": 56.86, + "confidence": 0.97753906 + }, + { + "text": "70 years, day and night. Nothing is free, nothing is easy.", + "start": 57.82, + "end": 65.8, + "confidence": 0.8833 + } + ] + } \ No newline at end of file diff --git a/packages/video-player/javascript/modules/subtitles/subtitles.css b/packages/video-player/javascript/modules/subtitles/subtitles.css new file mode 100644 index 0000000..8017de8 --- /dev/null +++ b/packages/video-player/javascript/modules/subtitles/subtitles.css @@ -0,0 +1,9 @@ +.vjs-text-track-display .vjs-word-highlight { + color: var(--subtitle-highlight-color, #FFD54F); +} + +.vjs-highlight-color-setting .vjs-highlight-color { + display: flex; + flex-direction: column; + gap: 0.5em; +} \ No newline at end of file diff --git a/packages/video-player/javascript/modules/subtitles/subtitles.ts b/packages/video-player/javascript/modules/subtitles/subtitles.ts new file mode 100644 index 0000000..326213a --- /dev/null +++ b/packages/video-player/javascript/modules/subtitles/subtitles.ts @@ -0,0 +1,861 @@ +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 TextTrack from 'video.js/dist/types/tracks/text-track'; +import { languageCodes } from '../../interfaces'; +import type { AutoGeneratedTextTrackOptions, RemoteTextTrackOptions, SourceOptions, TextTrackOptions } from '../../interfaces'; +import { filterTrQueryParam } from '../../utils'; + +declare module 'video.js' { + interface HtmlTrackElement { + track: TextTrack; + } +} + +interface CaptionData { + startTime: number; + endTime: number; + text: string; +} + +interface SubtitleTrackData { + captions: CaptionData[]; + languageCode: string; + label: string; + default: boolean; +} + +interface TimedWord { + word: string; + start: number; + end: number; +} + +interface TranscriptSegment { + transcript: string; + confidence: number; + words: Array; +} + +interface TranscriptParagraph { + text: string; + start: number; + end: number; + confidence: number; +} + +interface TranscriptData { + languageCode: string; + transcript: Array; + paragraphs?: Array; +} + +const DEFAULT_AI_GENERATED_SUBTITLE_LABEL = 'Auto-generated Subtitles'; + +const ALLOWED_TRANSFORM_PARAMS_SUBTITLES = new Set(['so', 'eo', 'du']); + +const MAX_TIME_GAP_SECONDS = 3; +const DEFAULT_MAX_CHARS = 60; +const READY_STATE_HAVE_METADATA = 1; + +// Module-level logger for subtitle functions +let moduleLogger: { warn?: (msg: string) => void; error?: (msg: string) => void } | null = null; + +/** + * Set the logger for subtitle module functions + */ +function setLogger(logger: { warn?: (msg: string) => void; error?: (msg: string) => void }): void { + moduleLogger = logger; +} + +/** + * Log a warning message using module logger or console fallback + */ +function logWarn(message: string): void { + if (moduleLogger?.warn) { + moduleLogger.warn(message); + } else { + console.warn(message); + } +} + +/** + * Log an error message using module logger or console fallback + */ +function logError(message: string): void { + if (moduleLogger?.error) { + moduleLogger.error(message); + } else { + console.error(message); + } +} + +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).autoGenerate === true; +} + +function isTranslate(opts: RemoteTextTrackOptions): opts is AutoGeneratedTextTrackOptions & { translations: Array<{ langCode: string; label?: string; default?: boolean }> } { + return (opts as any).translations && Array.isArray((opts as any).translations); +} + +/** + * Convert language code to a readable language name + * Example: 'en' -> 'English', 'es' -> 'Spanish' + */ +function getLanguageName(langCode: string): string { + const code = langCode.toLowerCase(); + const languageName = languageCodes[code as keyof typeof languageCodes]; + + if (!languageName) { + return langCode; + } + + return languageName; +} + +function disableOtherTracks(player: Player, activeTrack: any): void { + //@ts-ignore + for (const other of Array.from(player.textTracks())) { + if (other !== activeTrack) { + //@ts-ignore + other.mode = 'disabled'; + } + } +} + +/** + * Initialize a function when the player has loaded metadata. + * If metadata is already loaded, executes immediately; otherwise waits for the 'loadedmetadata' event. + */ +async function initWhenReady(player: Player, initFn: () => void | Promise): Promise { + if (player.readyState() >= READY_STATE_HAVE_METADATA) { + await initFn(); + } else { + player.one('loadedmetadata', async () => await initFn()); + } +} + +/** + * Creates captions with word-level highlighting + */ +function createHighlightedCaptions( + wordGroups: Array>, + addCaption: (caption: CaptionData) => void +): void { + wordGroups.forEach(block => { + block.forEach((word, idx) => { + addCaption({ + startTime: word.start, + endTime: word.end, + text: block + .map(w => (w === word ? `${w.word}` : w.word)) + .join(' ') + }); + + if (block[idx + 1] && word.end < block[idx + 1].start) { + addCaption({ + startTime: word.end, + endTime: block[idx + 1].start, + text: block.map(w => w.word).join(' ') + }); + } + }); + }); +} + +/** + * Creates regular captions without word highlighting + */ +function createRegularCaptions( + wordGroups: Array>, + addCaption: (caption: CaptionData) => void, + previousText: string | null = null, + nextText: string | null = null +): void { + wordGroups.forEach(block => { + if (block.length > 0) { + let text = block.map(w => w.word).join(' '); + + // Add context if provided + if (previousText || nextText) { + const contextLines: string[] = []; + if (previousText) contextLines.push(previousText); + contextLines.push(text); + if (nextText) contextLines.push(nextText); + text = contextLines.join('\n'); + } + + addCaption({ + startTime: block[0].start, + endTime: block[block.length - 1].end, + text + }); + } + }); +} + +/** + * Groups words into cues based on character limits and time gaps. + * Uses a soft limit for equal distribution and a hard limit (maxChars) that is never exceeded. + */ +function groupWordsByChars( + words: Array, + sentenceLength: number, + maxChars: number, + maxTimeGap: number +): Array> { + const groups: Array> = []; + + // Calculate soft limit for equal distribution of characters across cues + const divisor = Math.ceil(sentenceLength / maxChars); + const softLimit = Math.floor(sentenceLength / divisor); + + let currentGroup: Array = []; + let currentLine = ""; + let lastEnd = words[0]?.start; + + words.forEach((word, ind) => { + // Validate individual word length + if (word.word.length > maxChars) { + logWarn(`Individual word "${word.word}" is longer than maxChars (${maxChars})`); + } + + const gap = word.start - lastEnd; + const token = (currentLine ? " " : "") + word.word; + const newLineLength = (currentLine + token).length; + + // Determine if we should create a new cue + const shouldBreak = + (newLineLength > softLimit && newLineLength <= maxChars && gap <= maxTimeGap) || + gap > maxTimeGap || + newLineLength > maxChars; + + if (shouldBreak && currentGroup.length > 0 && newLineLength > maxChars) { + // Hard limit exceeded - don't add current word, start new group + groups.push([...currentGroup]); + currentGroup = [word]; + currentLine = word.word; + lastEnd = word.end; + } else if (shouldBreak && currentGroup.length > 0) { + // Soft limit or time gap - add current word and start new group + currentGroup.push(word); + groups.push([...currentGroup]); + currentGroup = []; + currentLine = ""; + lastEnd = ind < words.length - 1 ? words[ind + 1]?.start : word.end; + } else { + // Add word to current group + currentGroup.push(word); + currentLine += token; + lastEnd = word.end; + } + + // Last word of segment + if (ind === words.length - 1 && currentGroup.length > 0) { + groups.push(currentGroup); + } + }); + + return groups; +} + +export function validateRemoteTextTrackOptions(opts: RemoteTextTrackOptions): void { + if (isAutoGenerate(opts)) { + const conflicting = ['src', 'kind', 'srclang'].filter(key => key in opts); + if (conflicting.length > 0) { + throw new Error( + `Cannot specify [\`${conflicting.join('`, `')}\`] when \`autoGenerate\` is true.` + ); + } + + + if (opts.showAutoGenerated !== undefined && typeof opts.showAutoGenerated !== 'boolean') { + throw new Error( + '`showAutoGenerated` must be a boolean.' + ); + } + } + + if (hasSrc(opts) && (opts.maxChars || opts.highlightWords)) { + const srcURL = new URL(opts.src); + const isTranscript = srcURL.pathname.endsWith('.transcript'); + if (!isTranscript) { + throw new Error( + '`src` must be a transcript URL when using `maxChars` or `highlightWords`.' + ); + } + } + + // Validate showAutoGenerated: false requires translations + if (isAutoGenerate(opts) && opts.showAutoGenerated === false) { + if (!isTranslate(opts) || opts.translations.length === 0) { + throw new Error( + '`showAutoGenerated`: false only makes sense when `translations` are provided.' + ); + } + } + + if (isTranslate(opts)) { + if (!isAutoGenerate(opts)) { + throw new Error( + '`translations` can only be used with `autoGenerate`.' + ); + } + + if (opts.maxChars || opts.highlightWords) { + throw new Error( + '`translations` cannot be used with `maxChars` or `highlightWords`.' + ); + } + + if (!Array.isArray(opts.translations)) { + throw new Error('`translations` must be an array.'); + } + + const defaultCount = opts.translations.filter(t => t.default).length + ((opts.default) ? 1 : 0); + if (defaultCount > 1) { + throw new Error('`translations`: only one entry can have `default` set to true.'); + } + + opts.translations.forEach((t, idx) => { + if (!t.langCode || typeof t.langCode !== 'string') { + throw new Error(`\`translations[${idx}].langCode\` is required and must be a string.`); + } + if (!languageCodes[t.langCode.toLowerCase()]) { + throw new Error(`\`translations[${idx}].langCode\` "${t.langCode}" is not supported.`); + } + if (t.label && typeof t.label !== 'string') { + throw new Error(`\`translations[${idx}].label\` must be a string.`); + } + if (t.default && typeof t.default !== 'boolean') { + throw new Error(`\`translations[${idx}].default\` must be a boolean.`); + } + }); + } +} + +async function generateCaptions(params: { + opts: RemoteTextTrackOptions; + transcriptUrl: string; +}): Promise { + let { opts, transcriptUrl } = params; + const maxChars = opts.maxChars ?? DEFAULT_MAX_CHARS; + const highlightWords = opts.highlightWords ?? false; + + const raw = await fetch(transcriptUrl).then(r => { + if (!r.ok) throw new Error('Failed to fetch transcript'); + return r.text(); + }); + + const transcriptData = JSON.parse(raw) as TranscriptData; + + const entries = transcriptData.transcript; + + const parseTranscript = (): CaptionData[] => { + const captions: CaptionData[] = []; + const maxTimeGap = MAX_TIME_GAP_SECONDS; + const addCaption = ({ startTime, endTime, text }: CaptionData) => { + captions.push({ startTime, endTime, text }); + }; + + + // Process each segment + entries.forEach(segment => { + const words = segment.words; + + if (words && words.length > 0) { + const sentenceLength = segment.transcript.length; + const wordGroups = groupWordsByChars(words, sentenceLength, maxChars, maxTimeGap); + + if (highlightWords) { + createHighlightedCaptions(wordGroups, addCaption); + } else { + createRegularCaptions(wordGroups, addCaption); + } + } else { + // Fallback for segments without word-level timing + addCaption({ + startTime: (segment as any).start_time || 0, + endTime: (segment as any).end_time || 0, + text: segment.transcript + }); + } + }); + + return captions; + }; + + const captions = parseTranscript(); + + return { + captions, + languageCode: transcriptData.languageCode || 'en', + label: (isAutoGenerate(opts) && opts.autoGeneratedLabel) ? opts.autoGeneratedLabel : `${getLanguageName(transcriptData.languageCode)} (auto-generated)`, + default: opts.default || false, + }; +} + +async function prepareSubtitleSrc( + input: RemoteTextTrackOptions, + currentSource: SourceOptions, + signerFn?: (src: string) => Promise | undefined +): Promise { + let src: string = ""; + if (!hasSrc(input) && !isAutoGenerate(input)) { + throw new Error('Invalid RemoteTextTrackOptions: src or autoGenerate is required'); + } + + if (hasSrc(input)) { + const url = new URL(input.src); + if (signerFn) { + try { + src = await signerFn(url.toString()); + } catch (err) { + throw new Error(`Failed to sign subtitle URL: ${err instanceof Error ? err.message : String(err)}`); + } + } else { + src = url.toString(); + } + return [src]; + } + + if (isAutoGenerate(input)) { + if (!currentSource.src) { + throw new Error('Cannot generate subtitles: video source URL is missing'); + } + const transcriptUrl = new URL(currentSource.src); + if (transcriptUrl.pathname.endsWith('ik-master.m3u8')) { + transcriptUrl.pathname = transcriptUrl.pathname.replace(/ik-master\.m3u8$/, 'ik-gensubtitle.transcript'); + } else if (transcriptUrl.pathname.endsWith('ik-master.mpd')) { + transcriptUrl.pathname = transcriptUrl.pathname.replace(/ik-master\.mpd$/, 'ik-gensubtitle.transcript'); + } else { + transcriptUrl.pathname = `${transcriptUrl.pathname.replace(/\/$/, '')}/ik-gensubtitle.transcript`; + } + + filterTrQueryParam(transcriptUrl, ALLOWED_TRANSFORM_PARAMS_SUBTITLES); + src = transcriptUrl.toString(); + + let translatedUrls: string[] = []; + if (isTranslate(input)) { + translatedUrls = input.translations.map(t => { + const langCode = t.langCode.toLowerCase(); + const translatedUrl = new URL(transcriptUrl.toString()); + translatedUrl.pathname = translatedUrl.pathname.replace(/\.transcript$/, `.vtt`); + + const existingTr = translatedUrl.searchParams.get('tr'); + if (existingTr) { + translatedUrl.searchParams.set('tr', `${existingTr},lang-${langCode}`); + } else { + translatedUrl.searchParams.set('tr', `lang-${langCode}`); + } + + return translatedUrl.toString(); + }); + } + + if (signerFn) { + try { + const signer = signerFn; + [src, ...translatedUrls] = await Promise.all([signer(src), ...translatedUrls.map(url => signer(url))]); + } catch (err) { + throw new Error(`Failed to sign subtitle URLs: ${err instanceof Error ? err.message : String(err)}`); + } + } + + return [src, ...translatedUrls]; + } +} + +/** + * Initialize a base auto-generated text track + */ +async function initBaseTrack( + player: Player, + trackEl: HTMLTrackElement, + options: RemoteTextTrackOptions, + transcriptUrl: string +): Promise { + const trackData = await generateCaptions({ + opts: options, + transcriptUrl + }); + + if (!trackData) { + throw new Error('Failed to setup subtitles: no track data returned'); + } + + trackEl.label = trackData.label; + trackEl.srclang = trackData.languageCode; + + //@ts-ignore + if (!trackEl.track) { + throw new Error('Track element does not have an associated text track'); + } + //@ts-ignore + const textTrack = trackEl.track; + textTrack.label = trackData.label; + textTrack.default = trackData.default; + + trackData.captions.forEach(caption => { + textTrack.addCue(new VTTCue(caption.startTime, caption.endTime, caption.text)); + }); + + textTrack.mode = trackData.default ? 'showing' : 'hidden'; + + if (trackData.default) { + disableOtherTracks(player, textTrack); + } + + //@ts-ignore + const ccButton = player.controlBar.getChild('SubsCapsButton') as any; + if (ccButton && typeof ccButton.update === 'function') { + ccButton.update(); + } +} + +/** + * Parse VTT and convert to synthetic transcript structure with equal time per word + */ +function parseVTTToSyntheticTranscript(vttText: string): Array<{ + transcript: string; + words: Array<{ word: string; start: number; end: number }>; +}> { + const segments: Array<{ + transcript: string; + words: Array<{ word: string; start: number; end: number }>; + }> = []; + + // Normalize and split into cue blocks + const rawBlocks = vttText.replace(/\r\n|\r|\n/g, '\n').split(/\n{2,}/); + + for (const block of rawBlocks) { + const lines = block.trim().split('\n'); + + // Find the timing line (contains "-->") + const timeLine = lines.find((l) => /-->/.test(l)); + if (!timeLine) continue; + + // Extract start and end timestamps + const [startRaw, endRaw] = timeLine.split('-->').map((s) => s.trim()); + const startTime = parseVTTTimestamp(startRaw); + const endTime = parseVTTTimestamp(endRaw); + + // Get the text (everything after the timing line) + const timeLineIndex = lines.findIndex((l) => /-->/.test(l)); + const textLines = lines.slice(timeLineIndex + 1); + const text = textLines.join(' ').trim(); + + if (!text || isNaN(startTime) || isNaN(endTime) || endTime <= startTime) { + continue; + } + + // Split text into words + const words = text.split(/\s+/).filter(w => w.length > 0); + + if (words.length === 0) continue; + + // Calculate equal time per word + const duration = endTime - startTime; + const timePerWord = duration / words.length; + + // Create synthetic word-level timing + const syntheticWords = words.map((word, index) => ({ + word: word, + start: startTime + (index * timePerWord), + end: startTime + ((index + 1) * timePerWord) + })); + + segments.push({ + transcript: text, + words: syntheticWords + }); + } + + return segments; +} + +/** + * Parse VTT timestamp to seconds + */ +function parseVTTTimestamp(timestamp: string): number { + // Supports both "HH:MM:SS.mmm" and "MM:SS.mmm" + const parts = timestamp.split(':'); + + if (parts.length === 3) { + // HH:MM:SS.mmm + const hours = parseInt(parts[0], 10); + const minutes = parseInt(parts[1], 10); + const seconds = parseFloat(parts[2].replace(',', '.')); + return hours * 3600 + minutes * 60 + seconds; + } else if (parts.length === 2) { + // MM:SS.mmm + const minutes = parseInt(parts[0], 10); + const seconds = parseFloat(parts[1].replace(',', '.')); + return minutes * 60 + seconds; + } + + return 0; +} + +/** + * Format seconds to VTT timestamp (HH:MM:SS.mmm) + */ +function formatVTTTimestamp(seconds: number): string { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + const hoursStr = hours.toString().padStart(2, '0'); + const minutesStr = minutes.toString().padStart(2, '0'); + const secsStr = secs.toFixed(3).padStart(6, '0'); + return `${hoursStr}:${minutesStr}:${secsStr}`; +} + +/** + * Generate captions from VTT (for translations with maxChars support) + */ +async function generateCaptionsFromVTT(params: { + vttUrl: string; + maxChars: number; + label: string; + defaultTrack: boolean; +}): Promise { + const { vttUrl, maxChars, label, defaultTrack } = params; + + try { + const response = await fetch(vttUrl); + if (!response.ok) { + throw new Error(`Failed to fetch VTT: ${response.status}`); + } + const vttText = await response.text(); + + // Convert VTT to synthetic transcript + const syntheticSegments = parseVTTToSyntheticTranscript(vttText); + + if (syntheticSegments.length === 0) { + return null; + } + + // Use the same caption generation logic as base subtitles + const captions: CaptionData[] = []; + const maxTimeGap = MAX_TIME_GAP_SECONDS; + + // Generate word groups from segments and create captions directly + syntheticSegments.forEach((segment) => { + const words = segment.words; + const sentenceLength = segment.transcript.length; + const wordGroups = groupWordsByChars(words, sentenceLength, maxChars, maxTimeGap); + + // Create captions directly from word groups + wordGroups.forEach(group => { + if (group.length > 0) { + captions.push({ + startTime: group[0].start, + endTime: group[group.length - 1].end, + text: group.map(w => w.word).join(' ') + }); + } + }); + }); + + // Also log as VTT format for easier reading + const vttOutput = captions.map((caption, index) => { + const startTime = formatVTTTimestamp(caption.startTime); + const endTime = formatVTTTimestamp(caption.endTime); + return `${index + 1}\n${startTime} --> ${endTime}\n${caption.text}`; + }).join('\n\n'); + // console.log('Final captions as VTT:\nWEBVTT\n\n' + vttOutput); + + return { + captions, + languageCode: 'unknown', // Will be set by caller + label, + default: defaultTrack, + }; + } catch (error) { + logError(`Failed to generate captions from VTT: ${error instanceof Error ? error.message : String(error)}`); + return null; + } +} + +/** + * Create translation tracks + */ +function createTranslationTracks( + player: Player, + options: AutoGeneratedTextTrackOptions & { translations: Array<{ langCode: string; label?: string; default?: boolean }> }, + translationUrls: string[] +): void { + const maxChars = DEFAULT_MAX_CHARS; + + options.translations.forEach((t, index) => { + const langCode = t.langCode.toLowerCase(); + const languageName = getLanguageName(langCode); + const label = t.label || `${languageName} (auto-generated)`; + const vttUrl = translationUrls[index]; + + if (vttUrl) { + const translatedTrack = player.addRemoteTextTrack({ + kind: 'subtitles', + src: '', + srclang: langCode, + label, + default: t.default ?? false, + }, false) as HTMLTrackElement; + + //@ts-ignore + if (!translatedTrack.track) { + return; + } + + //@ts-ignore + const translatedTextTrack = translatedTrack.track; + + // Load and process VTT with maxChars support + const initTranslatedTrack = async () => { + try { + const trackData = await generateCaptionsFromVTT({ + vttUrl, + maxChars, + label, + defaultTrack: t.default ?? false, + }); + + if (!trackData) { + throw new Error('Failed to generate captions from VTT'); + } + + // Set language code + translatedTextTrack.language = langCode; + + // Add cues to the track + trackData.captions.forEach(caption => { + translatedTextTrack.addCue(new VTTCue(caption.startTime, caption.endTime, caption.text)); + }); + + // Set mode + if (t.default) { + //@ts-ignore + translatedTextTrack.mode = 'showing'; + disableOtherTracks(player, translatedTextTrack); + } else { + //@ts-ignore + translatedTextTrack.mode = 'disabled'; + } + } catch (err) { + logError(`Failed to load translated track for ${langCode}: ${err instanceof Error ? err.message : String(err)}`); + translatedTrack.label = `${label} (failed to load)`; + } + }; + + // Initialize track when player is ready + initWhenReady(player, initTranslatedTrack); + } + }); + + //@ts-ignore + const ccButton = player.controlBar.getChild('SubsCapsButton') as any; + if (ccButton && typeof ccButton.update === 'function') { + ccButton.update(); + } +} + +/** + * Internal function to set text tracks from SourceOptions + * This is called from player.src() to handle all text tracks at once + */ +export async function setTextTracks( + player: Player, + textTracks: RemoteTextTrackOptions[], + currentSource: SourceOptions, + signerFn?: (src: string) => Promise | undefined +): Promise { + // Initialize module logger with player's logger + setLogger(player.log); + + for (const options of textTracks) { + try { + // Validate the options + validateRemoteTextTrackOptions(options); + + // Handle regular tracks with src (non-transcript) + if (hasSrc(options)) { + const srcURL = new URL(options.src); + const isTranscript = srcURL.pathname.endsWith('.transcript'); + + if (!isTranscript) { + // Pass through to original addRemoteTextTrack for regular VTT/SRT files + const [preparedSrc] = await prepareSubtitleSrc(options, currentSource, signerFn); + + player.addRemoteTextTrack({ + kind: options.kind || 'subtitles', + src: preparedSrc, + srclang: options.srclang || 'en', + label: options.label || 'Subtitles', + default: options.default ?? false, + }, false); + continue; + } + } + + // Handle auto-generated or transcript files + if (isAutoGenerate(options) || (hasSrc(options) && new URL(options.src).pathname.endsWith('.transcript'))) { + const showAutoGenerated = isAutoGenerate(options) ? (options.showAutoGenerated !== false) : true; + const hasTranslations = isTranslate(options); + + // Prepare subtitle sources (base + translations) + const srcUrls: string[] = await prepareSubtitleSrc(options, currentSource, signerFn); + if (srcUrls.length === 0 || !srcUrls[0]) { + throw new Error('Failed to prepare subtitle source URLs'); + } + + const baseTranscriptUrl = srcUrls[0]; + const translationUrls = srcUrls.slice(1); + + // Create base auto-generated track only if showAutoGenerated is true + if (showAutoGenerated) { + const label = isAutoGenerate(options) + ? (options.autoGeneratedLabel || DEFAULT_AI_GENERATED_SUBTITLE_LABEL) + : (options.label || DEFAULT_AI_GENERATED_SUBTITLE_LABEL); + + const trackEl = player.addRemoteTextTrack({ + kind: 'subtitles', + src: '', + srclang: 'en', + label: label, + default: options.default ?? false, + }, false) as HTMLTrackElement; + + // Initialize base track + const initTrack = async () => { + try { + await initBaseTrack(player, trackEl, options, baseTranscriptUrl); + } catch (err) { + logError(`Base subtitle setup failed: ${err instanceof Error ? err.message : String(err)}`); + trackEl.label = 'Failed to load subtitles'; + } + }; + + await initWhenReady(player, initTrack); + } + + // Create translation tracks + if (hasTranslations) { + const initTranslations = () => { + try { + createTranslationTracks(player, options, translationUrls); + } catch (err) { + logError(`Translation tracks setup failed: ${err instanceof Error ? err.message : String(err)}`); + } + }; + + initWhenReady(player, initTranslations); + } + } + } catch (err) { + logError(`Failed to setup text track: ${err instanceof Error ? err.message : String(err)}`); + } + } +} \ No newline at end of file diff --git a/packages/video-player/javascript/modules/subtitles/track-settings-extension.ts b/packages/video-player/javascript/modules/subtitles/track-settings-extension.ts new file mode 100644 index 0000000..e1591c3 --- /dev/null +++ b/packages/video-player/javascript/modules/subtitles/track-settings-extension.ts @@ -0,0 +1,174 @@ +import type Player from 'video.js/dist/types/player'; + +function getStoredHighlightColor(player: Player): string | null { + try { + const key = `vjs-highlight-color-${player.id()}`; + return localStorage.getItem(key); + } catch (e) { + return null; + } +} + +function storeHighlightColor(player: Player, color: string): void { + try { + const key = `vjs-highlight-color-${player.id()}`; + localStorage.setItem(key, color); + } catch (e) { + // localStorage might not be available + } +} + +function setHighlightColor(player: Player, color: string): void { + const playerEl = player.el(); + if (!playerEl) return; + + (playerEl as HTMLElement).style.setProperty('--subtitle-highlight-color', color); + + const highlightElements = playerEl.querySelectorAll('.vjs-word-highlight'); + highlightElements.forEach((el) => { + (el as HTMLElement).style.color = color; + }); +} + +export function extendTrackSettings(player: Player): void { + const storedColor = getStoredHighlightColor(player); + if (!storedColor) { + setHighlightColor(player, '#FFD54F'); + storeHighlightColor(player, '#FFD54F'); + } else { + setHighlightColor(player, storedColor); + } + + if (!player.readyState || player.readyState() < 1) { + player.ready(() => { + setupTrackSettingsExtension(player); + }); + } else { + setupTrackSettingsExtension(player); + } +} + +function setupTrackSettingsExtension(player: Player): void { + const textTrackSettings = player.getChild('TextTrackSettings') as any; + + if (!textTrackSettings) { + player.log.warn('[TrackSettingsExtension] TextTrackSettings component not found'); + return; + } + + const originalUpdateDisplay = textTrackSettings.updateDisplay; + + textTrackSettings.updateDisplay = function() { + if (originalUpdateDisplay) { + originalUpdateDisplay.call(this); + } + + addHighlightColorFieldset(this, player); + }; + + const modal = textTrackSettings.el(); + if (modal) { + const observer = new MutationObserver(() => { + if (!modal.classList.contains('vjs-hidden')) { + setTimeout(() => addHighlightColorFieldset(textTrackSettings, player), 100); + } + }); + + observer.observe(modal, { + attributes: true, + attributeFilter: ['class'] + }); + } + + player.one('texttracksettingsshow', () => { + setTimeout(() => addHighlightColorFieldset(textTrackSettings, player), 100); + }); +} + +function addHighlightColorFieldset(settingsComponent: any, player: Player): void { + const modal = settingsComponent.el(); + if (!modal) return; + + const existingFieldset = modal.querySelector('.vjs-highlight-color-setting'); + if (existingFieldset) return; + + const colorsSection = modal.querySelector('.vjs-track-settings-colors'); + if (!colorsSection) { + player.log.warn('[TrackSettingsExtension] Colors section not found'); + return; + } + + const highlightFieldset = document.createElement('fieldset'); + highlightFieldset.className = 'vjs-track-setting vjs-highlight-color-setting'; + + const legend = document.createElement('legend'); + legend.id = `captions-highlight-legend-${player.id()}`; + legend.textContent = 'Word Highlight'; + highlightFieldset.appendChild(legend); + + const colorSpan = document.createElement('span'); + colorSpan.className = 'vjs-highlight-color'; + + const label = document.createElement('label'); + label.id = `captions-highlight-color-${player.id()}`; + label.className = 'vjs-label'; + label.setAttribute('for', `vjs_select_highlight_${player.id()}`); + label.textContent = 'Color'; + + const select = document.createElement('select'); + select.id = `vjs_select_highlight_${player.id()}`; + select.setAttribute('aria-labelledby', `captions-highlight-legend-${player.id()} captions-highlight-color-${player.id()}`); + + const colors = [ + { value: '#FFD54F', label: 'Amber Yellow (Default)' }, + { value: '#FFF', label: 'White' }, + { value: '#000', label: 'Black' }, + { value: '#F00', label: 'Red' }, + { value: '#0F0', label: 'Green' }, + { value: '#00F', label: 'Blue' }, + { value: '#FF0', label: 'Pure Yellow' }, + { value: '#F0F', label: 'Magenta' }, + { value: '#0FF', label: 'Cyan' }, + { value: '#FF6B6B', label: 'Coral' }, + { value: '#4ECDC4', label: 'Turquoise' }, + { value: '#45B7D1', label: 'Sky Blue' }, + { value: '#FFA07A', label: 'Light Salmon' }, + { value: '#98D8C8', label: 'Mint' }, + ]; + + const currentColor = getStoredHighlightColor(player) || '#FFD54F'; + + colors.forEach((color, index) => { + const option = document.createElement('option'); + option.id = `captions-highlight-color-${player.id()}-${color.label.replace(/\s+/g, '')}`; + option.value = color.value; + option.textContent = color.label; + option.setAttribute('aria-labelledby', `captions-highlight-legend-${player.id()} captions-highlight-color-${player.id()} captions-highlight-color-${player.id()}-${color.label.replace(/\s+/g, '')}`); + + if (color.value === currentColor) { + option.selected = true; + } + + select.appendChild(option); + }); + + select.addEventListener('change', (e) => { + const target = e.target as HTMLSelectElement; + const color = target.value; + setHighlightColor(player, color); + storeHighlightColor(player, color); + }); + + colorSpan.appendChild(label); + colorSpan.appendChild(select); + highlightFieldset.appendChild(colorSpan); + + const windowFieldset = colorsSection.querySelector('.vjs-window'); + if (windowFieldset && windowFieldset.parentNode) { + windowFieldset.parentNode.insertBefore(highlightFieldset, windowFieldset.nextSibling); + } else { + colorsSection.appendChild(highlightFieldset); + } + + setHighlightColor(player, currentColor); +} diff --git a/packages/video-player/javascript/styles/index.scss b/packages/video-player/javascript/styles/index.scss new file mode 100644 index 0000000..130877c --- /dev/null +++ b/packages/video-player/javascript/styles/index.scss @@ -0,0 +1,27 @@ +// import videojs css +@use "../../../../node_modules/video.js/dist/video-js.css"; + +// @use all modules +@use "../modules/http-source-selector/plugin.scss"; + +@use "../modules/playlist/styles/playlist-ui.scss"; + +@use "../modules/playlist/styles/present-upcoming.scss"; + +@use "../modules/chapters/chapter.scss"; + +@use "../modules/recommendations-overlay/recommendation-overlay.css"; + +@use "../modules/seek-thumbnails/seek-thumbnails.css"; + +@use "../modules/subtitles/subtitles.css"; + +@use "../modules/shoppable/shoppable.css"; + +@use "../modules/floating-player/floating-player.css"; + +@use "../modules/context-menu/context-menu.css"; + +@use "../modules/logo-button/logo-button.scss"; + +@use "./main.scss" \ No newline at end of file diff --git a/packages/video-player/javascript/styles/main.scss b/packages/video-player/javascript/styles/main.scss new file mode 100644 index 0000000..f07923d --- /dev/null +++ b/packages/video-player/javascript/styles/main.scss @@ -0,0 +1,618 @@ +$opacity-default: 0.8; +$opacity-hover: 1; +$opacity-overlay: 0.4; +$opacity-overlay-hidden: 0; + +$transition-fast: 0.1s; +$transition-base: 0.2s; +$transition-fade: 1s; + +$z-index-controls: 1; +$z-index-control-bar: 6; +$z-index-chapter-tooltip: 4; +$z-index-thumbnail: 5; +$z-index-menu: 7; + +// Spacing (em-based for scalability) +$spacing-control-bar-bottom: 3em; +$spacing-subtitles-default: 4em; +$spacing-subtitles-hidden: 1em; +$spacing-tooltip-gap: 1em; +$spacing-time-tooltip-offset: -1.6em; + +// Spacing (px-based for fixed positioning) +$spacing-thumbnail-default: 88px; +$spacing-thumbnail-with-chapters: 124px; +$spacing-progress-interactive: -0.4rem; +$spacing-progress-interactive-hover: -1.4rem; + +.video-js { + // Color custom properties - can be overridden + --color-accent: #0D9AFF; + --color-base: #000000; + --color-text: #FFFFFF; + --color-white: #FFFFFF; + --color-tooltip-bg: rgba(0, 0, 0, 0.85); + --color-subtitle-bg: rgba(0, 0, 0, 0.8); + --color-seek-feedback-bg: rgba(43, 51, 63, 0.7); + --color-error-bg: #90a0b3; + + &.video-js-skin-light { + --color-base: #FFFFFF; + --color-text: #000000; + } + + overflow: hidden; + + // The base font size controls the size of everything, not just text. + // All dimensions use em-based sizes so that the scale along with the font size. + font-size: 12px; + font-weight: 300; + + &:focus { + outline: none; + } + + .vjs-control, + .vjs-icon-close, + .vjs-volume-bar { + z-index: $z-index-controls; + } + + .vjs-control::before, + .vjs-icon-placeholder::before, + .vjs-time-divider, + .vjs-duration, + .vjs-playback-rate-value { + opacity: $opacity-default; + } + + .vjs-control:hover::before, + .vjs-icon-placeholder:hover::before, + .vjs-time-divider:hover, + .vjs-duration:hover, + .vjs-playback-rate:hover .vjs-playback-rate-value { + opacity: $opacity-hover; + text-shadow: none; + } + + .vjs-fullscreen-control .vjs-icon-placeholder::before { + font-size: 2.2em; + margin-top: -4px; + } + + .vjs-time-control { + padding-left: 0.15em; + padding-right: 0.15em; + width: auto; + font-variant-numeric: tabular-nums; + + >* { + font-size: 90%; + } + } + + .vjs-time-divider { + min-width: 0; + display: block; + } + + .vjs-current-time { + display: block; + } + + .vjs-remaining-time { + display: none; + } + + .vjs-duration { + display: block; + } + + .vjs-time-tooltip { + padding: 0.4em 0.6em; + top: $spacing-time-tooltip-offset; + font-size: 0.8em; + z-index: 3; + } + + .vjs-big-play-button { + font-size: 5em; + width: 1.5em; + height: auto; + border: 0; + margin: 0; + border-radius: 50%; + + left: 50%; + top: 50%; + transform: translateX(-50%) translateY(-50%); + + &:before { + content: ''; + position: relative; + display: block; + width: 100%; + padding-bottom: 100%; + } + + .vjs-icon-placeholder { + display: block; + position: absolute; + top: 30%; + left: 40%; + height: 40%; + width: 30%; + overflow: hidden; + + &:before { + content: ''; + position: absolute; + top: 50%; + left: 100%; + + display: block; + width: 0; + height: 0; + border-left: 300px solid currentColor; + border-top: 200px solid transparent; + border-bottom: 200px solid transparent; + margin-left: -300px; + margin-top: -200px; + } + } + } + + // Init-only big-play-button + &.vjs-big-play-button-init-only.vjs-has-started .vjs-big-play-button { + display: none; + } + + &.vjs-paused .vjs-big-play-button, + &.vjs-paused.vjs-has-started .vjs-big-play-button { + opacity: 1; + visibility: visible; + } + + &.vjs-error .vjs-error-display { + background: var(--color-error-bg); + opacity: 1; + + &:before { + display: none; + } + + .vjs-modal-dialog-content { + font-size: 20px; + font-weight: 500; + text-align: left; + padding: 0 10%; + display: flex; + align-items: center; + + &:before { + content: ''; + width: 34px; + height: 34px; + margin-right: 10px; + background: url('assets/icons/info-circle.svg'); + transform: translateY(-1px); + flex-shrink: 0; + } + } + } + + &.vjs-controls-disabled .vjs-big-play-button, + &.vjs-has-started .vjs-big-play-button, + &.vjs-using-native-controls .vjs-big-play-button, + &.vjs-error .vjs-big-play-button { + transition: + visibility $transition-base, + opacity $transition-base; + display: block; + visibility: hidden; + opacity: 0; + } + + &.vjs-controls-enabled::before { + content: ''; + pointer-events: none; + position: absolute; + bottom: $spacing-control-bar-bottom; + left: 0; + right: 0; + width: 100%; + height: 5rem; + background: linear-gradient(to bottom, transparent 0%, var(--color-base) 100%); + opacity: $opacity-overlay; + z-index: $z-index-controls; + font-size: 120%; + display: none; + } + + &.vjs-has-started::before, + &.vjs-audio-only-mode::before { + display: flex; + transition: opacity $transition-fast; + } + + &.vjs-has-started.vjs-user-inactive.vjs-playing::before { + opacity: $opacity-overlay-hidden; + transition: opacity $transition-fade; + } + + .vjs-control { + width: 2.5em; + } + + .vjs-control::before, + .vjs-icon-placeholder:before { + font-size: 1.8em; + line-height: 1.7; + -moz-osx-font-smoothing: grayscale; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; + } + + // Custom SVG icons for Video.js skip buttons + .vjs-skip-forward-10 .vjs-icon-placeholder::before, + .vjs-icon-forward-10::before, + .vjs-icon-skip-forward-10::before { + content: ''; + background-image: url('assets/icons/forward-10.svg'); + background-size: contain; + background-repeat: no-repeat; + background-position: center; + display: inline-block; + width: 1.8em; + height: 1.8em; + filter: brightness(0) invert(1); + font-size: inherit; + align-self: anchor-center; + } + + .vjs-skip-backward-10 .vjs-icon-placeholder::before, + .vjs-icon-replay-10::before, + .vjs-icon-skip-back-10::before { + content: ''; + background-image: url('assets/icons/replay-10.svg'); + background-size: contain; + background-repeat: no-repeat; + background-position: center; + display: inline-block; + width: 1.8em; + height: 1.8em; + filter: brightness(0) invert(1); + font-size: inherit; + align-self: anchor-center; + } + + .vjs-control-bar { + -webkit-backdrop-filter: blur(10px); + backdrop-filter: blur(10px); + z-index: $z-index-control-bar; + font-size: 120%; + + .vjs-menu-button { + position: relative; + z-index: $z-index-menu; + } + + .vjs-volume-panel { + margin-right: 0.5em; + + &.vjs-volume-panel-horizontal { + max-width: 8em; + } + + .vjs-slider { + background-color: color-mix(in srgb, var(--color-text) 10%, transparent); + box-shadow: 0 0 1px 1px color-mix(in srgb, var(--color-text) 80%, transparent) inset; + } + } + + .vjs-progress-control { + -webkit-backdrop-filter: blur(10px); + backdrop-filter: blur(10px); + position: absolute; + left: 0px; + width: 100%; + height: 3px; + bottom: 100%; + } + + .vjs-progress-holder { + margin: 0; + z-index: $z-index-controls; + height: 100%; + + &::after { + content: ''; + position: absolute; + width: 100%; + left: 0; + right: 0; + top: $spacing-progress-interactive; + bottom: $spacing-progress-interactive; + } + } + + .vjs-progress-control:hover { + z-index: $z-index-control-bar; + + .vjs-progress-holder { + font-size: inherit; + transform: scaleY(2); + transition: transform $transition-base ease-in-out; + } + + .vjs-progress-holder .vjs-time-tooltip { + transform: scaleY(0.5); + font-size: 1em; + } + + .vjs-progress-holder::after { + top: $spacing-progress-interactive-hover; + } + } + + .vjs-load-progress div { + background: none; + } + + .vjs-play-progress { + &::before { + display: none; + } + + .vjs-time-tooltip { + display: none; + } + } + + .vjs-progress-control-events-blocker { + background-color: transparent; + @extend .vjs-progress-control; + } + } + + .vjs-playback-rate-value { + font-size: 1.3em; + line-height: 2.3em; + } + + .vjs-subs-caps-button { + .vjs-captions-menu-item .vjs-menu-item-text .vjs-icon-placeholder { + vertical-align: top; + display: inline-block; + margin-bottom: -0.3em; + } + + >.vjs-icon-placeholder:before { + content: '\f10b' !important; + font-size: 2em; + margin-top: -1.5px; + } + } + + .vjs-menu { + position: absolute; + z-index: $z-index-menu; + + .vjs-menu-content { + max-width: 13em; + width: auto; + padding: 0.2em 0; + overflow-y: auto; + max-height: 20em; + z-index: $z-index-menu; + // Firefox + scrollbar-gutter: stable; + scrollbar-width: thin; + // Chrome, Safari, Edge + &::-webkit-scrollbar { + width: 8px; + height: 8px; + } + &::-webkit-scrollbar-track { + background: transparent; + } + &::-webkit-scrollbar-thumb { + background-color: rgba(0, 0, 0, 0.2); + border-radius: 4px; + &:hover { + background-color: rgba(0, 0, 0, 0.3); + } + } + } + + .vjs-selected { + background: none; + color: var(--color-text); + } + + .vjs-menu-item { + justify-content: left; + text-align: left; + white-space: nowrap; + text-transform: capitalize; + font-size: 0.9em; + padding: 0 1em; + line-height: 2em; + + .vjs-menu-item-text { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; + } + } + + &:has(.vjs-selected) { + .vjs-menu-item { + padding-left: 2em; + + &.vjs-selected { + &:before { + font-weight: 400; + font-style: normal; + content: '✓'; + display: block; + position: absolute; + width: 1em; + margin-left: -1.3em; + font-size: 1.2em; + line-height: 1.6; + } + } + } + } + } + + .vjs-custom-control-spacer { + display: block; + } + + .vjs-spacer { + flex: auto; + } + + .vjs-modal-dialog { + background: black; + opacity: 0.8; + } + + .vjs-playlist-button { + cursor: pointer; + } + + .vjs-text-track-display, + .vjs-present-upcoming { + bottom: $spacing-subtitles-default; + transition: bottom $transition-base ease-in-out; + pointer-events: none; + } + + + .vjs-present-upcoming { + pointer-events: auto; + } + + &.vjs-playing.vjs-user-inactive:not(:hover) { + .vjs-text-track-display, + .vjs-present-upcoming { + bottom: $spacing-subtitles-hidden; + } + } + + .vjs-seek-feedback { + position: absolute; + top: 50%; + transform: translateY(-50%) scale(0.8); + font-size: 2em; + color: var(--color-white); + background-color: var(--color-seek-feedback-bg); + border-radius: 50%; + width: 3em; + height: 3em; + display: flex; + justify-content: center; + align-items: center; + opacity: 0; + pointer-events: none; + transition: opacity $transition-base ease-out, transform $transition-base ease-out; + z-index: $z-index-control-bar; + + &::before { + content: ''; + position: absolute; + width: 1.5em; + height: 1.5em; + background-size: contain; + background-repeat: no-repeat; + background-position: center; + filter: brightness(0) invert(1); + } + + &.is-forward { + left: 75%; + + &::before { + background-image: url('assets/icons/forward-10.svg'); + } + } + + &.is-backward { + left: 25%; + + &::before { + background-image: url('assets/icons/replay-10.svg'); + } + } + + &.is-visible { + opacity: 1; + + &.is-forward, + &.is-backward { + transform: translateY(-50%) translateX(-50%) scale(1); + } + } + } + + .vjs-progress-control { + overflow: visible; + + .vjs-progress-holder { + position: relative; + } + } + + .vjs-http-source-selector { + z-index: $z-index-menu; + + >.vjs-button { + font-size: 1.6em; + margin-right: -12px; + margin-left: -12px; + } + + >.vjs-menu { + // margin-bottom already set in general .vjs-menu rule above + z-index: $z-index-menu; + + .vjs-menu-content { + z-index: $z-index-menu; + } + } + } + + .vjs-http-source-selector>.vjs-button.vjs-icon-cog::before { + opacity: $opacity-default; + } + + .vjs-http-source-selector>.vjs-button.vjs-icon-cog:hover::before { + opacity: $opacity-hover; + } + + .thumbnail-preview { + position: absolute; + bottom: $spacing-thumbnail-default; + z-index: $z-index-thumbnail; + transform: translateX(-50%); + pointer-events: none; + transition: bottom $transition-base ease-out; + + .thumbnail { + border: 2px solid var(--color-white); + border-radius: 5px; + } + } + + &:has(.vjs-chapter-tooltip-container) { + .thumbnail-preview { + bottom: $spacing-thumbnail-with-chapters; + } + } +} \ No newline at end of file diff --git a/packages/video-player/javascript/types/videojs-extensions.d.ts b/packages/video-player/javascript/types/videojs-extensions.d.ts new file mode 100644 index 0000000..ff95cd7 --- /dev/null +++ b/packages/video-player/javascript/types/videojs-extensions.d.ts @@ -0,0 +1,12 @@ +import { IKPlayerOptions } from 'javascript/interfaces'; +import type { ContextMenuUI } from '../modules/context-menu/types'; +import type BasePlayer from 'video.js/dist/types/player'; + +declare module 'video.js' { + export interface Player extends BasePlayer { + imagekitVideoPlayer?: IKPlayerOptions; + httpSourceSelector?: (options?: { default?: string }) => void; + contextmenuUI?: ContextMenuUI; + contextmenuUICleanups_?: Array<() => void>; + } +} diff --git a/packages/video-player/javascript/utils.ts b/packages/video-player/javascript/utils.ts new file mode 100644 index 0000000..e69c1bc --- /dev/null +++ b/packages/video-player/javascript/utils.ts @@ -0,0 +1,614 @@ +import { buildSrc as ikBuild } from '@imagekit/javascript'; +import type { IKPlayerOptions, Transformation } from './interfaces'; +import type { SourceOptions } from './interfaces'; +import type { ABSOptions } from './interfaces'; +import { StreamingResolution } from '@imagekit/javascript/dist/interfaces'; + +const HLS_MASTER_SUFFIX = 'ik-master.m3u8'; +const DASH_MASTER_SUFFIX = 'ik-master.mpd'; +const THUMBNAIL_SUFFIX = 'ik-thumbnail.jpg'; + +const ALLOWED_TRANSFORM_PARAMS_CHAPTERS = new Set(['so', 'eo', 'du']); + +/** + * Filters the 'tr' query parameter to only include allowed transformation parameters. + * Handles chained transformations (separated by :) and normal transformations (separated by ,). + * @param url - The URL to filter + * @param allowedParams - Set of allowed transformation parameter names + * @returns The same URL object with filtered tr parameter (mutated in place) + */ +export function filterTrQueryParam(url: URL, allowedParams: ReadonlySet): void { + const transformationString = url.searchParams.get('tr'); + if (transformationString) { + const filteredChains = transformationString.split(':').map(chain => { + // Split each chain by comma to get individual transformation params + const filteredParams = chain.split(',').filter(param => { + // Extract the key (part before the first '-') + const key = param.split('-')[0]; + return allowedParams.has(key); + }); + return filteredParams.join(','); + }).filter(chain => chain.length > 0); + + if (filteredChains.length > 0) { + url.searchParams.set('tr', filteredChains.join(':')); + } else { + url.searchParams.delete('tr'); + } + } +} +/** + * Prepares a video source by applying ABS suffix, transformations, and signing. + * @param input - String URL or SourceOptions object + * @param opts - ImageKit player options + * @returns Prepared SourceOptions with built and signed URL + */ +export async function prepareSource( + input: string | SourceOptions, + opts: IKPlayerOptions +): Promise { + let source: SourceOptions = + typeof input === 'string' ? { src: input } : { ...input }; + + const { src: finalSrc, transformation: finalTransformations } = + resolveSourceUrlAndTransformations(source, opts); + + source.src = ikBuild({ + src: finalSrc, + urlEndpoint: '', + transformation: finalTransformations, + }); + + if (opts.signerFn) { + try { + source.src = await opts.signerFn(source.src); + } catch (err) { + throw new Error(`Signing failed: ${err}`); + } + } + + return source; +} + +/** + * Normalizes input into a uniform array format. + * @param input - String, SourceOptions, or array of either + * @returns Array of string or SourceOptions + */ +export function normalizeInput( + input: string | SourceOptions | Array +): Array { + if (Array.isArray(input)) return input; + return [input]; +} + + +/** + * Polls a URL until the video is ready or max attempts are reached. + * @param url - The fully-built and signed video URL + * @param maxTries - Maximum number of polling attempts + * @param timeoutMs - Request timeout in milliseconds + * @param fixedDelayMs - Fixed delay between attempts, or undefined for exponential backoff + */ +export async function waitForVideoReady( + url: string, + maxTries: number, + timeoutMs: number, + fixedDelayMs?: number +): Promise { + for (let attempt = 1; attempt <= maxTries; attempt++) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + let res: Response; + try { + res = await fetch(url, { + method: 'HEAD', + signal: controller.signal + }); + } catch (err) { + clearTimeout(timer); + return; + } + clearTimeout(timer); + + const parsedUrl = new URL(res.url); + if (res.redirected && parsedUrl.searchParams.has('tr') && parsedUrl.searchParams.get('tr') === 'orig') { + if (attempt === maxTries) break; + const delay = fixedDelayMs != null + ? fixedDelayMs + : 10000 * Math.pow(2, attempt - 1); + await new Promise(r => setTimeout(r, delay)); + continue; + } else { + return; + } + } + + throw new Error(`Video unavailable after ${maxTries} attempts`); +} + + +/** + * Resolves the final source URL and transformations from input and options. + * Handles ABS configuration (adds suffix and streamingResolutions) and determines final transformations. + * @param input - String URL or SourceOptions + * @param opts - ImageKit player options + * @returns Object with modified src URL and final transformation array + */ +export function resolveSourceUrlAndTransformations( + input: string | SourceOptions, + opts: IKPlayerOptions +): { src: string; transformation: Transformation[] } { + const baseUrl = typeof input === 'string' ? input : input.src; + const url = new URL(baseUrl); + + const absOpts: ABSOptions | undefined = + typeof input === 'object' && input.abs != null + ? input.abs + : opts.abs; + + const existingTransforms: Transformation[] = + (typeof input === 'object' && input.transformation) ? input.transformation : (opts.transformation || []); + + let finalTransformations: Transformation[] = existingTransforms; + + 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 += `/${HLS_MASTER_SUFFIX}`; + } else if (absOpts.protocol === 'dash') { + url.pathname += `/${DASH_MASTER_SUFFIX}`; + } + + finalTransformations = [...existingTransforms, { streamingResolutions: absOpts.sr.map(res => res as unknown as StreamingResolution) }]; + } + + if(finalTransformations.length > 0 && url.searchParams.get('tr') !== null) { + url.searchParams.delete('tr'); + } + + return { + src: url.toString(), + transformation: finalTransformations, + }; +} + +/** + * Builds a poster URL from a video source. + * Generates default thumbnail URL if no poster is provided, or uses custom poster if specified. + * @param input - Source options with video URL + * @param opts - ImageKit player options + * @returns Fully built and signed poster image URL + */ +export async function preparePosterSrc( + input: SourceOptions, + opts: IKPlayerOptions +): Promise { + let videoSrcUrl = input.src; + let posterSrcUrl: string; + + const url = new URL(videoSrcUrl); + + if (url.pathname.endsWith(HLS_MASTER_SUFFIX)) { + url.pathname = url.pathname.replace(new RegExp(`${HLS_MASTER_SUFFIX}$`), THUMBNAIL_SUFFIX); + } else if (url.pathname.endsWith(DASH_MASTER_SUFFIX)) { + url.pathname = url.pathname.replace(new RegExp(`${DASH_MASTER_SUFFIX}$`), THUMBNAIL_SUFFIX); + } else { + url.pathname = `${url.pathname.replace(/\/$/, '')}/${THUMBNAIL_SUFFIX}`; + } + // strip all transformation parameters + url.searchParams.delete('tr'); + posterSrcUrl = url.toString(); + + if (input.poster && (input.poster.src || input.poster.transformation)) { + posterSrcUrl = ikBuild({ + src: input.poster.src ?? url.toString() + `/${THUMBNAIL_SUFFIX}`, + urlEndpoint: '', + transformation: input.poster.transformation!, + }); + } + + if (opts.signerFn) { + try { + posterSrcUrl = await opts.signerFn(posterSrcUrl); + } catch (err) { + throw new Error(`Signing failed: ${err}`); + } + } + + return posterSrcUrl; +} + + +/** + * Builds a seek thumbnail VTT URL from a video source. + * @param input - Source options with video URL + * @param opts - ImageKit player options + * @returns Fully built and signed seek thumbnail VTT URL + */ +export async function prepareSeekThumbnailVttSrc( + input: SourceOptions, + opts: IKPlayerOptions +): Promise { + let videoSrcUrl = input.src; + let seekThumbnailVttSrc: string; + + const url = new URL(videoSrcUrl); + url.pathname = `${url.pathname.replace(/\/$/, '')}/ik-seek-thumbnail-track.vtt`; + seekThumbnailVttSrc = url.toString(); + + if (opts.signerFn) { + try { + seekThumbnailVttSrc = await opts.signerFn(seekThumbnailVttSrc); + } catch (err) { + throw new Error(`Signing failed: ${err}`); + } + } + + return seekThumbnailVttSrc; +} + +/** + * Builds a chapters VTT URL from a video source. + * Also generates translated chapter URLs if translations are provided in textTracks. + * @param input - Source options with video URL + * @param opts - ImageKit player options + * @returns Object containing baseUrl and translatedUrls map + */ +export async function prepareChaptersVttSrc( + input: SourceOptions, + opts: IKPlayerOptions +): Promise<{ baseUrl: string; translatedUrls: Map }> { + let videoSrcUrl = input.src; + let baseUrl: string; + + const url = new URL(videoSrcUrl); + + if (url.pathname.endsWith(HLS_MASTER_SUFFIX)) { + url.pathname = url.pathname.replace(new RegExp(`${HLS_MASTER_SUFFIX}$`), 'ik-genchapter.vtt'); + } else if (url.pathname.endsWith(DASH_MASTER_SUFFIX)) { + url.pathname = url.pathname.replace(new RegExp(`${DASH_MASTER_SUFFIX}$`), 'ik-genchapter.vtt'); + } else { + url.pathname = `${url.pathname.replace(/\/$/, '')}/ik-genchapter.vtt`; + } + + filterTrQueryParam(url, ALLOWED_TRANSFORM_PARAMS_CHAPTERS); + + baseUrl = ikBuild({ + src: url.toString(), + urlEndpoint: '', + transformation: [], + }); + + const translatedUrls = new Map(); + + // Check if user has translation options in textTracks + if (input.textTracks) { + for (const textTrack of input.textTracks) { + if ('translations' in textTrack && Array.isArray(textTrack.translations)) { + // Generate translated chapter URLs for each translation + for (const translation of textTrack.translations) { + const langCode = translation.langCode.toLowerCase(); + const translatedChapterUrl = new URL(baseUrl); + + // Add translation parameter, same as subtitle logic + const existingTr = translatedChapterUrl.searchParams.get('tr'); + if (existingTr) { + translatedChapterUrl.searchParams.set('tr', `${existingTr},lang-${langCode}`); + } else { + translatedChapterUrl.searchParams.set('tr', `lang-${langCode}`); + } + + let finalUrl = translatedChapterUrl.toString(); + + // Sign the URL if signerFn is provided + if (opts.signerFn) { + try { + finalUrl = await opts.signerFn(finalUrl); + } catch (err) { + console.error(`Failed to sign translated chapter URL for ${langCode}:`, err); + continue; + } + } + + translatedUrls.set(langCode, finalUrl); + } + } + } + } + + // sign the base URL if signerFn is provided + if (opts.signerFn) { + try { + baseUrl = await opts.signerFn(baseUrl); + } catch (err) { + throw new Error(`Signing failed: ${err}`); + } + } + + return { baseUrl, translatedUrls }; +} + +/** + * Checks if transformations are allowed with ABS mode. + * Forbidden parameters: width, height, aspectRatio, format, videoCodec, audioCodec, quality. + * @param transformations - Array of transformation objects to validate + * @returns True if all transformations are allowed with ABS + */ +export function isTransformationAllowedWithABS( + transformations: Transformation[] +): boolean { + const forbiddenProps: Array = [ + 'width', + 'height', + 'aspectRatio', + 'format', + 'videoCodec', + 'audioCodec', + 'quality', + ]; + + const forbiddenRaw = /\b(?:w|h|ar|f|vc|ac|q)-/; + + for (const step of transformations) { + for (const prop of forbiddenProps) { + if (step[prop] !== undefined) { + return false; + } + } + + if (typeof step.raw === 'string' && forbiddenRaw.test(step.raw)) { + return false; + } + } + + return true; +} + +/** + * Runtime-validates IKPlayerOptions, ensuring all properties conform to expected types/ranges. + * Throws an Error on the first violation. + * @param opts - ImageKit player options to validate + */ +export function validateIKPlayerOptions( + opts: IKPlayerOptions +): asserts opts is Required { + if (typeof opts.imagekitId !== 'string' || opts.imagekitId.trim() === '') { + throw new Error('`imagekitId` is required and must be a non-empty string.'); + } + + if ( + opts.floatingWhenNotVisible != null && + opts.floatingWhenNotVisible !== 'left' && + opts.floatingWhenNotVisible !== 'right' + ) { + throw new Error("`floatingWhenNotVisible` must be 'left', 'right', or null."); + } + + if (opts.hideContextMenu != null && typeof opts.hideContextMenu !== 'boolean') { + throw new Error('`hideContextMenu` must be a boolean.'); + } + + 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.'); + } + } + } + + if (opts.seekThumbnails != null && typeof opts.seekThumbnails !== 'boolean') { + throw new Error('`seekThumbnails` must be a boolean.'); + } + + 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.`); + } + }); + } + + 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.' + ); + } + } + + if ( + opts.maxTries != null && + (!Number.isInteger(opts.maxTries) || opts.maxTries < 1) + ) { + throw new Error('`maxTries` must be an integer ≥ 1.'); + } + + if ( + opts.videoTimeoutInMS != null && + (typeof opts.videoTimeoutInMS !== 'number' || opts.videoTimeoutInMS < 0) + ) { + throw new Error('`videoTimeoutInMS` must be a number ≥ 0.'); + } + + if ( + opts.delayInMS != null && + (typeof opts.delayInMS !== 'number' || opts.delayInMS < 0) + ) { + throw new Error('`delayInMS` must be a number ≥ 0.'); + } + + if (opts.signerFn != null && typeof opts.signerFn !== 'function') { + throw new Error('`signerFn` must be a function that returns a Promise.'); + } +} + +/** + * Adds an event listener to an element and returns a cleanup function to remove it. + * This pattern helps prevent memory leaks by making cleanup explicit. + * + * @param element - The DOM element to attach the listener to + * @param eventName - The event name (e.g., 'click', 'mouseenter', 'keydown') + * @param handler - The event handler function + * @param options - Optional AddEventListenerOptions (capture, once, passive, etc.) + * @returns A cleanup function that removes the event listener when called + * + * @example + * ```typescript + * const cleanup = addEventListener(button, 'click', handleClick); + * // Later, when done: + * cleanup(); // Removes the listener + * ``` + */ +export function addEventListener( + element: EventTarget, + eventName: string, + handler: EventListenerOrEventListenerObject, + options?: boolean | AddEventListenerOptions +): () => void { + element.addEventListener(eventName, handler, options ?? false); + + return () => { + element.removeEventListener(eventName, handler, options ?? false); + }; +} + +/** + * Resource cleanup registry for managing timeouts, intervals, DOM elements, + * event listeners, and other resources that need cleanup. + * + * This class provides a centralized way to track and dispose of resources, + * preventing memory leaks by ensuring all resources are properly cleaned up. + * + * @example + * ```typescript + * const cleanup = new CleanupRegistry(); + * + * // Register a timeout + * cleanup.registerTimeout(() => console.log('done'), 1000); + * + * // Register a DOM element + * const el = cleanup.registerElement(document.createElement('div')); + * + * // Register an event listener + * cleanup.registerEventListener(button, 'click', handler); + * + * // Later, clean up everything at once + * cleanup.dispose(); + * ``` + */ +export class CleanupRegistry { + private cleanups: Array<() => void> = []; + + /** + * Registers a timeout and returns its ID. + * The timeout will be automatically cleared when dispose() is called. + */ + registerTimeout(callback: () => void, delay: number): ReturnType { + const id = setTimeout(callback, delay); + this.cleanups.push(() => clearTimeout(id)); + return id; + } + + /** + * Registers an interval and returns its ID. + * The interval will be automatically cleared when dispose() is called. + */ + registerInterval(callback: () => void, delay: number): ReturnType { + const id = setInterval(callback, delay); + this.cleanups.push(() => clearInterval(id)); + return id; + } + + /** + * Registers a DOM element for cleanup. + * The element will be removed from the DOM when dispose() is called. + */ + registerElement(element: HTMLElement): HTMLElement { + this.cleanups.push(() => element.remove()); + return element; + } + + /** + * Registers a native event listener using the addEventListener utility. + * The listener will be automatically removed when dispose() is called. + */ + registerEventListener( + element: EventTarget, + eventName: string, + handler: EventListenerOrEventListenerObject, + options?: boolean | AddEventListenerOptions + ): void { + const cleanup = addEventListener(element, eventName, handler, options); + this.cleanups.push(cleanup); + } + + /** + * Registers a Video.js event listener. + * The listener will be automatically removed when dispose() is called. + */ + registerVideoJsListener(player: any, event: string, handler: Function): void { + player.on(event, handler); + this.cleanups.push(() => player.off(event, handler)); + } + + /** + * Registers an IntersectionObserver. + * The observer will be disconnected when dispose() is called. + */ + registerObserver(observer: IntersectionObserver): IntersectionObserver { + this.cleanups.push(() => observer.disconnect()); + return observer; + } + + /** + * Registers a custom cleanup function. + * Useful for any other cleanup operations that don't fit the above patterns. + */ + register(cleanup: () => void): void { + this.cleanups.push(cleanup); + } + + /** + * Executes all registered cleanup functions and clears the registry. + * Should be called when the component/plugin is being disposed. + */ + dispose(): void { + this.cleanups.forEach(cleanup => cleanup()); + this.cleanups = []; + } + + /** + * Returns the number of registered cleanup functions. + * Useful for debugging. + */ + size(): number { + return this.cleanups.length; + } +} \ No newline at end of file diff --git a/packages/video-player/package.json b/packages/video-player/package.json new file mode 100644 index 0000000..ba28f85 --- /dev/null +++ b/packages/video-player/package.json @@ -0,0 +1,70 @@ +{ + "name": "@imagekit/video-player", + "version": "1.0.0-beta.1", + "description": "Core ImageKit Video Player + framework-specific wrappers (React, Vue)", + "license": "MIT", + "main": "dist/index.js", + "module": "dist/index.mjs", + "browser": "dist/index.global.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "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" + }, + "./vue": { + "types": "./dist/vue/index.d.ts", + "import": "./dist/vue/index.mjs", + "require": "./dist/vue/index.js" + }, + "./styles.css": "./dist/styles.css" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "yarn run build:tsup && yarn run build:css && yarn run build:assets", + "build:tsup": "tsup --tsconfig tsconfig.tsup.json", + "build:css": "sass --load-path=./node_modules javascript/styles/index.scss dist/styles.css", + "build:assets": "cp -r javascript/assets dist/", + "dev": "yarn run build && (yarn run dev:tsup & yarn run dev:css)", + "dev:tsup": "tsup --tsconfig tsconfig.tsup.json --watch", + "dev:css": "sass --watch --load-path=./node_modules javascript/styles/index.scss dist/styles.css" + }, + "peerDependencies": { + "react": "^17 || ^18", + "react-dom": "^17 || ^18", + "vue": "^3.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "vue": { + "optional": true + } + }, + "devDependencies": { + "@imagekit/javascript": "^5.0.0", + "@types/lodash": "^4.14.200", + "@types/react": "^18.0.0", + "@types/react-dom": "^18.0.0", + "lodash": "^4.17.21", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "sass": "^1.75.0", + "tsup": "^8.3.5", + "typescript": "~5.8.0", + "video.js": "^8.20.0", + "vue": "^3.0.0" + } +} diff --git a/packages/video-player/public-api.ts b/packages/video-player/public-api.ts new file mode 100644 index 0000000..f9a92bd --- /dev/null +++ b/packages/video-player/public-api.ts @@ -0,0 +1 @@ +export * from "./angular"; \ No newline at end of file diff --git a/packages/video-player/react-wrapper/IKVideoPlayer.tsx b/packages/video-player/react-wrapper/IKVideoPlayer.tsx new file mode 100644 index 0000000..f9f0a13 --- /dev/null +++ b/packages/video-player/react-wrapper/IKVideoPlayer.tsx @@ -0,0 +1,91 @@ +import { + useRef, + useEffect, + forwardRef, + useImperativeHandle, +} from 'react'; +import { videoPlayer } from '../javascript'; +import type { Player } from '../javascript'; + +import type { IKVideoPlayerProps, IKVideoPlayerRef } from './interfaces'; +import React from 'react'; + +const IKVideoPlayer = forwardRef( + ( + { ikOptions, videoJsOptions = {}, source, playlist }, + ref + ) => { + // A ref to the actual