Skip to content

MediaPlayer Integration

oldmangan edited this page Oct 10, 2026 · 1 revision

MediaPlayer knows nothing about any media server. You give it URLs — or, for an item it should fetch only when it is reached, a function that returns one — and it reports back what happened. This page shows how to connect it to whatever holds your media: files on the viewer's own disk, a static folder or CDN, cookies or Bearer tokens, signed URLs that expire, a server that starts a transcoding session per play, live streams.

Every pattern here runs in the demo (tests/mediaplayer_demo.py with tests/mediaplayer_demo_server.py, see the last section).

Which pattern is yours?

Your media is… Use Section
On the viewer's own computer (a local player) blob: URLs from a file input Files on the viewer's disk
Public files: a static folder, a CDN, a public bucket The URL Open URLs
Behind the app's login, on the same site The session cookie, nothing to do Cookies
On another site that uses cookies cross_origin="use-credentials" Cookies
Behind an API that wants Authorization: Bearer … MediaItem(headers=…) (HLS) Bearer tokens and API keys
Behind short-lived signed URLs (S3, CloudFront, your own) Lazy items, on_resolve, refresh() Signed URLs that expire
A server that transcodes, with a session per play (Jellyfin, Plex, your own) on_resolve to start it, on_unload to stop it Sessions per play
A live channel or radio stream MediaItem(live=True) Live streams

Before anything plays: the page and the server must agree

Three things decide whether media from your server can play in this page, and none of them is the widget's to change. Get them right first; when one is wrong, nothing plays and the cause isn't obvious.

1. The page's Content-Security-Policy. HLS plays through hls.js, which feeds the <video> element through Media Source Extensions — a blob: URL. And hls.js fetches playlists and segments with XHR, which connect-src governs, not media-src. So a page whose media is on https://media.example.com needs:

media-src 'self' blob: https://media.example.com;
connect-src 'self' https://media.example.com;
img-src 'self' data: https://media.example.com;    (posters and covers)

pyTincture's own policy has no media-src at all (so default-src 'self' applies) and no other origins: add these with a small ASGI wrapper around create_app — tests/mediaplayer_demo.py has one to copy. When the policy blocks the media, the player says so in its status line and an error event with code == "csp".

2. CORS on the media server, when it is on another origin than the app:

Access-Control-Allow-Origin: https://app.example.com        (the app's origin, exactly; not * with credentials)
Access-Control-Allow-Headers: Authorization                 (any header you send with MediaItem(headers=…))
Access-Control-Allow-Credentials: true                      (only if you send cookies)

…and an answer to OPTIONS (a sent header triggers a preflight). Subtitles (.vtt) need CORS too, and MediaPlayerConfig(cross_origin="anonymous") (or "use-credentials").

3. Caching of protected responses. A playlist fetched with credentials must not be served from the browser's cache to a request without them, or to another user's. Send Cache-Control: no-store (or private) and Vary: Authorization on anything access-controlled. Found the hard way while testing this page: with the header missing, a request that should have been refused was answered from cache and played.

And for plain files (not HLS): the server must answer Range requests (206 Partial Content) or the viewer can't seek, and an MP4's index (moov) belongs at the start (ffmpeg -movflags +faststart) or playback waits for the whole file.

Files on the viewer's disk

A local player — no server at all. A file input (or a drop) gives the page File objects; each becomes a blob: URL that the player plays like any other.

import js
from pyodide.ffi import create_proxy
from wapyt import MediaItem, MediaTextTrack

urls = []   # released when the next selection replaces them

def play_local(file_list):
    files = sorted((file_list.item(i) for i in range(file_list.length)),
                   key=lambda f: str(f.webkitRelativePath or f.name).lower())
    stem = lambda f: str(f.name).rsplit(".", 1)[0]
    subtitles = {stem(f): f for f in files if str(f.name).lower().endswith(".vtt")}
    for u in urls:
        js.URL.revokeObjectURL(u)
    urls.clear()

    def url(f):
        urls.append(str(js.URL.createObjectURL(f)))
        return urls[-1]

    items = [MediaItem(url(f), title=stem(f),
                       kind="audio" if str(f.type).startswith("audio/") else "video",
                       type=str(f.type) or None,     # a blob: URL has no extension to say what it is
                       text_tracks=[MediaTextTrack(url(subtitles[stem(f)]), "Subtitles")] if stem(f) in subtitles else [])
             for f in files if str(f.type).startswith(("audio/", "video/"))]
    player.load(items, autoplay=True)

