Controlling Media with JavaScript

Scripting playback with the media API

play() and pause()

Every HTMLMediaElement<video> and <audio> alike — exposes play() and pause() methods. Calling them from JavaScript gives you complete control over playback without relying on the browser's built-in controls attribute.

play() returns a Promise

This is the detail that surprises most developers: media.play() does not return undefined — it returns a Promise that resolves when playback has actually started, or rejects if the browser blocks it (e.g., an autoplay policy violation or the user navigating away). Always handle the rejection to avoid uncaught promise errors in the console.

const video = document.getElementById('my-video'); // Safe play: catch any autoplay rejection video.play().catch(err => { console.warn('Playback prevented:', err.message); }); // pause() is synchronous — no Promise video.pause();

Reflecting state with events, not polling

Rather than checking video.paused in a loop, listen for the play and pause events that the element fires whenever its state changes. This works whether the state change was triggered by your JavaScript, the native controls, or an autoplay policy. The demo below wires up a single toggle button that stays in sync through these events.

const video = document.getElementById('ctl-video'); const btn = document.getElementById('ctl-btn'); // Keep button label in sync with actual play/pause state video.addEventListener('play', () => { btn.textContent = 'Pause'; btn.dataset.playing = 'true'; }); video.addEventListener('pause', () => { btn.textContent = 'Play'; btn.dataset.playing = 'false'; }); btn.addEventListener('click', () => { if (video.paused) { video.play().catch(err => console.warn(err)); } else { video.pause(); } }); <video id="ctl-video" poster="../assets/poster.jpg" width="480" height="320"> <source src="../assets/sample-video.mp4" type="video/mp4"> <source src="../assets/sample-video.webm" type="video/webm"> </video> <div class="btn-row"> <button id="ctl-btn" class="btn-play-toggle" data-playing="false">Play</button> </div>

Press Play — the button label flips to "Pause" the moment playback starts, driven by the play event, not by a flag you manage yourself. Press again and the pause event resets it. The browser's own logic decides the authoritative state; you just reflect it.

Reading and setting properties

The HTMLMediaElement interface exposes several readable and writable properties that describe — and control — the media's current state.

Property Type Readable Writable Notes
duration number (seconds) Yes No Available after loadedmetadata fires. NaN beforehand.
currentTime number (seconds) Yes Yes Setting it seeks to that position. Values are clamped to [0, duration].
paused boolean Yes No true when not playing (including before first play).
volume number 0–1 Yes Yes Out-of-range values throw a DOMException.
muted boolean Yes Yes Independent of volume. Toggling muted is the standard un-mute pattern.
playbackRate number Yes Yes 1.0 = normal, 0.5 = half speed, 2.0 = double. Most browsers support roughly 0.5–4×.

Tracking progress with timeupdate

The timeupdate event fires several times per second while the media is playing. It is the standard hook for updating a custom seek bar or time display. Read currentTime and duration inside the handler:

