DEV Community

Kent Spencer
Kent Spencer

Posted on • Originally published at drewspen.blogspot.com on

GTM HTML5 Video & Audio Engagement

Published · Tags: GTM, GA4, HTML5 Video, HTML5 Audio, Brightcove, CSP, dataLayer, Event Listeners, Custom Templates, Video Engagement

HTML5

Track play, pause, progress-percent, and completion for any native HTML5 <video> or <audio> element — Brightcove, self‑hosted MP4, podcasts embedded as <audio>, or anything else that ultimately renders as one of those two tags — using a single Custom HTML listener tag and a native GA4 Event tag. One CSP hash, covers both media types, no per‑vendor JavaScript.

Note: This recipe is a 2026 refactor of a video‑tracking container I originally built in 2016 for a client running Brightcove — a container that, remarkably, is still live on their site today. The underlying pattern is not mine: it is a direct implementation of David Vallejo's excellent 2014 "Tracking HTML5 Videos with GTM" article on Thyngster, which first laid out the tag/rule/macro approach of listening to the native HTML5 media events and pushing them to the dataLayer. All credit for the original concept belongs to him. The JSON container export can be downloaded from Google Drive below.
⬇ Download the GTM Container JSON from Google Drive

Background and Rationale

Back in 2016 I built a Brightcove video‑engagement container for a client, modeled closely on David Vallejo's 2014 Thyngster write‑up: listen for the browser's native play, pause, timeupdate, and ended events on every <video> element on the page, bucket the progress into percent thresholds, and push everything to the dataLayer as a single reusable event. That container is, as of this writing, still running in production for that client a decade later — a good sign the underlying idea has aged well, even as Brightcove's own player markup and GTM's own tooling have moved on.

This post refreshes that original implementation for 2026: it swaps Universal Analytics event pushes for a native GA4 Event tag, replaces vendor‑specific URL parsing with a reusable sandboxed Custom Template, and — the one functional addition beyond Vallejo's original scope — extends the listener to cover <audio> elements as well as <video>, since podcast players and audio‑only embeds share the exact same HTMLMediaElement event API. Everything else — the tag/trigger/variable shape, the reliance on native browser events instead of polling or platform SDKs — is Vallejo's approach, unchanged in spirit.

What's Inside the Container

The exported container (gtm-html5-video-audio-engagement.json) lives in one folder, HTML5 Video Engagement, alongside a shared Analytics folder for the GA4 stream constant. It contains two tags, two triggers, a set of variables covering platform/URL parsing and event‑name mapping, and one Custom Template (URL Component Extractor) that replaces hand‑written per‑vendor JavaScript.

Tags

Tag Type Fires On Purpose
HTML5 Video Listener Custom HTML Window Loaded The only custom JavaScript in the recipe. Attaches native event listeners to every <video> and <audio> element on the page and pushes a gtm.video dataLayer event on start, pause, progress‑percent, and completion.
HTML5 Video Engagement Send Event GA4 Event (gaawe) Custom Event: gtm.video Native, already‑sandboxed GA4 Event tag — not Custom HTML. Maps the built‑in Video variables plus the parsed URL variables onto GA4 event parameters using GA4 Enhanced Measurement's own naming (video_start, video_progress, video_pause, video_complete) for consistency with any Enhanced Measurement data already in the property.

Triggers

Trigger Type Conditions
Window Loaded - All Pages Window Loaded Fires the single HTML5 Video Listener tag once per page. The listener itself uses a MutationObserver to catch media elements that render after this point, so no per‑platform Element Visibility triggers are needed.
Custom Event - gtm.video Custom Event {{_event}} equals gtm.video, filtered so {{HTML5 Video Platform Lookup}} matches ^[a-zA-Z].* (i.e., a resolvable platform name). Uses the same event name GTM's own native YouTube video tracking uses. One trigger covers start/progress/pause/complete; branch in tags with {{Video Status}} if you need per‑status tags, exactly as you would with a native YouTube trigger.

Note — scope this trigger to your video pages: As published, Window Loaded - All Pages fires on every page. Because the listener's own DOM query (querySelectorAll('video, audio')) is cheap and a no‑op when nothing is found, this is safe — but it is wasteful to run it site‑wide. In your own build, add a page‑path or page‑hostname condition to this trigger (e.g., Page Path matches RegEx a path prefix like ^/videos/, or ^/podcasts/ for audio) so the listener only attaches on pages where you know your target <video>/<audio> elements actually appear. This is also the point at which any applicable host names belong — if your video pages live on a specific subdomain or the Brightcove player is only embedded on a defined set of URLs, restrict the trigger there rather than letting it evaluate globally.

