From c228398d1408006efe8262462dbdbcea78123d8c Mon Sep 17 00:00:00 2001 From: Steve 'Cutter' Blades Date: Tue, 28 Apr 2026 12:11:00 -0400 Subject: [PATCH 1/4] feat: Add video support and utility export refinements --- apps/demo/src/app/app.element.css | 90 +++-- apps/demo/src/app/app.element.ts | 250 +++++++++++-- apps/framework-demo/src/App.spec.tsx | 4 +- .../FrameworkDemo/FrameworkDemo.component.tsx | 18 +- .../FrameworkDemo.components.spec.tsx | 7 +- .../FrameworkDemo.hooks.spec.tsx | 3 + .../FrameworkDemo/FrameworkDemo.module.css | 11 +- .../FilePickerRow/FilePickerRow.component.tsx | 46 ++- .../FilePickerRow/FilePickerRow.module.css | 8 + .../hooks/useFilePickerRowProps.memo.tsx | 80 ++++- .../StageDisplay/StageDisplay.component.tsx | 12 +- .../StageDisplay/StageDisplay.module.css | 30 +- .../hooks/useStageDisplayProps.memo.tsx | 6 + .../hooks/useFrameworkDemo.context.tsx | 69 +++- .../useFrameworkDemo.context.spec.tsx | 1 + apps/storybook-hub/.storybook/manager.ts | 62 ++++ apps/storybook-hub/debug-storybook.log | 18 + .../storybook-hub/docs/contracts-controls.mdx | 9 + apps/storybook-hub/docs/contracts-loader.mdx | 56 ++- apps/storybook-hub/docs/contracts-player.mdx | 19 +- apps/storybook-react/.storybook/preview.ts | 16 + .../storybook-react/stories/react-example.mdx | 17 +- apps/storybook-web/.storybook/preview.ts | 16 + .../stories/framework-agnostic-example.mdx | 15 +- packages/cdg-controls/README.md | 9 + packages/cdg-controls/src/index.ts | 4 + packages/cdg-controls/src/lib/controls.ts | 205 +---------- .../src/lib/utils/controls.constants.ts | 2 + .../src/lib/utils/controls.functions.ts | 195 ++++++++++ packages/cdg-core/src/lib/cdg-player.ts | 61 +--- .../src/lib/utils/runtime.constants.ts | 5 + .../src/lib/utils/runtime.functions.ts | 65 ++++ packages/cdg-loader/README.md | 52 ++- packages/cdg-loader/src/index.ts | 2 + .../cdg-loader/src/lib/loader-core.spec.ts | 2 +- packages/cdg-loader/src/lib/loader-core.ts | 337 ++++++++---------- packages/cdg-loader/src/lib/types.ts | 9 +- .../src/lib/utils/loader.constants.ts | 28 ++ .../src/lib/utils/loader.functions.ts | 4 + .../lib/utils/loader.internal-functions.ts | 23 ++ .../src/lib/utils/loader.public-functions.ts | 171 +++++++++ packages/cdg-player/README.md | 15 +- packages/cdg-player/src/index.ts | 4 + packages/cdg-player/src/lib/audio-engine.ts | 66 ++-- packages/cdg-player/src/lib/player.spec.ts | 16 + packages/cdg-player/src/lib/player.ts | 286 ++++++++++++--- .../src/lib/utils/player.constants.ts | 11 + .../src/lib/utils/player.functions.ts | 35 ++ 48 files changed, 1855 insertions(+), 615 deletions(-) create mode 100644 apps/storybook-hub/.storybook/manager.ts create mode 100644 apps/storybook-hub/debug-storybook.log create mode 100644 packages/cdg-controls/src/lib/utils/controls.constants.ts create mode 100644 packages/cdg-controls/src/lib/utils/controls.functions.ts create mode 100644 packages/cdg-core/src/lib/utils/runtime.constants.ts create mode 100644 packages/cdg-core/src/lib/utils/runtime.functions.ts create mode 100644 packages/cdg-loader/src/lib/utils/loader.constants.ts create mode 100644 packages/cdg-loader/src/lib/utils/loader.functions.ts create mode 100644 packages/cdg-loader/src/lib/utils/loader.internal-functions.ts create mode 100644 packages/cdg-loader/src/lib/utils/loader.public-functions.ts create mode 100644 packages/cdg-player/src/lib/utils/player.constants.ts create mode 100644 packages/cdg-player/src/lib/utils/player.functions.ts diff --git a/apps/demo/src/app/app.element.css b/apps/demo/src/app/app.element.css index 22af174..23df188 100644 --- a/apps/demo/src/app/app.element.css +++ b/apps/demo/src/app/app.element.css @@ -1,20 +1,23 @@ -:host { +:is(app-root, cdg-storybook-framework-agnostic-demo) { display: block; - min-height: 100vh; + height: 100dvh; + width: 100dvw; + overflow: hidden; font-family: 'Avenir Next', 'Segoe UI', sans-serif; color: #1f2e3a; background: linear-gradient(180deg, #f8fafb 0%, #e9eef2 100%); } .app-shell { - display: flex; - flex-direction: column; - align-items: stretch; - min-height: 100vh; + display: grid; + grid-template-rows: auto 1fr; + height: 100dvh; + width: 100dvw; + overflow: hidden; } .source-link-row { - padding: 1rem 1rem 0; + padding-top: 1rem; text-align: center; font-size: 0.88rem; color: #31485d; @@ -36,24 +39,52 @@ } .file-select-container { - padding: 0.5rem 1rem 0; + padding-top: 0.2rem; text-align: center; } +.file-picker-row { + display: flex; + align-items: center; + justify-content: center; + gap: 0.65rem; + font-size: 0.92rem; + color: #1f2e3a; +} + +.codec-diagnostic { + display: none; + font-size: 0.82rem; + font-weight: 600; + color: #8d3c12; +} + +.codec-diagnostic.is-visible { + display: block; + margin-top: 0.4rem; +} + .file-picker { display: inline-block; - margin-bottom: 0.5rem; font-size: 0.92rem; } #track-input { max-width: 520px; - width: 100%; + width: min(520px, 100%); + font: inherit; + font-size: 0.92rem; +} + +#track-input::file-selector-button { + font: inherit; +} + +#track-input::-webkit-file-upload-button { font: inherit; } .perf-export-button { - margin-top: 0.5rem; padding: 0.3rem 0.6rem; border: 1px solid rgba(73, 99, 126, 0.55); border-radius: 4px; @@ -69,19 +100,9 @@ cursor: not-allowed; } -.cdg-player { - flex: 1 1 auto; +.controls-header { display: flex; flex-direction: column; - justify-content: center; - width: 100%; - height: 100%; - margin: 0 auto; - visibility: visible; -} - -.app-shell.has-track .cdg-player { - visibility: visible; } .status { @@ -142,7 +163,7 @@ justify-content: center; gap: 0.45rem; min-height: 40px; - padding: 0.35rem 0; + padding: 0.35rem 0.5rem; } .settings-panel { @@ -452,26 +473,34 @@ } .stage { - flex: 1 1 auto; + container-type: size; display: flex; align-items: center; justify-content: center; position: relative; - padding: 0.75rem 0 1rem; + overflow: hidden; background: #000; } canvas[data-role='canvas'] { - width: min(72vw, 72%); - max-width: 920px; aspect-ratio: 25 / 18; + width: min(100%, calc(100cqh * 25 / 18)); height: auto; display: block; - margin: 0 auto; background: #000; image-rendering: pixelated; } +video[data-role='video'] { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: contain; + display: none; + background: #000; +} + .titleImage { display: flex; align-items: center; @@ -527,7 +556,10 @@ audio[data-role='audio'] { } @media (max-width: 760px) { - canvas[data-role='canvas'], + .file-picker-row { + flex-wrap: wrap; + } + .titleImage { width: calc(100% - 1.2rem); } diff --git a/apps/demo/src/app/app.element.ts b/apps/demo/src/app/app.element.ts index 7211e24..a7f117a 100644 --- a/apps/demo/src/app/app.element.ts +++ b/apps/demo/src/app/app.element.ts @@ -35,6 +35,45 @@ const isLocalDevelopmentRuntime = (): boolean => { const PERF_SAMPLE_LIMIT = 120; const FRAMEWORK_AGNOSTIC_SOURCE_URL = 'https://github.com/cutterbl/CDGPlayer/tree/main/apps/demo'; +const STORYBOOK_STORY_CHANGE_EVENT = 'cdg:storybook-story-change'; +const STOP_PLAYBACK_MESSAGE_TYPE = 'cdg:stop-playback'; + +const VIDEO_EXTENSION_TO_MIME: Record = { + mp4: 'video/mp4', + webm: 'video/webm', + mov: 'video/quicktime', + m4v: 'video/mp4', + mkv: 'video/x-matroska', + avi: 'video/x-msvideo', +}; + +const extensionFromName = ({ name }: { name: string }): string | null => { + const dotIndex = name.lastIndexOf('.'); + if (dotIndex < 0 || dotIndex === name.length - 1) { + return null; + } + + return name.slice(dotIndex + 1).toLowerCase(); +}; + +const inferVideoMimeType = ({ file }: { file: File }): string | null => { + if (file.type.startsWith('video/')) { + return file.type.toLowerCase(); + } + + const extension = extensionFromName({ name: file.name }); + if (!extension) { + return null; + } + + return VIDEO_EXTENSION_TO_MIME[extension] ?? null; +}; + +const canPlayVideoMimeType = ({ mimeType }: { mimeType: string }): boolean => { + const videoElement = document.createElement('video'); + const supportLevel = videoElement.canPlayType(mimeType); + return supportLevel === 'probably' || supportLevel === 'maybe'; +}; /** * Builds the reusable DOM template for the custom element shell. @@ -53,40 +92,44 @@ const createAppTemplate = ({ const template = document.createElement('template'); template.innerHTML = `
- -
- - - ${showPerfDiagnostics ? '' : ''} +
+ +
+
+ + + ${showPerfDiagnostics ? '' : ''} +
+
+
+
-
-
-
-
- `; @@ -148,6 +191,7 @@ export class AppElement extends HTMLElement { private controlsParts: DisposableControl[] = []; private controlsUnsubscribe: (() => void) | null = null; private canvasElement: HTMLCanvasElement | null = null; + private videoElement: HTMLVideoElement | null = null; private statusElement: HTMLElement | null = null; private titleImage: HTMLElement | null = null; private titleMeta: HTMLElement | null = null; @@ -156,10 +200,13 @@ export class AppElement extends HTMLElement { private perfElement: HTMLElement | null = null; private perfExportButton: HTMLButtonElement | null = null; private appShell: HTMLElement | null = null; + private codecDiagnosticElement: HTMLElement | null = null; private lastStatus: string | null = null; private statusFadeTimeoutId: number | null = null; private hasPlaybackStarted = false; private hasGraphicsTrack = true; + private hasVideoTrack = false; + private lifecycleAbortController: AbortController | null = null; private readonly showPerfDiagnostics = isLocalDevelopmentRuntime(); private renderSamples: RenderSample[] = []; @@ -167,6 +214,9 @@ export class AppElement extends HTMLElement { * Custom element lifecycle teardown: dispose listeners, controls, and player resources. */ disconnectedCallback(): void { + this.detachLifecycleStopGuards(); + this.stopPlaybackForNavigation(); + // Custom element teardown: remove listeners/disposables to avoid memory leaks. this.controlsUnsubscribe?.(); this.controlsUnsubscribe = null; @@ -194,11 +244,87 @@ export class AppElement extends HTMLElement { ? perfAppTemplate : baseAppTemplate; this.replaceChildren(template.content.cloneNode(true)); + this.attachLifecycleStopGuards(); // Once DOM is present, wire browser elements to player/controls logic. this.initializeDemo(); } + /** + * Stops active playback when the host page/story is hidden or unloaded. + */ + private attachLifecycleStopGuards(): void { + this.detachLifecycleStopGuards(); + + const abortController = new AbortController(); + this.lifecycleAbortController = abortController; + const { signal } = abortController; + + const stopPlayback = (): void => { + this.stopPlaybackForNavigation(); + }; + + const handleVisibilityChange = (): void => { + if (document.visibilityState === 'hidden') { + stopPlayback(); + } + }; + + const handleWindowMessage = (event: MessageEvent): void => { + const payload = event.data; + if ( + typeof payload === 'object' && + payload !== null && + 'type' in payload && + payload.type === STOP_PLAYBACK_MESSAGE_TYPE + ) { + stopPlayback(); + } + }; + + document.addEventListener('visibilitychange', handleVisibilityChange, { + signal, + }); + window.addEventListener('pagehide', stopPlayback, { signal }); + window.addEventListener('beforeunload', stopPlayback, { signal }); + window.addEventListener(STORYBOOK_STORY_CHANGE_EVENT, stopPlayback, { + signal, + }); + window.addEventListener('message', handleWindowMessage, { signal }); + } + + /** + * Removes page lifecycle listeners installed for playback teardown. + */ + private detachLifecycleStopGuards(): void { + this.lifecycleAbortController?.abort(); + this.lifecycleAbortController = null; + } + + /** + * Best-effort stop used when navigating away from the current story/page. + */ + private stopPlaybackForNavigation(): void { + if (!this.player) { + return; + } + + // Storybook/unit-test doubles may not expose the full runtime player API. + const playerMaybe = this.player as unknown as { + stop?: () => void; + pause?: () => void; + }; + + if (typeof playerMaybe.stop === 'function') { + playerMaybe.stop(); + return; + } + + if (typeof playerMaybe.pause === 'function') { + playerMaybe.pause(); + } + } + /** * Queries required DOM nodes and wires player + controls + UI event handlers. */ @@ -209,6 +335,8 @@ export class AppElement extends HTMLElement { ); this.canvasElement = canvas; const audio = this.querySelector('[data-role="audio"]'); + const video = this.querySelector('[data-role="video"]'); + this.videoElement = video; const transportContainer = this.querySelector( '[data-role="transport-bar"]', ); @@ -221,6 +349,9 @@ export class AppElement extends HTMLElement { '[data-role="status"]', ); this.appShell = this.querySelector('[data-role="app-shell"]'); + this.codecDiagnosticElement = this.querySelector( + '[data-role="codec-diagnostic"]', + ); this.titleImage = this.querySelector( '[data-role="title-image"]', ); @@ -243,6 +374,7 @@ export class AppElement extends HTMLElement { if ( !canvas || !audio || + !video || !transportContainer || !stage || !settingsContainer || @@ -259,6 +391,7 @@ export class AppElement extends HTMLElement { options: { canvas, audio, + video, debug: true, }, }); @@ -381,6 +514,8 @@ export class AppElement extends HTMLElement { this.player.stop(); this.hasPlaybackStarted = false; this.hasGraphicsTrack = true; + this.hasVideoTrack = false; + this.setCodecDiagnostic(null); this.setTitleMetadata(null); this.syncStageGraphicsVisibility(); this.syncTitleImage('loading'); @@ -390,6 +525,17 @@ export class AppElement extends HTMLElement { message: 'calling player.load', fileName: selectedFile.name, }); + + const likelyVideoMimeType = inferVideoMimeType({ file: selectedFile }); + if ( + likelyVideoMimeType && + !canPlayVideoMimeType({ mimeType: likelyVideoMimeType }) + ) { + this.setCodecDiagnostic( + `This browser reports limited support for ${likelyVideoMimeType}. For best compatibility, use MP4 (H.264 video + AAC audio).`, + ); + } + void this.player .load({ input: { kind: 'file', file: selectedFile }, @@ -412,7 +558,14 @@ export class AppElement extends HTMLElement { }); } - this.hasGraphicsTrack = loadedTrack?.hasGraphics ?? false; + this.hasVideoTrack = loadedTrack?.mediaKind === 'video'; + this.hasGraphicsTrack = + !this.hasVideoTrack && (loadedTrack?.hasGraphics ?? false); + + if (this.hasVideoTrack) { + this.setCodecDiagnostic(null); + } + this.syncStageGraphicsVisibility(); this.hasPlaybackStarted = false; @@ -425,6 +578,23 @@ export class AppElement extends HTMLElement { errorValue instanceof Error ? errorValue.message : 'Unknown load error'; + + const likelyVideoMimeType = inferVideoMimeType({ + file: selectedFile, + }); + const codecLikelyUnsupported = + message.includes('cannot play video format') || + message.includes('Unable to load video media in this browser') || + message.includes( + 'Video track could not be decoded by this browser', + ); + + if (likelyVideoMimeType && codecLikelyUnsupported) { + this.setCodecDiagnostic( + `This file appears to use an unsupported video codec for this browser (${likelyVideoMimeType}). Try MP4 with H.264 video + AAC audio.`, + ); + } + logger.debug({ message: 'player.load rejected', file: selectedFile.name, @@ -433,6 +603,7 @@ export class AppElement extends HTMLElement { }); this.setStatusMessage(`Load failed: ${message}`); this.hasGraphicsTrack = true; + this.hasVideoTrack = false; this.syncStageGraphicsVisibility(); this.syncTitleImage('error'); this.syncLayout('error'); @@ -459,6 +630,21 @@ export class AppElement extends HTMLElement { } } + /** + * Shows or hides persistent codec diagnostics near the picker. + */ + private setCodecDiagnostic(message: string | null): void { + if (!this.codecDiagnosticElement) { + return; + } + + this.codecDiagnosticElement.textContent = message ?? ''; + this.codecDiagnosticElement.classList.toggle( + 'is-visible', + Boolean(message), + ); + } + /** * Clears any pending status auto-hide timeout. */ @@ -568,6 +754,7 @@ export class AppElement extends HTMLElement { this.appShell.classList.toggle('show-player', shouldShowPlayer); this.appShell.classList.toggle('is-playing', status === 'playing'); this.appShell.classList.toggle('has-graphics', this.hasGraphicsTrack); + this.appShell.classList.toggle('has-video', this.hasVideoTrack); if (status === 'ready' || status === 'playing' || status === 'paused') { this.appShell.classList.add('has-track'); @@ -581,11 +768,12 @@ export class AppElement extends HTMLElement { * Hides canvas when the loaded track has no graphics stream. */ private syncStageGraphicsVisibility(): void { - if (!this.canvasElement) { + if (!this.canvasElement || !this.videoElement) { return; } this.canvasElement.style.display = this.hasGraphicsTrack ? 'block' : 'none'; + this.videoElement.style.display = this.hasVideoTrack ? 'block' : 'none'; } /** diff --git a/apps/framework-demo/src/App.spec.tsx b/apps/framework-demo/src/App.spec.tsx index 01cd652..ff72d66 100644 --- a/apps/framework-demo/src/App.spec.tsx +++ b/apps/framework-demo/src/App.spec.tsx @@ -232,7 +232,7 @@ describe('App', () => { expect(harness.controlsModel.togglePlayPause).toHaveBeenCalledTimes(2); const fileInput = screen.getByLabelText( - 'Select audio or karaoke zip (audio/*, .zip)', + 'Select media or karaoke zip (audio/*, video/*, .zip)', ); const file = new File(['zip-content'], 'demo-track.zip', { type: 'application/zip', @@ -313,7 +313,7 @@ describe('App', () => { ).toBe(true); const fileInput = screen.getByLabelText( - 'Select audio or karaoke zip (audio/*, .zip)', + 'Select media or karaoke zip (audio/*, video/*, .zip)', ); const file = new File(['zip-content'], 'broken-track.zip', { type: 'application/zip', diff --git a/apps/framework-demo/src/app/components/FrameworkDemo/FrameworkDemo.component.tsx b/apps/framework-demo/src/app/components/FrameworkDemo/FrameworkDemo.component.tsx index f0c6b1a..cbac1ba 100644 --- a/apps/framework-demo/src/app/components/FrameworkDemo/FrameworkDemo.component.tsx +++ b/apps/framework-demo/src/app/components/FrameworkDemo/FrameworkDemo.component.tsx @@ -18,14 +18,16 @@ function FrameworkDemo() { // Provider holds shared player/model state so child controls can stay focused.
- - - {/* Top controls: file loading + transport row */} - - + {/* All controls above the stage grouped in one auto-height container */} +
+ + {/* Top controls: file loading + transport row */} + + +
{/* Stage owns visuals; transport now hosts settings popovers in-row. */} diff --git a/apps/framework-demo/src/app/components/FrameworkDemo/FrameworkDemo.components.spec.tsx b/apps/framework-demo/src/app/components/FrameworkDemo/FrameworkDemo.components.spec.tsx index 39ff29e..e1d8b13 100644 --- a/apps/framework-demo/src/app/components/FrameworkDemo/FrameworkDemo.components.spec.tsx +++ b/apps/framework-demo/src/app/components/FrameworkDemo/FrameworkDemo.components.spec.tsx @@ -16,6 +16,7 @@ import TransportBar from './components/TransportBar'; const createContextValue = (overrides: Record = {}) => ({ canvasRef: { current: null }, audioRef: { current: null }, + videoRef: { current: null }, showPerfDiagnostics: false, compatibilityWarning: null, statusMessage: 'Choose a track to start.', @@ -38,6 +39,10 @@ const createContextValue = (overrides: Record = {}) => ({ setTitleMetadata: vi.fn(), hasGraphicsTrack: true, setHasGraphicsTrack: vi.fn(), + hasVideoTrack: false, + setHasVideoTrack: vi.fn(), + codecDiagnostic: null, + setCodecDiagnostic: vi.fn(), perfSummary: null, hasTrack: false, showTitle: false, @@ -122,7 +127,7 @@ describe('Framework demo components', () => { render(); const input = screen.getByLabelText( - 'Select audio or karaoke zip (audio/*, .zip)', + 'Select media or karaoke zip (audio/*, video/*, .zip)', ); fireEvent.change(input, { target: { files: [] } }); expect(player.stop).not.toHaveBeenCalled(); diff --git a/apps/framework-demo/src/app/components/FrameworkDemo/FrameworkDemo.hooks.spec.tsx b/apps/framework-demo/src/app/components/FrameworkDemo/FrameworkDemo.hooks.spec.tsx index c03cc55..2423ae0 100644 --- a/apps/framework-demo/src/app/components/FrameworkDemo/FrameworkDemo.hooks.spec.tsx +++ b/apps/framework-demo/src/app/components/FrameworkDemo/FrameworkDemo.hooks.spec.tsx @@ -18,6 +18,9 @@ const createContextValue = (overrides: Record = {}) => ({ controlsModel: null, setTitleMetadata: vi.fn(), setHasGraphicsTrack: vi.fn(), + setHasVideoTrack: vi.fn(), + codecDiagnostic: null, + setCodecDiagnostic: vi.fn(), showPerfDiagnostics: false, perfSummary: null, showStatusMessage: vi.fn(), diff --git a/apps/framework-demo/src/app/components/FrameworkDemo/FrameworkDemo.module.css b/apps/framework-demo/src/app/components/FrameworkDemo/FrameworkDemo.module.css index 01295c7..beab98d 100644 --- a/apps/framework-demo/src/app/components/FrameworkDemo/FrameworkDemo.module.css +++ b/apps/framework-demo/src/app/components/FrameworkDemo/FrameworkDemo.module.css @@ -1,9 +1,14 @@ @layer components { .frameworkShell { - min-height: 100vh; + display: grid; + grid-template-rows: auto 1fr; + height: 100dvh; + width: 100dvw; + overflow: hidden; + } + + .controlsHeader { display: flex; flex-direction: column; - gap: 0.6rem; - padding: 0; } } diff --git a/apps/framework-demo/src/app/components/FrameworkDemo/components/FilePickerRow/FilePickerRow.component.tsx b/apps/framework-demo/src/app/components/FrameworkDemo/components/FilePickerRow/FilePickerRow.component.tsx index 151609e..0e17d13 100644 --- a/apps/framework-demo/src/app/components/FrameworkDemo/components/FilePickerRow/FilePickerRow.component.tsx +++ b/apps/framework-demo/src/app/components/FrameworkDemo/components/FilePickerRow/FilePickerRow.component.tsx @@ -8,31 +8,39 @@ function FilePickerRow() { const { showPerfDiagnostics, canExportPerf, + codecDiagnostic, handleTrackSelect, handleExportPerfArtifact, } = useFilePickerRowProps(); return ( // This row is intentionally simple: choose file + optional speed-report export. -
- - - {showPerfDiagnostics ? ( - +
+
+ + + {showPerfDiagnostics ? ( + + ) : null} +
+ {codecDiagnostic ? ( +

+ {codecDiagnostic} +

) : null}
); diff --git a/apps/framework-demo/src/app/components/FrameworkDemo/components/FilePickerRow/FilePickerRow.module.css b/apps/framework-demo/src/app/components/FrameworkDemo/components/FilePickerRow/FilePickerRow.module.css index 472cf28..0d3c566 100644 --- a/apps/framework-demo/src/app/components/FrameworkDemo/components/FilePickerRow/FilePickerRow.module.css +++ b/apps/framework-demo/src/app/components/FrameworkDemo/components/FilePickerRow/FilePickerRow.module.css @@ -21,4 +21,12 @@ cursor: not-allowed; } } + + .codecDiagnostic { + margin: 0.4rem 0 0; + text-align: center; + font-size: 0.82rem; + font-weight: 600; + color: #8d3c12; + } } diff --git a/apps/framework-demo/src/app/components/FrameworkDemo/components/FilePickerRow/hooks/useFilePickerRowProps.memo.tsx b/apps/framework-demo/src/app/components/FrameworkDemo/components/FilePickerRow/hooks/useFilePickerRowProps.memo.tsx index dbeb0ec..f140efe 100644 --- a/apps/framework-demo/src/app/components/FrameworkDemo/components/FilePickerRow/hooks/useFilePickerRowProps.memo.tsx +++ b/apps/framework-demo/src/app/components/FrameworkDemo/components/FilePickerRow/hooks/useFilePickerRowProps.memo.tsx @@ -2,12 +2,50 @@ import { useCallback } from 'react'; import type { ChangeEvent } from 'react'; import useFrameworkDemoContext from '../../../hooks/useFrameworkDemo.context'; +const VIDEO_EXTENSION_TO_MIME: Record = { + mp4: 'video/mp4', + webm: 'video/webm', + mov: 'video/quicktime', + m4v: 'video/mp4', + mkv: 'video/x-matroska', + avi: 'video/x-msvideo', +}; + +const extensionFromName = ({ name }: { name: string }): string | null => { + const dotIndex = name.lastIndexOf('.'); + if (dotIndex < 0 || dotIndex === name.length - 1) { + return null; + } + + return name.slice(dotIndex + 1).toLowerCase(); +}; + +const inferVideoMimeType = ({ file }: { file: File }): string | null => { + if (file.type.startsWith('video/')) { + return file.type.toLowerCase(); + } + + const extension = extensionFromName({ name: file.name }); + if (!extension) { + return null; + } + + return VIDEO_EXTENSION_TO_MIME[extension] ?? null; +}; + +const canPlayVideoMimeType = ({ mimeType }: { mimeType: string }): boolean => { + const videoElement = document.createElement('video'); + const supportLevel = videoElement.canPlayType(mimeType); + return supportLevel === 'probably' || supportLevel === 'maybe'; +}; + /** * View-model contract for FilePickerRow. */ export type FilePickerRowResolvedProps = { showPerfDiagnostics: boolean; canExportPerf: boolean; + codecDiagnostic: string | null; handleTrackSelect: (event: ChangeEvent) => void; handleExportPerfArtifact: () => void; }; @@ -20,8 +58,11 @@ function useFilePickerRowProps(): FilePickerRowResolvedProps { player, setTitleMetadata, setHasGraphicsTrack, + setHasVideoTrack, + setCodecDiagnostic, showPerfDiagnostics, perfSummary, + codecDiagnostic, showStatusMessage, resetPlaybackStarted, exportPerfArtifact, @@ -39,8 +80,20 @@ function useFilePickerRowProps(): FilePickerRowResolvedProps { resetPlaybackStarted(); setTitleMetadata(null); setHasGraphicsTrack(true); + setHasVideoTrack(false); + setCodecDiagnostic(null); showStatusMessage('Loading track...'); + const likelyVideoMimeType = inferVideoMimeType({ file }); + if ( + likelyVideoMimeType && + !canPlayVideoMimeType({ mimeType: likelyVideoMimeType }) + ) { + setCodecDiagnostic( + `This browser reports limited support for ${likelyVideoMimeType}. For best compatibility, use MP4 (H.264 video + AAC audio).`, + ); + } + try { // Let the player parse the selected zip file from the browser File API. const loadedTrack = await player.load({ @@ -59,7 +112,15 @@ function useFilePickerRowProps(): FilePickerRowResolvedProps { }); } - setHasGraphicsTrack(loadedTrack?.hasGraphics ?? false); + const nextHasVideoTrack = loadedTrack?.mediaKind === 'video'; + setHasVideoTrack(nextHasVideoTrack); + setHasGraphicsTrack( + !nextHasVideoTrack && (loadedTrack?.hasGraphics ?? false), + ); + + if (nextHasVideoTrack) { + setCodecDiagnostic(null); + } showStatusMessage('Track loaded.'); } catch (errorValue: unknown) { @@ -68,6 +129,20 @@ function useFilePickerRowProps(): FilePickerRowResolvedProps { errorValue instanceof Error ? errorValue.message : 'Unknown load error'; + setHasVideoTrack(false); + + const likelyVideoMimeType = inferVideoMimeType({ file }); + const codecLikelyUnsupported = + message.includes('cannot play video format') || + message.includes('Unable to load video media in this browser') || + message.includes('Video track could not be decoded by this browser'); + + if (likelyVideoMimeType && codecLikelyUnsupported) { + setCodecDiagnostic( + `This file appears to use an unsupported video codec for this browser (${likelyVideoMimeType}). Try MP4 with H.264 video + AAC audio.`, + ); + } + showStatusMessage(`Load failed: ${message}`); } }, @@ -76,6 +151,8 @@ function useFilePickerRowProps(): FilePickerRowResolvedProps { resetPlaybackStarted, setTitleMetadata, setHasGraphicsTrack, + setHasVideoTrack, + setCodecDiagnostic, showStatusMessage, ], ); @@ -101,6 +178,7 @@ function useFilePickerRowProps(): FilePickerRowResolvedProps { return { showPerfDiagnostics, canExportPerf: perfSummary !== null, + codecDiagnostic, handleTrackSelect, handleExportPerfArtifact, }; diff --git a/apps/framework-demo/src/app/components/FrameworkDemo/components/StageDisplay/StageDisplay.component.tsx b/apps/framework-demo/src/app/components/FrameworkDemo/components/StageDisplay/StageDisplay.component.tsx index db33711..5bc6f07 100644 --- a/apps/framework-demo/src/app/components/FrameworkDemo/components/StageDisplay/StageDisplay.component.tsx +++ b/apps/framework-demo/src/app/components/FrameworkDemo/components/StageDisplay/StageDisplay.component.tsx @@ -16,6 +16,7 @@ function StageDisplay({ children }: StageDisplayProps) { const { canvasRef, audioRef, + videoRef, showPerfDiagnostics, compatibilityWarning, statusMessage, @@ -23,6 +24,7 @@ function StageDisplay({ children }: StageDisplayProps) { viewState, titleMetadata, hasGraphicsTrack, + hasVideoTrack, perfSummary, hasTrack, showTitle, @@ -58,7 +60,15 @@ function StageDisplay({ children }: StageDisplayProps) { ref={canvasRef} width={300} height={216} - className={hasGraphicsTrack ? undefined : styles.canvasHidden} + className={ + hasGraphicsTrack && !hasVideoTrack ? undefined : styles.canvasHidden + } + /> +