# Media and casting - JavaScript bridge API

> The lock-screen media notification your page drives, and handing a video to a TV.

- **Applies to:** Appwright
- **Source:** extracted from the app runtime and its per-method example files; this page is generated from them.
- **HTML:** https://freewebsitetoapk.com/docs/api/media

### `navigator.mediaSession`

```js
navigator.mediaSession + MediaMetadata
```

**Example**

Shows what your page is playing in a media notification and on the lock screen, with Previous / Play-Pause / Next / Stop buttons that call back into your page. Uses the normal Media Session API.

**Returns:** nothing; button presses call the handlers you set with `setActionHandler`, with `{ action }` as the standard says. **Needs:** turn on **Media Notification** in Step 4 (Access) when you build. AI-built apps get it switched on for you when the code uses `navigator.mediaSession`.

```js
var audio = document.getElementById('player');
var tracks = [
  { src: 'https://example.com/a.mp3', title: 'Morning', artist: 'Band', art: 'https://example.com/a.jpg' },
  { src: 'https://example.com/b.mp3', title: 'Evening', artist: 'Band', art: 'https://example.com/b.jpg' }
];
var index = 0;
var handlersReady = false;

function setupHandlers() {
  if (handlersReady || !('mediaSession' in navigator)) return;
  handlersReady = true;
  var ms = navigator.mediaSession;
  ms.setActionHandler('play', function () { audio.play(); });
  ms.setActionHandler('pause', function () { audio.pause(); });
  ms.setActionHandler('nexttrack', function () { playTrack(index + 1); });
  ms.setActionHandler('previoustrack', function () { playTrack(index - 1); });
  ms.setActionHandler('stop', function () { audio.pause(); audio.currentTime = 0; });
}

function playTrack(i) {
  index = (i + tracks.length) % tracks.length;
  var t = tracks[index];
  audio.src = t.src;
  audio.play();
  setupHandlers();   // on the first play, not at page load (see Notes)
  if ('mediaSession' in navigator && window.MediaMetadata) {
    navigator.mediaSession.metadata = new MediaMetadata({
      title: t.title, artist: t.artist, album: '',
      artwork: [{ src: t.art, sizes: '512x512', type: 'image/jpeg' }]
    });
  }
}

document.getElementById('play-btn').addEventListener('click', function () { playTrack(0); });
```

Keep the play/pause button and the progress bar right:

```js
function publishState() {
  if (!('mediaSession' in navigator)) return;
  navigator.mediaSession.playbackState = audio.paused ? 'paused' : 'playing';   // set this FIRST
  if (audio.duration > 0 && navigator.mediaSession.setPositionState) {
    navigator.mediaSession.setPositionState({
      duration: audio.duration, position: audio.currentTime, playbackRate: 1
    });
  }
}
audio.addEventListener('play', publishState);
audio.addEventListener('pause', publishState);
audio.addEventListener('seeked', publishState);
```

A podcast player where the notification's Next / Previous buttons skip 30 seconds forward and 15 seconds back instead of changing episode:

```js
var audio = document.getElementById('player');
function skip(seconds) {
  var end = audio.duration || 0;
  audio.currentTime = Math.max(0, Math.min(end, audio.currentTime + seconds));
  if ('mediaSession' in navigator && navigator.mediaSession.setPositionState && end > 0) {
    navigator.mediaSession.setPositionState({ duration: end, position: audio.currentTime });
  }
}
if ('mediaSession' in navigator) {
  navigator.mediaSession.setActionHandler('play', function () { audio.play(); });
  navigator.mediaSession.setActionHandler('pause', function () { audio.pause(); });
  navigator.mediaSession.setActionHandler('nexttrack', function () { skip(30); });
  navigator.mediaSession.setActionHandler('previoustrack', function () { skip(-15); });
  if (window.MediaMetadata) {
    navigator.mediaSession.metadata = new MediaMetadata({
      title: 'Episode 12: Getting started', artist: 'My Podcast',
      artwork: [{ src: 'https://example.com/podcast-512.jpg', sizes: '512x512' }]
    });
  }
}
```

