PAC Media Player
A self-contained YouTube or local MP4 player sample from Prime Access Consulting. It uses a vendored Video.js 10 runtime with a hand-composed control bar and PAC-authored accessibility behavior. No Video.js skin is used, and Video.js's defaults are not represented here as accessible.
Sample player
Markup
The player is assembled from Video.js 10 custom elements, without a packaged skin, rather than a generated player wrapper.
- A
video-playerelement holds the player state. Inside it, amedia-containeris the visible player box and the fullscreen target: aplayer__stagewrapper for the media element, followed by a hand-composed control bar in normal flow below the video. js/player-config.jscreates the media element in the stage: Video.js'syoutube-videofor a YouTube ID, a plainvideofor an MP4, or a plainaudiofor an audio file. Video.js has no provider that picks an engine from the source, so this keeps one markup for every sample page; the audio sample uses it without the video stage and the controls only a video has.- The control bar order is skip-back, play/pause, skip-forward, mute, volume, time, seek, playback rate, captions, audio track, picture-in-picture, fullscreen. The bar is three layout groups in that order: transport, timeline, and extras. On a narrow player each group is a row of its own, every button is the same square, and the volume slider is hidden while Mute stays (a layout choice: the device volume applies, and the keyboard volume shortcuts still work).
- The time read-out is
aria-hidden: the Seek slider's value text already says the position and the duration (for example “30 seconds of 1 minute”), so a screen reader hears the time once. - Buttons and sliders are Video.js elements with small inline icons; a slider takes focus on its thumb. Playback rate and audio track are native buttons, each followed by a Video.js
media-menuwhose radio group creates the menuitemradio options. config.jsonis the only file an integrator changes: the source (youtubewith a video ID,mp4with a local URL, title, and optional WebVTT captions, oraudiowith a local URL and title), every PAC-authored label, and the player's colors. A page may override it section by section with a JSON block, as the local MP4 sample does for its source.- The picture-in-picture button is available for a capable local video source. It stays visible, disabled, and named unavailable for YouTube.
- No autoplay is used. Video.js fetches no poster for an embed; the pre-play cover fetches the one thumbnail this page shows.
Styling
No Video.js skin is loaded, so everything visible is styled locally for a clear, compact presentation.
- The video stage keeps a 16:9 area; control rows add height below it instead of covering it, so the picture stays visible on a phone. On a narrow player the bar is three rows at default text size: the transport buttons, the time read-out with the seek slider, and the remaining controls. From 44.5rem of player width (712px at default text) the bar is one row with a volume slider; that is where the row was measured to fit, so a player in an 800px content column keeps it. The breakpoint is in rem, so a wide player at enlarged text gets the compact layout. The one row wraps rather than clipping a control. Controls keep their source order. Open menus are in the browser's top layer, positioned against their button inside the viewport: they open upward when there is room, can extend past the player instead of being clipped to it, and scroll only in a short viewport.
- Control visuals are driven by the data-attributes and custom properties Video.js sets on its elements (for example
data-pausedfor the play icon and--media-slider-fillfor slider position). - Every player color is one of thirteen named custom properties (
--pac-color-control-barand so on) thatconfig.json'scolorssection overrides with a style sheet rule. The Contrast Themes rules use system colors directly, so configured colors never outrank them, and a configured pair under its WCAG contrast minimum is reported in the console. - The control bar is always visible and never overlays the video: it sits below the stage in normal flow and stays in the accessibility tree and tab order throughout playback.
- Keyboard focus shows a 4px accent outline via
:focus-visible; on a slider the outline surrounds the whole slider rather than its small thumb. - Caption cues are the browser's own, styled with
::cue, including in Contrast Themes. Their size follows the player's width: 2% of it on a wide player, the reader's text size on a narrower one but only up to what lets a 42-character line fit, and never under 12px.
JavaScript
js/runtime.js registers the Video.js elements; small
PAC modules under js/ then provide the tested
interaction and accessibility behavior, reading and acting through
the player store.
- The Video.js files under
vendor/videojs/are only the ones the player loads, pinned at 10.3.0, with no CDN or runtime dependency. - No Video.js hotkeys or gestures are included;
keyboard.jssupplies the scoped PAC map (15-second skip, 1% and 5% seek, volume, Home/End, number-key seeking, and Space to play/pause) via a capture-phase listener on the player, which runs before a focused slider's own keys. While the volume slider has focus, every arrow key and Home/End set the volume instead, as a focused slider expects. There is no hidden swipe-to-seek gesture. capabilities.jskeeps the volume slider, fullscreen button, picture-in-picture button, and the menu buttons visible, focusable, and in the accessibility tree when their feature is absent: disabled and namedVolume unavailable,Fullscreen unavailable, orPicture-in-Picture unavailable, where Video.js would hide them. iOS Safari sets volume only with the device buttons; the unavailable volume slider says so in its title. Muting is never gated.- The skip buttons show a circular arrow with the number
15inside it. Their names,Skip backward 15 secondsandSkip forward 15 seconds, contain that visible number and say the unit in full, so voice input can match the label and screen readers do not spell out an abbreviation. The playback-rate button's name includes its visible current rate. The caption button names its next action,Turn captions offorTurn captions on, and its icon fills in while captions are showing. player-config.jsloadsconfig.json(and a page's JSON block) and creates the media element;labels.jsholds every PAC-authored name and message, from the file or the built-in English, and names the fixed controls;colors.jsapplies configured colors and checks their contrast.captions.jsdefaults a sideloaded caption track on once and names the captions button;menus.jsnames the menu buttons (Video.js menus already focus the checked option, close on a choice or Escape, and return focus);layout.jskeeps a focused menu option and a focused fullscreen control in view while the player resizes; andannounce.jsannounces keyboard volume changes.- Video.js buttons answer the plain click that access technology sends, so every control works with screen-reader activation without a shim.
- Starting playback never moves focus and never announces anything beyond the play control's own rename to Pause; nothing else speaks while media plays. Video.js's status announcer, which speaks on play and pause, is not included. A Play pressed before the media is ready plays once it is.
youtube.jstakes the YouTube embed out of the tab order and hides it from assistive technology, covers the paused embed until the first play so YouTube's painted play button is not a second Play, and makes the video area click-to-play — see below.- The player is exposed as
window.videoLab.playerfor console debugging; its state and actions arewindow.videoLab.player.store.
What the remote provider costs
A YouTube source introduces constraints that client integrations need to account for.
- YouTube's script runs on this page. Video.js's
youtube-videoelement is vendored and loaded on demand, but it controls the embed through YouTube's iframe API, which it loads into this page fromwww.youtube.com. So on load the non-origin requests are theyoutube-nocookie.comembed, that script, and the cover's one thumbnail. Video.js adds no preconnect hints. The media itself is unavoidably remote — that part cannot be vendored. - One control surface, not two. The element embeds with
controls=0,iv_load_policy=3, andrel=0, so YouTube's control bar and annotations are off and related videos stay within the video's own channel. The embed takes no pointer events, andjs/youtube.jssetstabindex="-1"andaria-hidden="true"on it, so neither Tab nor a screen reader's reading cursor reaches YouTube's own links and buttons. The embed URL does not switch YouTube's keyboard handling off; it simply cannot receive keys. - Captions are YouTube's. A sideloaded WebVTT track does not apply to a YouTube source. Once playback starts, the captions button offers YouTube's own caption tracks, if the video has any, and YouTube draws them inside the embed; before that it reads
Captions unavailable. - Picture-in-picture is gone, and alternate audio tracks were never available. Both buttons stay visible and disabled.
- YouTube still paints its own play button over a paused video, and no embed parameter removes it, so a paused embed would show two Play controls: ours and an inert one. Until the first play,
js/youtube.jscovers the embed with anaria-hidden, non-focusable cover showing the video's own thumbnail, so there is one Play and the page still previews the video. A video without a full-size thumbnail falls back to the standard frame every video has, and if no thumbnail loads the cover is plain black. After the first play the cover is gone for good: YouTube repaints its button over a paused frame mid-stream, and covering that would hide the frame itself; that reappearing button cannot be suppressed and does nothing. The video area is click-to-play through our own player, before and after, so the affordance does what it looks like it does. The click deliberately does not move focus: the player root is a tab stop named “Media player”, and letting focus land on it would make a screen reader re-read the entire player. - Playback rate, volume, seeking, time display, and fullscreen still work where the browser offers them. Rate and volume go through YouTube's iframe API; fullscreen uses the player box rather than the iframe. On iPhone Safari an iframe cannot go fullscreen and the page cannot set volume, so there the fullscreen button and the volume slider read unavailable and stay in the accessibility tree.
- Ads, buffering, and quality selection remain YouTube's. Nothing on this page can influence them.