picker = js.document.getElementById("files")    # <input type="file" multiple accept="audio/*,video/*,.vtt">
picker.addEventListener("change", create_proxy(lambda e: play_local(e.target.files)))
# A whole folder: <input type="file" webkitdirectory multiple>; each file's webkitRelativePath is its path inside it.
# A drop: the "drop" event's e.dataTransfer.files, after preventDefault() on "dragover" and "drop".
  • The browser decides what plays: MP4 (H.264/AAC), WebM, MP3, AAC, FLAC, Ogg and WAV almost everywhere; Matroska (.mkv) and HEVC rarely. A file that can't play fails with code == "unsupported".
  • The page's policy needs media-src blob: — which HLS needs anyway.
  • Nothing is uploaded; the files never leave the viewer's computer.

Open URLs: a static folder or a CDN

player.load([
    MediaItem("https://cdn.example.com/films/dune/master.m3u8", title="Dune", poster="https://cdn.example.com/dune.jpg",
              text_tracks=[MediaTextTrack("https://cdn.example.com/films/dune/en.vtt", "English", "en")]),
    MediaItem("https://cdn.example.com/podcast/ep12.mp3", title="Episode 12", kind="audio"),
])

.m3u8 (or type="hls") plays as HLS; anything else goes to the <video> element as it is. A URL without an extension needs kind ("audio" / "video"), and for HLS, type="hls".

Cookies