**Notes:**
- In the app, `navigator.mediaSession` is installed before the page's first script, so handlers and metadata can be set at any time - at the top of the page or when playback starts. It exists in the main page, not inside iframes.
- Supported actions: `play`, `pause`, `nexttrack`, `previoustrack`, `stop`. Seeking from the notification is not supported, so `seekto` never fires.
- Setting `playbackState` keeps the position from your last `setPositionState` call; call `setPositionState` again when the position really changes (a seek).
- Only the **last** artwork image is used. It must be a full `https://` address on the internet (the app downloads it, 4 s limit); a file inside your app ZIP does not show.
- `<audio>` / `<video>` elements are tracked for you: when the page handles a button neither with `setActionHandler` nor with its own `window.WebToApkOnMediaAction`, Play / Pause / Stop control the media element that is playing (else the one played last, else the first).
- To remove the notification, call `WebToApk.clearMediaNotification()` (setting `metadata = null` does not remove it).
- A page that defines `window.WebToApkOnMediaAction = function (action) { ... }` (actions `play`, `pause`, `next`, `prev`, `stop`) gets every button press there too - the app no longer overwrites it. Use one of the two, not both, or a press runs twice.

### `requestPictureInPicture`

```js
video.requestPictureInPicture() — Picture-in-Picture
```

**Example**

The standard Picture-in-Picture API: float a video in a small window while the user does something else. Chrome on Android has it and Android's WebView does not; in the app the video fills the floating window, as it does in Chrome.

**Returns:** a Promise for a `PictureInPictureWindow` (`width`, `height`, `resize` event). The video gets `enterpictureinpicture`, then `leavepictureinpicture` when the user closes or expands the window; `document.pictureInPictureElement` is the video meanwhile. Rejects `NotAllowedError` without a tap, `InvalidStateError` before the video's metadata has loaded or when it has `disablePictureInPicture`. **Needs:** Picture-in-picture switched on in the build (it is switched on by itself when your code calls `requestPictureInPicture`), Android 8 or newer.

```html
<video id="v" src="clip.mp4" controls playsinline></video>
<button id="pip">Pop out</button>
<script>
const v = document.getElementById('v');
const btn = document.getElementById('pip');
btn.hidden = !document.pictureInPictureEnabled;
btn.addEventListener('click', async () => {
  try {
    if (document.pictureInPictureElement) await document.exitPictureInPicture();
    else await v.requestPictureInPicture();
  } catch (e) {
    console.warn('Picture-in-Picture:', e.name);
  }
});
v.addEventListener('enterpictureinpicture', () => { btn.textContent = 'Back to app'; });
v.addEventListener('leavepictureinpicture', () => { btn.textContent = 'Pop out'; });
</script>
```

**Pop out automatically when the user leaves, and report an unsupported build:**

```js
const v = document.getElementById('v');
if (!document.pictureInPictureEnabled || v.disablePictureInPicture) {
  document.getElementById('pip').hidden = true;      // this build has no picture-in-picture
}
document.addEventListener('visibilitychange', async () => {
  if (document.hidden && !v.paused && !document.pictureInPictureElement) {
    try { await v.requestPictureInPicture(); }
    catch (e) { console.warn(e.name); }               // NotAllowedError without a tap
  }
});
```

**Wait for the metadata, and resize with the window:**

```js
async function popOut(video) {
  if (video.readyState < 1) await new Promise((r) => video.addEventListener('loadedmetadata', r, { once: true }));
  try {
    const pipWin = await video.requestPictureInPicture();
    pipWin.addEventListener('resize', () => console.log(pipWin.width, pipWin.height));
  } catch (e) {
    if (e.name === 'InvalidStateError') showMessage('This video cannot float.');
    else if (e.name === 'NotAllowedError') showMessage('Tap the button to pop out.');
  }
}
document.getElementById('pip').onclick = () => popOut(document.getElementById('v'));
```