The Listener Tag: How It Works

The HTML5 Video Listener is static vanilla JavaScript with zero {{GTM variable}} interpolation in the tag body. That's deliberate: because the script text never changes between page loads, a CSP script-src 'sha256-...' hash computed for it never changes either. All configuration lives in a plain CONFIG object at the top of the script and in optional data-* attributes on the media element itself — never in GTM variables baked into the tag body.

var CONFIG = {
  eventName: 'gtm.video', // same event GTM's native YouTube tracking uses
  progressThresholds: [1, 10, 25, 50, 75, 90, 99], // % markers
  watchForNewVideos: true, // MutationObserver, for players injected after load
  mediaSelector: 'video, audio' // tracks both <video> and <audio> elements
};

function getProvider(video) {
  // 'html5video' for <video>, 'html5audio' for <audio>
  return video.tagName === 'AUDIO' ? 'html5audio' : 'html5video';
}

['play', 'pause', 'ended', 'timeupdate'].forEach(function(evt) {
  video.addEventListener(evt, handleEvent, false);
});
Enter fullscreen mode Exit fullscreen mode

A few implementation details worth calling out:

  • Video and audio, one selector. document.querySelectorAll('video, audio') binds the same four listeners — play, pause, ended, timeupdate — to both element types. Every downstream piece (the 8 built‑in Video variables, the URL‑parsing variables, the GA4 tag) is unchanged and now covers audio for free; only gtm.videoProvider branches on tagName to report html5video or html5audio.
  • Late‑rendered players are still caught. A MutationObserver watches for nodes added after the initial scan, which covers Brightcove‑style players that inject their own <video> node asynchronously, modal/lightbox players, and SPA route changes — without needing a separate Element Visibility trigger per platform.
  • Title and threshold overrides via data-* attributes, not GTM variables. An author can set title="", data-video-title="", or data-video-progress-thresholds="10,50,90" directly on the media tag to override the auto‑derived title or the default percent markers, on a per‑page or per‑player basis, without ever touching this script or its CSP hash.

Note — only this tag needs a CSP hash: This is the only custom JavaScript in the entire recipe. Because it contains no variable interpolation, you compute one script-src 'sha256-...' hash for it and that hash never has to be recomputed as long as the listener code itself doesn't change. Every other tag in this container — the GA4 Event tag, and the URL‑parsing Custom Template variables — runs as either a native GTM tag type or Sandboxed JavaScript, neither of which is subject to CSP script‑hash requirements, since both execute inside GTM's own sandbox, already covered by the https://www.googletagmanager.com script‑src entry your site already needs.

Reusing gtm.click Instead of Reinventing It

Where this recipe deliberately does not add anything new: play/pause button interaction beyond the native media events. Because <video controls> and <audio controls> expose native play/pause events directly on the element, there is no need to separately intercept clicks on the player's own play/pause button — the browser already tells you when playback starts and stops. If your implementation adds custom UI controls layered on top of the native player (a custom play button overlay, for example), that interaction is ordinary DOM interaction and GTM's existing gtm.click / Click trigger machinery — the same built‑in click listener and click variables ({{Click Element}}, {{Click Classes}}, {{Click ID}}, etc.) already ship with GTM — is the right tool, rather than adding bespoke click‑handling JavaScript to the listener tag.

Platform Detection: The URL Component Extractor Template

Rather than hand‑writing regex against {{Video URL}} for every vendor, one sandboxed Custom Template — URL Component Extractor — is instantiated five times, each returning a different piece of the URL (host, path, last path segment, extension, query string) using the sandboxed parseUrl API. Every instance references {{Video URL}} only inside the sandbox, never inside a Custom HTML tag, so none of it is CSP‑relevant.

Variable Component Feeds Into
HTML5 Video URL Host host HTML5 Video Platform Lookup (below) and the GA4 video_url_host parameter
HTML5 Video URL Path pathname video_url_path
HTML5 Video URL Filename last path segment video_url_filename
HTML5 Video URL Extension file extension video_url_extension
HTML5 Video URL Query query string video_url_query

The HTML5 Video Platform Lookup variable is a Simple Lookup Table keyed off {{HTML5 Video URL Host}}, matching wildcard host patterns such as *brightcove*, *bcove*, *wistia*, *youtube*, *vimeo*, *vidyard*, and *soundcloud* to a friendly platform label. Its own note describes it as a config‑only replacement for what used to be a hand‑written "Coalesce BC ID and Self Hosted" Custom JS variable — this is the place to add a row, not edit code, when a new media platform shows up in your currentSrc values. This is also where any applicable host names belong: if your Brightcove CDN, self‑hosted media bucket, or CDN edge domain differs from the defaults shown here, add or edit rows in this lookup table rather than modifying the listener script.