const video = document.getElementById('props-video'); const bar = document.getElementById('props-bar'); const readout = document.getElementById('props-readout'); video.addEventListener('loadedmetadata', () => { bar.max = video.duration; // set bar range once duration is known }); video.addEventListener('timeupdate', () => { bar.value = video.currentTime; readout.textContent = video.currentTime.toFixed(1) + 's / ' + video.duration.toFixed(1) + 's'; });

Seeking by setting currentTime

Writing to currentTime causes an immediate seek — the same mechanism used by a custom seek bar. The demo below uses two buttons that jump backward and forward by 2 seconds, plus a live progress bar and readout driven by timeupdate.

// Jump forward 2 seconds (clamped automatically to duration) video.currentTime = Math.min(video.currentTime + 2, video.duration); // Jump back 2 seconds (clamped automatically to 0) video.currentTime = Math.max(video.currentTime - 2, 0); <video id="props-video" controls poster="../assets/poster.jpg" width="480" height="320"> <source src="../assets/sample-video.mp4" type="video/mp4"> <source src="../assets/sample-video.webm" type="video/webm"> </video> <div class="time-readout"> <progress id="props-bar" value="0" max="1"></progress> <output id="props-readout">0.0s / —</output> </div> <div class="btn-row"> <button id="props-back" class="btn-seek">← −2s</button> <button id="props-fwd" class="btn-seek">+2s →</button> </div>
0.0s / —

The progress bar and text readout update in real time as you play the video or use the seek buttons. You can also scrub with the native controls and watch the JS readout stay in sync — both are reading from the same source of truth: the element's currentTime.

Tip: duration is NaN until the browser has downloaded enough of the file to know the total length. Always wait for the loadedmetadata event before reading or displaying duration.

Media events

The media element fires a rich set of events that let you respond to every phase of the playback lifecycle. Here are the most useful ones:

Event Fires when…
loadedmetadata The browser has parsed the file headers: duration, videoWidth, and videoHeight are now valid.
play Playback has been requested and the element transitions from paused to playing.
pause Playback is suspended (user paused, .pause() called, or tab hidden).
timeupdate The playback position changed — fires roughly 4–66 times per second while playing.
ended Playback reached the end of the media and there is no loop attribute.
volumechange volume or muted was changed.
seeking A seek operation has started (e.g., user scrubbing or currentTime was set).
seeked The seek has completed and playback is ready at the new position.
waiting Playback has stalled because the browser is waiting for more data (buffering).

Listening for events with addEventListener

Use addEventListener rather than setting on* properties directly — it lets you attach multiple independent handlers to the same event without the later one overwriting the earlier one.

const video = document.getElementById('my-video'); // Log every event of interest ['play', 'pause', 'ended', 'seeking', 'seeked', 'waiting', 'volumechange'].forEach(type => { video.addEventListener(type, () => console.log('event:', type)); }); // Act on a specific event video.addEventListener('ended', () => { console.log('Video finished — showing replay button'); });

The live demo below logs events to a visible panel so you can see the sequence in action. Play, pause, scrub, and let it finish to observe the event order.

Event log:

(play the video to see events)

Notice that timeupdate fires far more often than the others — several times per second. That is why the log coalesces it into a single counter line rather than flooding the output.

Fullscreen and Picture-in-Picture

Two browser APIs let you move a video element out of its normal document flow: the Fullscreen API expands an element to cover the entire screen, and the Picture-in-Picture API (PiP) floats a small video overlay that persists while the user switches tabs or applications. Both require a user gesture — a click, tap, or keypress — to work; calling them from an autonomous script will throw or be silently ignored.

Fullscreen API

Call element.requestFullscreen() on any element (not just video). To exit, call document.exitFullscreen(). The fullscreenchange event fires on document when the state transitions. Feature-detection is straightforward because the property is present on all modern browsers without a prefix.

const video = document.getElementById('my-video'); const fsBtn = document.getElementById('fullscreen-btn'); fsBtn.addEventListener('click', () => { if (!document.fullscreenElement) { // Enter fullscreen video.requestFullscreen().catch(err => { console.warn('Fullscreen request failed:', err.message); }); } else { // Exit fullscreen document.exitFullscreen(); } }); // Update button label when state changes document.addEventListener('fullscreenchange', () => { fsBtn.textContent = document.fullscreenElement ? 'Exit Fullscreen' : 'Fullscreen'; });

Picture-in-Picture API

PiP is video-specific. Feature-detect with two guards before calling: 'requestPictureInPicture' in video confirms the method exists, and document.pictureInPictureEnabled confirms the browser has not disabled the feature (e.g., it can be blocked by a Permissions-Policy header). To exit, call document.exitPictureInPicture().

const video = document.getElementById('my-video'); const pipBtn = document.getElementById('pip-btn'); // Feature-detect before wiring up the button if ('requestPictureInPicture' in video && document.pictureInPictureEnabled) { pipBtn.disabled = false; pipBtn.addEventListener('click', async () => { try { if (document.pictureInPictureElement) { await document.exitPictureInPicture(); } else { await video.requestPictureInPicture(); } } catch (err) { console.warn('PiP failed:', err.message); } }); video.addEventListener('enterpictureinpicture', () => { pipBtn.textContent = 'Exit Picture-in-Picture'; }); video.addEventListener('leavepictureinpicture', () => { pipBtn.textContent = 'Picture-in-Picture'; }); } else { pipBtn.textContent = 'PiP not supported'; }

The live demo below wires up both buttons on the same video element. Click "Fullscreen" to expand the player, or "Picture-in-Picture" to pop it into a floating overlay. Both buttons disable themselves automatically when the feature is unavailable.

For a complete example that combines a custom play/pause toggle, a scrubbing seek bar, volume control, and both Fullscreen and PiP into a single polished interface, see Example 01: Custom Player.