diff --git a/examples/astro/src/pages/index.astro b/examples/astro/src/pages/index.astro index 28ec86f..f4e3026 100644 --- a/examples/astro/src/pages/index.astro +++ b/examples/astro/src/pages/index.astro @@ -168,6 +168,36 @@ const playlist: { sources: SourceOptions[]; options?: PlaylistOptions } = { signerOptions={{ mode: 'imperative' }} /> + + +
+

5. Analytics with imperative mapError

+

+ Uses a deliberately broken URL to trigger error code + 2 (MEDIA_ERR_NETWORK). The mapError callback + enriches the reported context with extra metadata and logs the input/output +

+ +
+ Waiting for error… +
+
@@ -208,6 +238,39 @@ const playlist: { sources: SourceOptions[]; options?: PlaylistOptions } = { if (el) { el.signerFn = makeSigner(); } + + // Imperative mapError wiring for section 5. + // Enriches the reported context with a bit of extra metadata. + type MapErrorFn = (error: { code: string; message?: string; context?: string }) => + { code?: string; message?: string; context?: string } | null | undefined; + type IKAnalyticsEl = HTMLElement & { mapError?: MapErrorFn }; + + const analyticsEl = document.querySelector('#player-analytics'); + const logEl = document.querySelector('#map-error-log'); + + function showLog(before: object, after: object | null | undefined) { + if (!logEl) return; + logEl.innerHTML = + `mapError called
` + + `IN:  ${JSON.stringify(before)}
` + + `OUT: ${JSON.stringify(after)}`; + } + + if (analyticsEl) { + analyticsEl.mapError = (err) => { + // Keep the original error, just attach some extra context metadata. + const result = { + context: JSON.stringify({ + original: err.context, + page: 'astro-example', + url: location.href, + }), + }; + console.log('[mapError] IN:', err, '→ OUT:', result); + showLog(err, result); + return result; + }; + } + + +
+ ← Back to examples +

Try It Yourself 2

+

Internal page with analytics option enabled for testing.

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

Generated Code

+
+
+
+
+ + + + diff --git a/examples/javascript/src/try-it-yourself.ts b/examples/javascript/src/try-it-yourself.ts index 9283d29..d2dcfbe 100644 --- a/examples/javascript/src/try-it-yourself.ts +++ b/examples/javascript/src/try-it-yourself.ts @@ -52,6 +52,12 @@ function formatObjectAsCode(value: any): string { return jsonString; } +interface AnalyticsOptions { + userId?: string; + customDimensions?: Record; + mapError?: boolean; +} + /** * Builds the player configuration objects based on form inputs */ @@ -63,7 +69,8 @@ function buildPlayerConfig( wordHighlight?: boolean, translationLangs?: Array<{ label?: string; langCode: string }>, signerUrl?: string, - transformation?: Transformation[] + transformation?: Transformation[], + analyticsOptions?: AnalyticsOptions ): { playerOptions: IKPlayerOptions; srcConfig: SourceOptions } { // Build player options const playerOptions: IKPlayerOptions = { @@ -79,6 +86,30 @@ function buildPlayerConfig( playerOptions.seekThumbnails = true; } + if (features.includes('analytics')) { + playerOptions.analytics = { + enabled: true, + ...(analyticsOptions?.userId ? { userId: analyticsOptions.userId } : {}), + ...(analyticsOptions?.customDimensions && Object.keys(analyticsOptions.customDimensions).length > 0 + ? { customDimensions: analyticsOptions.customDimensions } + : {}), + ...(analyticsOptions?.mapError + ? { + mapError: (err: any) => { + // Extract the VHS errorType from context when available, + // otherwise fall back to the raw error code. + try { + const ctx = err?.context ? JSON.parse(err.context) : null; + const errorType = ctx?.metadata?.errorType; + if (errorType) return { ...err, code: errorType }; + } catch { /* ignore parse errors */ } + return err; + } + } + : {}), + }; + } + // Add signer function if URL is provided if (signerUrl && signerUrl.trim()) { playerOptions.signerFn = async (url: string): Promise => { @@ -183,7 +214,8 @@ function generateCode( wordHighlight?: boolean, translationLangs?: Array<{ label?: string; langCode: string }>, signerUrl?: string, - transformation?: Transformation[] + transformation?: Transformation[], + analyticsOptions?: AnalyticsOptions ): string { const { playerOptions, srcConfig } = buildPlayerConfig( imagekitId, @@ -193,7 +225,8 @@ function generateCode( wordHighlight, translationLangs, signerUrl, - transformation + transformation, + analyticsOptions ); // Keep video.js options in one place so displayed code always matches runtime config @@ -201,12 +234,25 @@ function generateCode( muted: true }; - // Format player options, but handle signerFn separately since it's a function + // Format player options, but handle function properties separately since they can't be JSON-serialised const optionsForFormatting = { ...playerOptions }; if (optionsForFormatting.signerFn) { delete (optionsForFormatting as any).signerFn; } + const hasMapError = !!(analyticsOptions?.mapError && playerOptions.analytics); + if (hasMapError && (optionsForFormatting.analytics as any)?.mapError) { + delete (optionsForFormatting.analytics as any).mapError; + } let playerOptionsCode = formatObjectAsCode(optionsForFormatting); + + // If mapError is enabled, inject the readable function snippet + if (hasMapError) { + // Insert mapError inside the analytics object, before its closing brace + playerOptionsCode = playerOptionsCode.replace( + /(analytics:\s*\{[^}]*)\}/, + `$1,\n mapError: (err) => {\n try {\n const ctx = err?.context ? JSON.parse(err.context) : null;\n const errorType = ctx?.metadata?.errorType;\n if (errorType) return { ...err, code: errorType };\n } catch {}\n return err;\n }\n }` + ); + } // If signer function exists, add it to the code if (playerOptions.signerFn && signerUrl) { @@ -275,6 +321,25 @@ function updatePlayer() { const signerUrlInput = document.getElementById('signer-url') as HTMLInputElement; const signerUrl = enableSigner && signerUrlInput?.value.trim() ? signerUrlInput.value.trim() : undefined; + // Get analytics options + let analyticsOptions: AnalyticsOptions | undefined; + if (features.includes('analytics')) { + const userId = (document.getElementById('analytics-user-id') as HTMLInputElement)?.value.trim() || undefined; + const customDimensions: Record = {}; + const dimItems = document.querySelectorAll('#custom-dims-list .translation-lang-item'); + dimItems.forEach(item => { + const key = (item.querySelector('.custom-dim-key-input') as HTMLInputElement)?.value.trim(); + const val = (item.querySelector('.custom-dim-value-input') as HTMLInputElement)?.value.trim(); + if (key && val) customDimensions[key] = val; + }); + const mapError = (document.getElementById('enable-map-error') as HTMLInputElement)?.checked || false; + analyticsOptions = { + ...(userId ? { userId } : {}), + ...(Object.keys(customDimensions).length > 0 ? { customDimensions } : {}), + ...(mapError ? { mapError } : {}), + }; + } + // Get transformation const transformationInput = (document.getElementById('transformation') as HTMLInputElement)?.value.trim() || ''; let transformation: Transformation[] | undefined = undefined; @@ -301,11 +366,12 @@ function updatePlayer() { wordHighlight, translationLangsForConfig, signerUrl, - transformation + transformation, + analyticsOptions ); // Generate and display code - const code = generateCode(imagekitId, srcUrl, features, maxChars, wordHighlight, translationLangsForConfig, signerUrl, transformation); + const code = generateCode(imagekitId, srcUrl, features, maxChars, wordHighlight, translationLangsForConfig, signerUrl, transformation, analyticsOptions); document.getElementById('code-display')!.textContent = code; // Get the video element before disposing @@ -315,7 +381,7 @@ function updatePlayer() { if (currentPlayer) { try { // Check if player is already disposed - if (!currentPlayer.isDisposed && !currentPlayer.isDisposed()) { + if (typeof currentPlayer.isDisposed === 'function' && !currentPlayer.isDisposed()) { currentPlayer.dispose(); } } catch (e) { diff --git a/examples/javascript/vite.config.ts b/examples/javascript/vite.config.ts index fa50ed1..cff12d9 100644 --- a/examples/javascript/vite.config.ts +++ b/examples/javascript/vite.config.ts @@ -32,6 +32,7 @@ export default defineConfig({ abs: path.resolve(__dirname, 'pages/abs.html'), logo: path.resolve(__dirname, 'pages/logo.html'), tryItYourself: path.resolve(__dirname, 'pages/try-it-yourself.html'), + tryItYourself2: path.resolve(__dirname, 'pages/try-it-yourself-2.html'), // Note: You didn't provide a context-menu example, so it's commented out. // contextMenu: path.resolve(__dirname, 'pages/context-menu.html'), }, diff --git a/examples/react/src/App.tsx b/examples/react/src/App.tsx index 4ea3a4d..4c2c7da 100644 --- a/examples/react/src/App.tsx +++ b/examples/react/src/App.tsx @@ -21,6 +21,10 @@ export default function App() { logoImageUrl: 'https://ik.imgkit.net/ikmedia/logo/light_T4buIzohVH.svg', logoOnclickUrl: 'https://imagekit.io/', }, + analytics: { + enabled: true, + userId: 'test_user_id', + }, }; // 2) For a single video source (SourceOptions) diff --git a/examples/vue/src/App.vue b/examples/vue/src/App.vue index deb4758..f478013 100644 --- a/examples/vue/src/App.vue +++ b/examples/vue/src/App.vue @@ -22,6 +22,10 @@ const playerRef = ref(null); const ikOptions: IKPlayerOptions = { imagekitId: 'YOUR_IMAGEKIT_ID', // Remember to replace this seekThumbnails: true, + analytics: { + enabled: true, + userId: 'test_user_id', + }, }; const playlist: { diff --git a/packages/video-player/CHANGELOG.md b/packages/video-player/CHANGELOG.md index 9064f9d..2c2f3be 100644 --- a/packages/video-player/CHANGELOG.md +++ b/packages/video-player/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## [1.0.0-beta.5] + +- Added playback analytics (QoE) tracking: enable via `analytics.enabled` + ## [1.0.0-beta.4] - Added support for Astro, by exporting component from `@imagekit/video-player/astro` diff --git a/packages/video-player/astro/IKVideoPlayer.astro b/packages/video-player/astro/IKVideoPlayer.astro index 97f10d0..b5adb97 100644 --- a/packages/video-player/astro/IKVideoPlayer.astro +++ b/packages/video-player/astro/IKVideoPlayer.astro @@ -31,6 +31,19 @@ * or any signing flow that doesn't fit the simple POST contract. * An imperatively-set `signerFn` always wins over `signerOptions`. * + * ERROR REMAPPING (ASTRO-SAFE PATTERN) + * ------------------------------------ + * Similar to `signerFn`, function values can't be serialized through + * `data-config`. If you need error remapping, assign `mapError` + * imperatively from a sibling script: + * + * ```html + * + * ``` + * * STYLES * ------ * This component does NOT auto-import the player stylesheet. Import it @@ -175,7 +188,7 @@ const config = JSON.stringify({ ikOptions, videoJsOptions, source, playlist, sig -