GA4 Event Mapping

The HTML5 Video Engagement Send Event tag is a native GA4 Event tag (type gaawe), not Custom HTML — already sandboxed by Google. Its event name comes from HTML5 Video Event Name Lookup, a Simple Lookup keyed on {{Video Status}} that maps startvideo_start, progressvideo_progress, pausevideo_pause, and completevideo_complete — the same names GA4 Enhanced Measurement's own native video tracking uses, so this recipe's rows sit consistently alongside any Enhanced Measurement video data already flowing into the property.

Event Parameter Source Variable
video_provider Video Provider (built‑in)
video_title Video Title (built‑in)
video_url Video URL (built‑in)
video_current_time Video Current Time (built‑in)
video_duration Video Duration (built‑in)
video_percent Video Percent (built‑in)
visible Video Visible (built‑in)
video_status Video Status (built‑in)
video_platform HTML5 Video Platform Lookup
video_url_host / _path / _filename / _extension / _query the five URL Component Extractor instances

Replace the {{Measurement Stream ID}} Constant variable (placeholder: G-AAAAAAAA) with your live GA4 Measurement ID after import; the tag references it through measurementIdOverride so it can send independently of an existing GA4 Configuration tag, or you can point it at your existing config tag's own Measurement ID variable if you'd rather not override per‑tag.

Tested With: the Brightcove In‑Page Embed

This recipe was validated against a standard Brightcove In‑Page embed — the same responsive‑wrapper pattern published on CodePen by Broadridge Media:

<!-- start brightcove In-Page embed -->
  <div style="display: block; position: relative; max-width: 100%;">
    <div style="padding-top: 56.25%;">
      <video style="width: 100%; height: 100%; position: absolute; top: 0px; bottom: 0px; right: 0px; left: 0px;border:0;border-radius:5px" data-video-id="4093643993001" data-account="1752604059001" data-player="VyqgG8mql" data-embed="default" class="video-js"
        controls></video>
      <script src="//players.brightcove.net/1752604059001/VyqgG8mql_default/index.min.js"></script>
    </div>
  </div>
<!-- end brightcove In-Page embed -->
Enter fullscreen mode Exit fullscreen mode

Brightcove's player script replaces this markup with its own rendered <video> element after the page has loaded — exactly the "player injected later" case the listener's MutationObserver is there to catch. Note the data-video-id, data-account, and data-player attributes Brightcove leaves on the element: depending on your implementation, you may want to add an additional GTM variable (a small Custom JavaScript or sandboxed template reading {{Click Element}}.getAttribute('data-video-id') equivalent) to surface Brightcove's own video ID as its own event parameter, rather than relying solely on the parsed currentSrc. Or, if your media platform exposes different metadata attributes altogether, the listener's getVideoTitle() helper is the natural place to read them instead — this is implementation‑ and platform‑dependent, so treat the shipped script as a starting point rather than a final answer for your specific player.

How to Import

  1. Download the JSON from the Google Drive link above.
  2. In GTM, go to Admin → Import Container.
  3. Upload gtm-html5-video-audio-engagement.json.
  4. Choose Merge (not Overwrite) to preserve your existing container setup, and pick a new workspace so you can review the diff before publishing.
  5. Update the Measurement Stream ID Constant variable to your live GA4 Measurement ID.
  6. Add a page‑path or hostname condition to the Window Loaded - All Pages trigger so the listener only attaches on pages where your <video> / <audio> elements actually live.
  7. Review and, if needed, extend the rows in HTML5 Video Platform Lookup to match your actual player host names (Brightcove account/player subdomain, self‑hosted CDN, etc.).
  8. Compute a CSP script-src 'sha256-...' hash for the HTML5 Video Listener tag's script body if your site enforces a strict CSP, and add it to your policy. No other tag in this container needs one.
  9. Verify consent settings: the listener requires analytics_storage in this export — adjust to match your CMP setup.
  10. Open GTM Preview mode, load a page containing your video/audio embed, and confirm the gtm.video event fires in the dataLayer on play, at each progress threshold, on pause, and on completion, and that the GA4 Event tag fires alongside it.

The full source — container JSON and documentation — is also published on GitHub. Renewed thanks to David Vallejo / Thyngster for the original 2014 pattern this recipe is built on. If you extend the platform lookup table, add support for additional data-* metadata attributes, or adapt this for a different media platform, open a pull request or issue.

⬇ Download GTM Container JSON

Top comments (0)