Repository navigation
MediaPlayer Integration
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).
| 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 |
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.
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 withcode == "unsupported". - The page's policy needs
media-src blob:— which HLS needs anyway. - Nothing is uploaded; the files never leave the viewer's computer.
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".
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.
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.
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()emitsunload(reasonrefresh), thenresolveagain — withrefresh: Trueand theposition— and resumes there. Thedatayou gave the item comes back unchanged. -
statusis 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 anycode == "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.
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
pagehidetoo — a tab closed mid-film fires nounload. Akeepalivefetch 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.m3u8take the token asX-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 onunload— this exact pattern.
-
Jellyfin: an HLS master playlist at
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.
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 automaticshow_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.
- Start where the viewer left off:
MediaItem(..., start=1234.5)(seconds). - Report progress:
on_time_updateeverytime_update_intervalseconds (default 1; the scrubber itself updates every frame regardless), pluson_pause,on_endedandon_unloadfor the moments that matter. Send a report every 10–15 s while playing rather than on every update. -
refresh()keeps the position by itself.
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.
-
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.
./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 themIts 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
.vttas 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, theunload (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.
wapyt is MIT-licensed · Repository · pyTincture · Issues
Getting started
Widgets
- Layout and MainWindow
- Tree
- DataTable
- Form
- ModalWindow
- Window
- TabWidget
- Toolbar
- Sidebar
- Chat
- Terminal
- CardPanel
- ResourceBoard
- File Transfer
- Message
- ContextMenu
- MenuBar
- Listbox
- Kanban
- ProgressBar
- Pagination
- Popup and tooltips
- Chart
- Scheduler
- MediaPlayer
Guides
Planning
Contributing