diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 9eee00d..41a43f1 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -28,10 +28,18 @@ - Runnable apps live under `apps/`, including `demo`, `framework-demo`, and the Storybook apps. - Keep package boundaries intact between `packages/cdg-*`, demo apps, and Storybook apps. - Make focused changes; avoid broad refactors unless the task requires them. +- Prefer clear segmentation: keep constants and pure utilities in dedicated files instead of mixing them into large orchestration modules. +- Prefer package/module boundaries that separate contracts, capabilities/utilities, and runtime orchestration when expanding scope. - Keep implementation guides and example stories aligned with the code they document. - Framework-agnostic implementation guidance must align with `apps/demo`. - React implementation guidance must align with `apps/framework-demo`. +## Public Exports + +- When adding reusable developer-facing resources (types, constants, utilities, helpers), consider promoting them to public exports. +- Validate public export candidates before exposing them: stable naming, tests, documentation, and compatibility impact. +- Prefer exporting from package entrypoints (`index.ts` / `exports`) intentionally; avoid accidental deep-import-only APIs. + ## Monorepo Workflow - Use `pnpm` for installs and script execution; do not switch to `npm` or `yarn` for workspace tasks. @@ -43,6 +51,9 @@ ## Documentation +- Any code change must include accompanying documentation updates. +- Acceptable documentation updates include one or more of: Storybook docs, package/app README updates, or inline JSDoc for the touched API/logic. +- For exported APIs, prefer JSDoc updates in addition to user-facing docs when behavior/contracts change. - Update Storybook documentation when public behavior, integration flow, or architecture guidance changes. - Keep runtime contracts in `apps/storybook-hub/docs`. - Keep framework-specific implementation guides next to their example stories in `apps/storybook-web/stories` and `apps/storybook-react/stories`. @@ -50,7 +61,9 @@ ## Build and Test +- Any code change must include corresponding test updates or additions. - Run the smallest relevant validation for the change when possible. - For documentation changes, prefer `pnpm run ci:docs` and the relevant Storybook build. - For broader changes, use the repo validation commands already documented in Storybook contribution guidance. - Keep coverage thresholds unchanged, but aim to stay at least 5 percentage points above the enforced minima when practical (currently branch >= 85% and functions >= 95%). +- Do not land code changes that reduce coverage below enforced thresholds; update/add tests to maintain threshold compliance. diff --git a/.github/instructions/react-framework.instructions.md b/.github/instructions/react-framework.instructions.md index ebf53fd..0fda647 100644 --- a/.github/instructions/react-framework.instructions.md +++ b/.github/instructions/react-framework.instructions.md @@ -22,6 +22,7 @@ applyTo: 'apps/framework-demo/src/**/*.{ts,tsx}, apps/storybook-react/**/*.{ts,t - Function files: `name.function.ts` - Constant files: `name.constant.ts` - Type files: `name.type.ts` +- Keep constants and pure utilities in dedicated files instead of embedding them in component render files. ## Folder Structure @@ -75,6 +76,7 @@ export default Example; ## Barrel Exports - Use folder-level `index.ts` barrel exports. +- Export reusable developer-facing utilities/types intentionally through barrel files after validating naming, tests, and docs. ```ts export { default } from './Example.component'; 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.spec.ts b/apps/demo/src/app/app.element.spec.ts index b6d00b3..7a64567 100644 --- a/apps/demo/src/app/app.element.spec.ts +++ b/apps/demo/src/app/app.element.spec.ts @@ -62,6 +62,8 @@ const createDemoHarness = ({ title: string; artist: string; }; + mediaKind?: 'audio' | 'video'; + hasGraphics?: boolean; } | undefined; loadError?: Error; @@ -645,6 +647,107 @@ describe('AppElement', () => { document.body.removeChild(app); }); + it('stops playback when visibility changes to hidden', () => { + const harness = createDemoHarness({ + playerState: { status: 'playing' }, + }); + + const visibilityDescriptor = Object.getOwnPropertyDescriptor( + document, + 'visibilityState', + ); + + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: () => 'hidden', + }); + + registerAppElement(); + + const app = document.createElement('cdgplayer-demo-app') as AppElement; + document.body.appendChild(app); + + document.dispatchEvent(new Event('visibilitychange')); + + expect(harness.player.stop).toHaveBeenCalled(); + + document.body.removeChild(app); + + if (visibilityDescriptor) { + Object.defineProperty(document, 'visibilityState', visibilityDescriptor); + } else { + Reflect.deleteProperty(document, 'visibilityState'); + } + }); + + it('stops playback when receiving stop-playback window message', () => { + const harness = createDemoHarness({ + playerState: { status: 'playing' }, + }); + + registerAppElement(); + + const app = document.createElement('cdgplayer-demo-app') as AppElement; + document.body.appendChild(app); + + window.dispatchEvent( + new MessageEvent('message', { + data: { type: 'cdg:stop-playback' }, + }), + ); + + expect(harness.player.stop).toHaveBeenCalled(); + + document.body.removeChild(app); + }); + + it('shows codec diagnostics for unsupported video paths', async () => { + vi.spyOn(HTMLMediaElement.prototype, 'canPlayType').mockReturnValue(''); + + const harness = createDemoHarness({ + loadError: new Error('Unable to load video media in this browser'), + playerState: { status: 'error' }, + }); + + registerAppElement(); + + const app = document.createElement('cdgplayer-demo-app') as AppElement; + document.body.appendChild(app); + + const input = app.querySelector('#track-input'); + const codecDiagnostic = app.querySelector( + '[data-role="codec-diagnostic"]', + ); + const file = new File(['video-content'], 'unsupported.mp4', { + type: 'video/mp4', + }); + + if (!input) { + throw new Error('Expected file input to exist.'); + } + + Object.defineProperty(input, 'files', { + configurable: true, + value: { + item: (index: number) => (index === 0 ? file : null), + length: 1, + 0: file, + }, + }); + + input.dispatchEvent(new Event('change')); + await Promise.resolve(); + await Promise.resolve(); + + expect(codecDiagnostic?.textContent).toContain( + 'unsupported video codec for this browser', + ); + expect(codecDiagnostic?.classList.contains('is-visible')).toBe(true); + expect(harness.player.stop).toHaveBeenCalledOnce(); + + document.body.removeChild(app); + }); + it('surfaces non-Error initialization failures when player setup throws', () => { createPlayerMock.mockImplementation(() => { throw 'setup exploded'; 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..aec94eb 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(), @@ -249,4 +252,92 @@ describe('Framework demo hook edge cases', () => { 'Load failed: Unknown load error', ); }); + + it('shows proactive codec warning when browser reports weak support for selected video mime type', async () => { + const setCodecDiagnostic = vi.fn(); + const showStatusMessage = vi.fn(); + const player = { + stop: vi.fn(), + load: vi.fn(async () => ({ + mediaKind: 'video', + hasGraphics: false, + metadata: { + title: 'Video', + artist: 'Artist', + }, + })), + }; + + vi.spyOn(HTMLMediaElement.prototype, 'canPlayType').mockReturnValue(''); + + mockUseFrameworkDemoContext.mockReturnValue( + createContextValue({ + player, + setTitleMetadata: vi.fn(), + setHasGraphicsTrack: vi.fn(), + setHasVideoTrack: vi.fn(), + setCodecDiagnostic, + showStatusMessage, + resetPlaybackStarted: vi.fn(), + }), + ); + + const { result } = renderHook(() => useFilePickerRowProps()); + const videoFile = new File(['video-content'], 'sample-video.mp4', { + type: 'video/mp4', + }); + + result.current.handleTrackSelect({ + target: { files: [videoFile] }, + } as never); + + await Promise.resolve(); + await Promise.resolve(); + + expect(setCodecDiagnostic).toHaveBeenCalledWith( + 'This browser reports limited support for video/mp4. For best compatibility, use MP4 (H.264 video + AAC audio).', + ); + }); + + it('shows unsupported codec guidance when player rejects video decode in catch path', async () => { + const setCodecDiagnostic = vi.fn(); + const showStatusMessage = vi.fn(); + const player = { + stop: vi.fn(), + load: vi.fn(async () => { + throw new Error('Unable to load video media in this browser'); + }), + }; + + mockUseFrameworkDemoContext.mockReturnValue( + createContextValue({ + player, + setTitleMetadata: vi.fn(), + setHasGraphicsTrack: vi.fn(), + setHasVideoTrack: vi.fn(), + setCodecDiagnostic, + showStatusMessage, + resetPlaybackStarted: vi.fn(), + }), + ); + + const { result } = renderHook(() => useFilePickerRowProps()); + const videoFile = new File(['video-content'], 'unsupported.avi', { + type: 'video/x-msvideo', + }); + + result.current.handleTrackSelect({ + target: { files: [videoFile] }, + } as never); + + await Promise.resolve(); + await Promise.resolve(); + + expect(setCodecDiagnostic).toHaveBeenCalledWith( + 'This file appears to use an unsupported video codec for this browser (video/x-msvideo). Try MP4 with H.264 video + AAC audio.', + ); + expect(showStatusMessage).toHaveBeenCalledWith( + 'Load failed: Unable to load video media in this browser', + ); + }); }); 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 + } + /> +