` 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