Same origin (the app and its media on one site, behind the app's login): nothing to do. The browser sends the session cookie with every request — the <video> element's and hls.js's. This is how the Orpheus media server's player works: one origin, an HttpOnly cookie.

Another origin: MediaPlayerConfig(cross_origin="use-credentials") makes both the media element and hls.js send cookies; the server must answer with Access-Control-Allow-Credentials: true and its exact Access-Control-Allow-Origin, and the cookie must be SameSite=None; Secure — which means HTTPS.

Bearer tokens and API keys

MediaItem("https://api.example.com/stream/42/master.m3u8", title="Film 42",
          headers={"Authorization": f"Bearer {token}"})

headers go on every HLS request — the master playlist, each rendition's playlist, keys and segments. The server must allow them (Access-Control-Allow-Headers).

A plain file URL can't carry headers: the browser makes that request itself, and no page can add a header to it. For plain files behind a token, use one of:

  • a signed URL (next section) — the usual answer;
  • a token in the URL (?api_key=…) — fine on a LAN, but it ends up in logs and the browser history;
  • a cookie (above).

Setting headers on a plain file plays it without them and logs a console warning that says so.

Signed URLs that expire

Short-lived URLs — S3 presigned, CloudFront signed, your own HMAC tokens — are fetched only when an item is reached, and fetched again when one expires mid-play. Leave src out; answer resolve; and on a refusal, refresh().

items = [MediaItem(title=f.title, kind="video", data={"id": f.id}) for f in films]   # no src

async def resolve(item):
    signed = await api.signed_url(item["data"]["id"])          # your backend signs it
    return {"src": signed.url, "type": "hls" if signed.url.endswith(".m3u8") else None}

def on_error(event):
    # 401/403/410 from the media server: the URL lapsed. Get a new one and carry on.
    if event["status"] in (401, 403, 410):
        player.refresh()

player.on_resolve(resolve)
player.on_error(on_error)
player.load(items, autoplay=True)
  • refresh() emits unload (reason refresh), then resolve again — with refresh: True and the position — and resumes there. The data you gave the item comes back unchanged.
  • status is known for HLS (each request is the page's own). A plain file's failure has no status — the browser doesn't say — so for plain files behind expiring URLs either make them long-lived enough to finish, or refresh on any code == "network".
  • hls.js buffers about 30 s ahead, so an expiry shows up when the player next needs a segment, not at the moment the URL lapses.

A session per play: transcoding servers

Servers that transcode on demand start a session when something is played and want to be told when it stops. That is on_resolve and on_unload:

async def resolve(item):
    session = await server.start_playback(item["data"]["id"], capabilities)   # the server picks direct / remux / transcode
    item_sessions[item["index"]] = session.id
    return {"src": session.url, "type": "hls" if session.is_hls else None, "text_tracks": session.subtitles}

def unload(item):
    session = item_sessions.pop(item["index"], None)
    if session:
        asyncio.ensure_future(server.stop_playback(session))    # next, previous, ended, a new queue, refresh, destroy

player.on_resolve(resolve)
player.on_unload(unload)
  • Bind both before load. An item given to the constructor asks to be resolved during construction, before any handler exists, and waits for ever.
  • Close the session on pagehide too — a tab closed mid-film fires no unload. A keepalive fetch survives the page: js.fetch(url, {"method": "DELETE", "keepalive": True, …}).
  • Two common servers, as sketches — check their own API documentation for the details:
    • Jellyfin: an HLS master playlist at /Videos/{id}/master.m3u8?api_key=…&MediaSourceId=… (plus the container and codec parameters its playback-info endpoint suggests), music at /Audio/{id}/universal?api_key=…. The key rides in the URL, so plain-file URLs work without headers.
    • Plex: media part URLs and its transcoder's start.m3u8 take the token as X-Plex-Token=… in the query.
    • Orpheus (the server this widget was first built with) posts /items/{id}/playback, plays the URL its decision returns, and deletes the session on unload — this exact pattern.

Live streams

MediaItem("https://live.example.com/channel1/index.m3u8", title="Channel 1", live=True)

live=True starts at the stream's live edge (without it, playback starts at the oldest segment in the window) and shows LIVE instead of a duration; state()["live"] is True. Seeking works within the window the server keeps. refresh() on a live item rejoins at the live edge.

Quality

An HLS stream with several renditions plays adaptively: hls.js picks the rendition the connection can carry and moves between them. The bar's menu offers them too — Auto, 1080p, 720p… — and code can:

player.on_levels(lambda e: print([l["label"] for l in e["levels"]], "auto" if e["auto"] else e["current"]))
player.set_quality(index)              # a fixed rendition, by its index in on_levels
player.set_quality(None)               # back to automatic

show_quality=False takes it out of the menu (adaptive either way). Saving a viewer's choice is the app's: store the label, and on the next on_levels find its index again.

Resume and progress

  • Start where the viewer left off: MediaItem(..., start=1234.5) (seconds).
  • Report progress: on_time_update every time_update_interval seconds (default 1; the scrubber itself updates every frame regardless), plus on_pause, on_ended and on_unload for the moments that matter. Send a report every 10–15 s while playing rather than on every update.
  • refresh() keeps the position by itself.

When something goes wrong

Every error event carries message (shown in the player's status line), fatal, code and status:

code Means Usually
network A request failed; status is the HTTP status for HLS 401/403/410: an expired or missing credential → refresh(); 404: a wrong URL; 5xx: the server; none: offline or CORS
media hls.js couldn't play what it fetched (after one recovery attempt) A codec the browser lacks, or a broken segment
decode The browser couldn't decode a plain file A codec it lacks (HEVC, AC-3 in many browsers)
unsupported The format isn't playable here, or this browser can't play HLS .mkv in most browsers; transcode on the server
csp The page's Content-Security-Policy blocks the media Fix the policy (above)
aborted, error Playback was stopped, or another failure —

A refused request (any 4xx) or a playlist that never loaded is reported at once; a timeout or a 5xx on a segment is retried once first.

Formats and limits

  • HLS plays through the bundled hls.js light build in every browser but Safari, which plays HLS natively. The light build has no alternate audio renditions and no in-playlist subtitles: give subtitles as WebVTT files (text_tracks). An app that needs the rest can serve the full hls.js build under the same asset name.
  • Plain files play if the browser does — see the local player for the usual list.
  • Not supported: DASH (.mpd) and DRM (Widevine, FairPlay, PlayReady). A server that offers both HLS and DASH should be given HLS.

The runnable demo

./scripts/dev_wheel.sh tests                          # the widgetset, as a dev wheel
python tests/mediaplayer_demo_server.py               # "your" media server on :9102 (makes test media with ffmpeg once)
cd tests && uv run mediaplayer_demo.py                # the app on :8070; MEDIAPLAYER_DEMO_PORT / _SERVER to move them

Its Sources column:

  • On this computer — choose files or a folder, or drop them: songs queue in the docked bar, video plays with its same-named .vtt as subtitles.
  • Your server — four buttons against mediaplayer_demo_server.py, on another origin than the app:
    • Open HLS — two renditions, so the Quality menu appears;
    • Bearer header — MediaItem(headers=…), refused (401) without it;
    • Signed URL — minted in on_resolve, revoked after 10 s: watch the log show the 403, the unload (refresh) and the new URL, and playback carry on;
    • Live — a sliding window; the bar says LIVE.

The log under the buttons shows each event as the player reports it. mediaplayer_demo_server.py is a starting point for your own: each of its routes is one of the patterns above, in a few lines of the standard library.

Clone this wiki locally