**Notes:** Android puts the whole app into the floating window, so the video is first shown alone (full screen) and the window takes the video's shape; leaving the window puts the video back in the page. `document.exitPictureInPicture()` brings the app back to full screen. When the build has no picture-in-picture, `document.pictureInPictureEnabled` is `false`, as in a browser without it. The mode change reaches the page as `appmint:pip`. Internal transports: `__pipEnter`, `__pipExit`, `__pipAvailable`, `__pipPrepare`.

### `__pipAvailable`

```js
window.WebToApk.__pipAvailable(): Boolean
```

Whether video.requestPictureInPicture() can work: PiP on in the build, Android 8+.

**Example**

Documented together with `WebToApk.__pipEnter` - the example there shows this one too.

Internal transport behind `video.requestPictureInPicture()` and `document.exitPictureInPicture()`. Pages use the standard API; never call these directly, because the `__` methods may change without notice.

**Returns:** `__pipEnter(width, height)` returns `"ok"` when Android accepted the window (with the video's aspect ratio), else `"unsupported"` / `"failed"`. The window opening and closing arrive as `appmint:pip` with `{inPip}`. `__pipAvailable()` is true when the build has picture-in-picture on Android 8+. **Needs:** picture-in-picture switched on in the build.

**Use the public API:**

```js
const video = document.querySelector('video');
button.addEventListener('click', () => video.requestPictureInPicture());
```

Check support first, and report a refusal:

```js
async function floatVideo(video) {
  if (!document.pictureInPictureEnabled) { showMessage('Picture-in-picture is not on in this app.'); return; }
  try {
    await video.requestPictureInPicture();
  } catch (err) {
    showMessage('Could not open the floating window.');
  }
}
```

Leave it again and follow the state with the standard events:

```js
video.addEventListener('enterpictureinpicture', () => hideControls());
video.addEventListener('leavepictureinpicture', () => showControls());
closeBtn.onclick = () => { if (document.pictureInPictureElement) document.exitPictureInPicture(); };
```

**Notes:** Implemented in the app shell (PictureInPictureShim.kt). `__pipPrepare` stops the shell turning the screen sideways for the full-screen step on the way into the window.

### `__pipEnter`

```js
window.WebToApk.__pipEnter(width: Int, height: Int): String
```

Opens the PiP window with a [width]×[height] video's aspect ratio: "ok", or "unsupported" / "failed". The mode change arrives as `appmint:pip`.

**Example**

Internal transport behind `video.requestPictureInPicture()` and `document.exitPictureInPicture()`. Pages use the standard API; never call these directly, because the `__` methods may change without notice.

**Returns:** `__pipEnter(width, height)` returns `"ok"` when Android accepted the window (with the video's aspect ratio), else `"unsupported"` / `"failed"`. The window opening and closing arrive as `appmint:pip` with `{inPip}`. `__pipAvailable()` is true when the build has picture-in-picture on Android 8+. **Needs:** picture-in-picture switched on in the build.

**Use the public API:**

```js
const video = document.querySelector('video');
button.addEventListener('click', () => video.requestPictureInPicture());
```

Check support first, and report a refusal:

```js
async function floatVideo(video) {
  if (!document.pictureInPictureEnabled) { showMessage('Picture-in-picture is not on in this app.'); return; }
  try {
    await video.requestPictureInPicture();
  } catch (err) {
    showMessage('Could not open the floating window.');
  }
}
```

Leave it again and follow the state with the standard events:

```js
video.addEventListener('enterpictureinpicture', () => hideControls());
video.addEventListener('leavepictureinpicture', () => showControls());
closeBtn.onclick = () => { if (document.pictureInPictureElement) document.exitPictureInPicture(); };
```

**Notes:** Implemented in the app shell (PictureInPictureShim.kt). `__pipPrepare` stops the shell turning the screen sideways for the full-screen step on the way into the window.

### `__pipExit`

```js
window.WebToApk.__pipExit()
```

document.exitPictureInPicture(): brings the app back to full screen.

**Example**

Documented together with `WebToApk.__pipEnter` - the example there shows this one too.

Internal transport behind `video.requestPictureInPicture()` and `document.exitPictureInPicture()`. Pages use the standard API; never call these directly, because the `__` methods may change without notice.

**Returns:** `__pipEnter(width, height)` returns `"ok"` when Android accepted the window (with the video's aspect ratio), else `"unsupported"` / `"failed"`. The window opening and closing arrive as `appmint:pip` with `{inPip}`. `__pipAvailable()` is true when the build has picture-in-picture on Android 8+. **Needs:** picture-in-picture switched on in the build.

**Use the public API:**

```js
const video = document.querySelector('video');
button.addEventListener('click', () => video.requestPictureInPicture());
```

Check support first, and report a refusal:

```js
async function floatVideo(video) {
  if (!document.pictureInPictureEnabled) { showMessage('Picture-in-picture is not on in this app.'); return; }
  try {
    await video.requestPictureInPicture();
  } catch (err) {
    showMessage('Could not open the floating window.');
  }
}
```

Leave it again and follow the state with the standard events:

```js
video.addEventListener('enterpictureinpicture', () => hideControls());
video.addEventListener('leavepictureinpicture', () => showControls());
closeBtn.onclick = () => { if (document.pictureInPictureElement) document.exitPictureInPicture(); };
```

**Notes:** Implemented in the app shell (PictureInPictureShim.kt). `__pipPrepare` stops the shell turning the screen sideways for the full-screen step on the way into the window.

### `__pipPrepare`

```js
window.WebToApk.__pipPrepare()
```

The page is about to put a video fullscreen for PiP: do not force landscape for it.

**Example**

Documented together with `WebToApk.__pipEnter` - the example there shows this one too.

Internal transport behind `video.requestPictureInPicture()` and `document.exitPictureInPicture()`. Pages use the standard API; never call these directly, because the `__` methods may change without notice.

**Returns:** `__pipEnter(width, height)` returns `"ok"` when Android accepted the window (with the video's aspect ratio), else `"unsupported"` / `"failed"`. The window opening and closing arrive as `appmint:pip` with `{inPip}`. `__pipAvailable()` is true when the build has picture-in-picture on Android 8+. **Needs:** picture-in-picture switched on in the build.

**Use the public API:**

```js
const video = document.querySelector('video');
button.addEventListener('click', () => video.requestPictureInPicture());
```

Check support first, and report a refusal:

```js
async function floatVideo(video) {
  if (!document.pictureInPictureEnabled) { showMessage('Picture-in-picture is not on in this app.'); return; }
  try {
    await video.requestPictureInPicture();
  } catch (err) {
    showMessage('Could not open the floating window.');
  }
}
```

Leave it again and follow the state with the standard events:

```js
video.addEventListener('enterpictureinpicture', () => hideControls());
video.addEventListener('leavepictureinpicture', () => showControls());
closeBtn.onclick = () => { if (document.pictureInPictureElement) document.exitPictureInPicture(); };
```

**Notes:** Implemented in the app shell (PictureInPictureShim.kt). `__pipPrepare` stops the shell turning the screen sideways for the full-screen step on the way into the window.

### `castMedia`

```js
window.WebToApk.castMedia(mediaUrl: String)
```

Offers [mediaUrl] to whatever on this phone can play it on another screen - Chromecast, a Smart TV app, a local player - through the system chooser. The type is taken from the URL ([CastMediaType]): an audio file (.mp3, .m4a, .aac, .ogg, .opus, .wav, .flac …) or an audio `data:` URL is offered to audio apps, anything else - video files, stream manifests, extension-less URLs - to video apps. This is a hand-off, not a cast session: the app does not stay in control of playback, and there is no callback. A page that needs a real session should use the Cast SDK in JavaScript instead.

**Example**

Offers a video or audio address to the apps on the phone that can play it on another screen (a Chromecast app, a Smart TV app, a video player), through the Android "Cast to..." chooser.

**Returns:** nothing, and no callback. **Needs:** nothing to switch on. The phone needs an app that accepts the link.

```js
function castToTv(url) {
  if (window.WebToApk && typeof window.WebToApk.castMedia === 'function') {
    window.WebToApk.castMedia(url);          // opens the "Cast to..." chooser
  } else {
    alert('Casting is only available in the app');
  }
}

document.getElementById('cast-btn').addEventListener('click', function () {
  var video = document.querySelector('video');
  var url = video && video.currentSrc ? video.currentSrc : 'https://example.com/movie.mp4';
  castToTv(url);
});
```

Send a podcast episode from a list to a speaker or TV app (an `.mp3` link is offered to audio apps):

```js
// <li><button class="cast" data-src="https://example.com/ep12.mp3">Cast</button> Episode 12</li>
document.getElementById('episodes').addEventListener('click', function (e) {
  var btn = e.target.closest('button.cast');
  if (!btn) return;
  var src = btn.getAttribute('data-src') || '';
  if (src.indexOf('https://') !== 0) {
    alert('This episode cannot be cast');   // files inside the app cannot be handed over
    return;
  }
  if (window.WebToApk && typeof window.WebToApk.castMedia === 'function') {
    window.WebToApk.castMedia(src);
  }
});
```

Show the Cast button only inside the app, and pause the page's own player before handing a live stream over:

```js
var castBtn = document.getElementById('cast-live');
var canCast = !!(window.WebToApk && typeof window.WebToApk.castMedia === 'function');
castBtn.hidden = !canCast;

castBtn.addEventListener('click', function () {
  var video = document.getElementById('live');
  video.pause();                                   // avoid playing twice, here and on the TV
  window.WebToApk.castMedia('https://example.com/live/stream.m3u8');   // offered to video apps
});
```

**Notes:**
- This is a hand-off, not a cast session: your page does not control playback after the user picks an app.
- The address must be a full `https://` link that other apps can open. A file inside your app ZIP, a `blob:` URL or a web page address will not play.
- The type comes from the link: an audio file (`.mp3`, `.m4a`, `.aac`, `.ogg`, `.opus`, `.wav`, `.flac` …) or an `audio/` `data:` URL is offered to audio apps; anything else - video files, `.m3u8` streams, links without a file extension - to video apps.

### `clearMediaNotification`

```js
window.WebToApk.clearMediaNotification()
```

Lets web content drive the Android media-style notification and receive transport control callbacks (play/pause/next/prev) from the lock screen and notification shade.  Usage from JavaScript:   window.WebToApk.setMediaMetadata('Song Title', 'Artist Name', 'https://…/art.jpg')   window.WebToApk.setPlaybackState(true, 30000, 240000)   // playing, pos ms, dur ms   window.WebToApk.clearMediaNotification()  Callback from Android → JS:   window.WebToApkOnMediaAction('play'|'pause'|'next'|'prev'|'stop')

_Described by its group, Media Notification Bridge, rather than on its own._

**Example**

Removes the media notification and its lock-screen controls, for example when the user closes your player.

**Returns:** nothing. **Needs:** turn on **Media Notification** in Step 4 (Access) when you build. Without it the call does nothing.

There is no standard web call for this, so use the bridge:

```js
function closePlayer() {
  var audio = document.getElementById('player');
  audio.pause();
  audio.currentTime = 0;
  if (window.WebToApk && typeof window.WebToApk.clearMediaNotification === 'function') {
    window.WebToApk.clearMediaNotification();
  }
}

document.getElementById('close-player').addEventListener('click', closePlayer);

// The notification's own Stop button already removes it; just stop your audio:
if ('mediaSession' in navigator) {
  navigator.mediaSession.setActionHandler('stop', function () {
    var audio = document.getElementById('player');
    audio.pause();
    audio.currentTime = 0;
  });
}
```

A sleep timer that stops the audio and removes the notification after the chosen number of minutes:

```js
var sleepTimer = null;
function setSleepTimer(minutes) {
  clearTimeout(sleepTimer);
  sleepTimer = setTimeout(function () {
    var audio = document.getElementById('player');
    audio.pause();
    if (window.WebToApk && typeof window.WebToApk.clearMediaNotification === 'function') {
      window.WebToApk.clearMediaNotification();
    }
  }, minutes * 60 * 1000);
}
document.getElementById('sleep-30').addEventListener('click', function () { setSleepTimer(30); });
```

Remove the notification when the last track of a playlist has finished, instead of leaving a Play button for nothing:

```js
var audio = document.getElementById('player');
var playlist = ['https://example.com/1.mp3', 'https://example.com/2.mp3'];
var current = 0;

audio.addEventListener('ended', function () {
  current++;
  if (current < playlist.length) {
    audio.src = playlist[current];
    audio.play();
  } else if (window.WebToApk && typeof window.WebToApk.clearMediaNotification === 'function') {
    window.WebToApk.clearMediaNotification();
  }
});
```

**Notes:** Use this for "stop", not for "pause" (for pause, send `setPlaybackState(false, ...)` so the Play button stays). Setting `navigator.mediaSession.metadata = null` does not remove the notification. The next `setMediaMetadata` / `setPlaybackState` (or new `navigator.mediaSession` metadata / state) brings the notification and the lock-screen controls back.

### `setMediaMetadata`

```js
window.WebToApk.setMediaMetadata(title: String, artist: String, artworkUrl: String)
```

Lets web content drive the Android media-style notification and receive transport control callbacks (play/pause/next/prev) from the lock screen and notification shade.  Usage from JavaScript:   window.WebToApk.setMediaMetadata('Song Title', 'Artist Name', 'https://…/art.jpg')   window.WebToApk.setPlaybackState(true, 30000, 240000)   // playing, pos ms, dur ms   window.WebToApk.clearMediaNotification()  Callback from Android → JS:   window.WebToApkOnMediaAction('play'|'pause'|'next'|'prev'|'stop')

_Described by its group, Media Notification Bridge, rather than on its own._

**Example**

Sets the title, artist and cover picture of the media notification and lock-screen player. Pages normally set `navigator.mediaSession.metadata`, which calls this for you.

**Returns:** nothing. **Needs:** turn on **Media Notification** in Step 4 (Access) when you build. Without it the call does nothing.

The public way (use this):

```js
if ('mediaSession' in navigator && window.MediaMetadata) {
  navigator.mediaSession.metadata = new MediaMetadata({
    title: 'Episode 12', artist: 'My Podcast',
    artwork: [{ src: 'https://example.com/cover.jpg', sizes: '512x512' }]
  });
}
```

The raw call. Arguments: `title, artist, artworkUrl` (use `''` for no picture):

```js
function showNowPlaying(track) {
  if (!(window.WebToApk && typeof window.WebToApk.setMediaMetadata === 'function')) return;
  window.WebToApk.setMediaMetadata(track.title, track.artist || '', track.cover || '');
  window.WebToApk.setPlaybackState(true, 0, Math.round(track.seconds * 1000));
}

// Buttons in the notification come back through the standard API:
if ('mediaSession' in navigator) {
  navigator.mediaSession.setActionHandler('nexttrack', function () { playNext(); });
  navigator.mediaSession.setActionHandler('previoustrack', function () { playPrevious(); });
}
```

Keep the notification's title up to date for a live radio stream, where the song changes without the page loading a new file:

```js
var lastSong = '';
function refreshNowPlaying() {
  fetch('https://example.com/radio/now-playing.json')
    .then(function (r) { return r.json(); })
    .then(function (np) {            // e.g. { "title": "...", "artist": "...", "cover": "https://..." }
      var key = np.title + '|' + np.artist;
      if (key === lastSong) return;  // only update when the song really changed
      lastSong = key;
      if (window.WebToApk && typeof window.WebToApk.setMediaMetadata === 'function') {
        window.WebToApk.setMediaMetadata(np.title || 'Live radio', np.artist || '', np.cover || '');
      }
    })
    .catch(function () { /* try again on the next tick */ });
}
document.getElementById('radio').addEventListener('play', function () {
  refreshNowPlaying();
  setInterval(refreshNowPlaying, 30000);
}, { once: true });
```

**Notes:**
- The notification appears (or updates) with this call. An empty title shows the app name.
- `artworkUrl` must be a full `https://` address on the internet. The app downloads it (4-second limit); if that fails, the app icon is shown.
- Receive button presses with `navigator.mediaSession.setActionHandler` (see `navigator.mediaSession`), or with your own `window.WebToApkOnMediaAction = function (action) { ... }` (`play`, `pause`, `next`, `prev`, `stop`) - use one of the two. After `clearMediaNotification()`, the next call to this (or `setPlaybackState`) brings the notification and the lock-screen controls back.

### `setPlaybackState`

```js
window.WebToApk.setPlaybackState(isPlaying: Boolean, positionMs: Long, durationMs: Long)
```

Lets web content drive the Android media-style notification and receive transport control callbacks (play/pause/next/prev) from the lock screen and notification shade.  Usage from JavaScript:   window.WebToApk.setMediaMetadata('Song Title', 'Artist Name', 'https://…/art.jpg')   window.WebToApk.setPlaybackState(true, 30000, 240000)   // playing, pos ms, dur ms   window.WebToApk.clearMediaNotification()  Callback from Android → JS:   window.WebToApkOnMediaAction('play'|'pause'|'next'|'prev'|'stop')

_Described by its group, Media Notification Bridge, rather than on its own._

**Example**

Tells the media notification whether you are playing or paused, and where you are in the track. Pages normally set `navigator.mediaSession.playbackState` and call `setPositionState`, which call this for you.

**Returns:** nothing. **Needs:** turn on **Media Notification** in Step 4 (Access) when you build. Without it the call does nothing.

The public way (use this):

```js
var audio = document.getElementById('player');
function publish() {
  if (!('mediaSession' in navigator)) return;
  navigator.mediaSession.playbackState = audio.paused ? 'paused' : 'playing';
  if (audio.duration > 0 && navigator.mediaSession.setPositionState) {
    navigator.mediaSession.setPositionState({ duration: audio.duration, position: audio.currentTime });
  }
}
audio.addEventListener('play', publish);
audio.addEventListener('pause', publish);
```

The raw call. Arguments: `isPlaying` (true/false), `positionMs`, `durationMs` (whole milliseconds):

```js
function publishRaw(audio) {
  if (!(window.WebToApk && typeof window.WebToApk.setPlaybackState === 'function')) return;
  window.WebToApk.setPlaybackState(
    !audio.paused,
    Math.floor(audio.currentTime * 1000),
    Math.floor((audio.duration || 0) * 1000)
  );
}
```

Report the new position after the user seeks, and switch the notification to Play when the track ends:

```js
var audio = document.getElementById('player');
function report(playing) {
  if (!(window.WebToApk && typeof window.WebToApk.setPlaybackState === 'function')) return;
  var durMs = Math.floor((audio.duration || 0) * 1000);
  var posMs = Math.floor(audio.currentTime * 1000);
  window.WebToApk.setPlaybackState(playing, Math.min(posMs, durMs || posMs), durMs);
}
audio.addEventListener('seeked', function () { report(!audio.paused); });
audio.addEventListener('ended', function () { report(false); });   // Play button, notification can be swiped away
```

**Notes:** `true` shows the Pause button and keeps the notification from being swiped away; `false` shows Play. Call `setMediaMetadata` first, so the notification has a title. To remove the notification when the user stops, call `clearMediaNotification()`.

