Repository navigation
MediaPlayer
Video and audio with a queue, in three shapes: the picture in place, an audio bar that can dock to the foot of the window and keep playing while the page changes, and a compact inline player for a chat message or a list row. HLS streams play through a bundled hls.js; WebVTT subtitles, items whose URL is fetched only when they are reached, exclusive groups, the keyboard and the OS media keys come with it, and an app can add buttons of its own to the bar.
Added in #70; the app's own buttons and
shuffle-without-repeats in #71; headers, live
streams, quality and refresh() in #72.
It works with any media source. It plays URLs and asks the app for them when it needs them; it has no idea what server — if any — is behind them. Files on the viewer's disk, a CDN, cookies, Bearer tokens, expiring signed URLs, a transcoding server's per-play sessions, live channels: each is a few lines, shown in MediaPlayer: connecting your media server, with a runnable demo.
from wapyt import MediaItem, MediaPlayerConfig, MediaTextTrack
player = self.add_mediaplayer("main", MediaPlayerConfig(
items=[MediaItem("/media/dune.m3u8", title="Dune", poster="/art/dune.jpg",
text_tracks=[MediaTextTrack("/subs/dune.en.vtt", "English", "en")])],
mode="video", autoplay=True,
))
player.on_ended(lambda e: print("finished", e["title"]))Outside a layout cell: MediaPlayer(config, container=element) or MediaPlayer(config, root="#selector").
mode |
What it shows | For |
|---|---|---|
auto (default) |
The picture for a video item, the bar for an audio item | A queue of both |
video |
The picture with controls over it; they hide while playing | Films, episodes, previews |
audio |
A bar: cover, title, subtitle, queue line, controls, scrubber | Music, podcasts |
compact |
A pill: play, scrubber and time | A voice message, a list row |
Docked music. MediaPlayerConfig(mode="audio", dock="bottom") pins the bar to the foot of the window. The page
is padded by the bar's height while it shows, and a full-height MainWindow layout shrinks to fit, so the bar
never covers content; with nothing queued it disappears. Mount it outside the area the app re-renders (on
document.body, say) and it keeps playing while the rest of the page changes.
set_mode() switches shape at run time; on_mode reports it.
An item can leave out src. When it becomes current the player emits resolve and waits; the app answers with
the URL. That is how a per-play session, a signed link that expires, or a transcode the server starts on demand
is fetched only for what is actually played, and nothing for the rest of a long queue.
items = [MediaItem(title=t.title, kind="audio", data={"id": t.id}) for t in album] # no src
async def resolve(item):
decision = await api.playback(item["data"]["id"])
return {"src": decision.url, "type": "hls" if decision.is_hls else None}
player.on_resolve(resolve) # return a dict, or call player.resolve(index, ...)
player.on_unload(lambda item: api.stop(item["data"]["id"])) # the item stopped being current: close its session
player.load(items, start_index=0, autoplay=True)- An
on_resolvehandler may be async and return{"src", "type", "kind", "text_tracks", ...}, or callplayer.resolve(index, src, **fields)itself. Raising shows the error in the player. -
unloadfires when an item stops being current; itsreasonsays why:next,previous,ended,select(play_index),remove,load(a new queue),clear,stop,destroy. -
Bind the handlers before loading. Items given to the constructor ask to be resolved during construction,
before
on_resolveexists. Create the player empty andloadthe queue after binding. -
data(anything JSON-serialisable) comes back in every event about the item, so the app never maps an index back to its own records.
load(items, start_index, autoplay) replaces the queue; add(items, at=None), remove(index), clear(),
play_index(i), next(), previous() (which restarts the item if it's past 3 s) work on it. With more than one
item the bar shows Previous / Next and a queue line: "3 of 14 · up next: Two Step" (queue_line=False hides
it).
Shuffle deals every item once, in a random order, before any plays again. The player remembers what has played in the current pass:
- Turned on mid-queue, shuffle keeps what already played behind the current item, in the order it played (so Previous retraces it), and shuffles only what hasn't played yet.
- Items added while shuffled join what's still to come; played items aren't dealt again.
- With
repeat="all", each new pass is a fresh order, never beginning with the item that just played, and "up next" at the end of a pass names what actually follows.
To start a playlist on a random item, pick the start yourself:
player.set_shuffle(True); player.load(items, start_index=random.randrange(len(items))).
repeat is off, all (the queue) or one. show_shuffle / show_repeat hide those buttons.
An app can put its own buttons in the bar, beside the standard controls: "add to playlist", "save", "like",
"reply". Pressing one emits action about the current item, with the button's position on screen so a menu can
open beside it.
from wapyt import MediaAction
player = MediaPlayer(MediaPlayerConfig(mode="audio", dock="bottom", actions=[
MediaAction("playlist", "mdi-playlist-plus", "Add to playlist"),
MediaAction("like", "mdi-heart-outline", "Like", pressed=False),
]), root="#music")
def on_action(event):
if event["action"] == "playlist":
open_playlist_menu(event["data"]["id"], near=event["rect"]) # rect: left, top, right, bottom, width, height
elif event["action"] == "like":
liked = toggle_like(event["data"]["id"])
player.set_action("like", pressed=liked, icon="mdi-heart" if liked else "mdi-heart-outline")
player.on_action(on_action)- With no current item, every action is disabled;
disabled=Truedisables one regardless. -
set_actions([...])replaces them;set_action(id, icon=, label=, pressed=, disabled=)changes one (omitted fields stay). - A docked bar sits at the window's foot, so a menu opened from it belongs above the bar: place it from the bar's top, not the button's.
- Compact mode hides them, as it hides the subtitles menu.
MediaTextTrack(src, label, language, kind="subtitles", default=False) adds a WebVTT track; the bar's menu
lists them (and C cycles them), with a file's own audio tracks where the browser exposes them, and an HLS
stream's renditions under Quality (Auto, 1080p, 720p…). The bundled hls.js light build has no alternate
audio renditions and no in-playlist subtitles, so give subtitles as WebVTT files. set_text_track(index)
(None turns them off) and set_audio_track(index) choose from code; on_tracks reports what's available and
showing. A track on another origin needs CORS and cross_origin="anonymous".
Quality. An HLS stream with several renditions plays adaptively. on_levels reports them (index,
height, width, bitrate, label), the one playing (current) and whether the choice is automatic
(auto); set_quality(index) fixes one and set_quality(None) returns to automatic. show_quality=False
leaves it out of the menu.
-
MediaItem(headers={"Authorization": "Bearer …"})adds headers to every request of an HLS stream — playlists, keys, segments. A plain file URL can't carry headers (the browser makes that request); use a cookie, a token in the URL or a signed URL for those, and the player warns in the console if you try. -
cross_origin="use-credentials"sends cookies with the media element's requests and hls.js's. -
MediaItem(live=True)starts a live stream at its live edge and shows LIVE instead of a duration. -
refresh(index=None)fetches the current item's URL again — an expired signed URL, a dropped server session — and resumes where it was:unload(reasonrefresh), thenresolvewithrefresh: Trueand theposition. Call it fromon_errorwhenstatusis 401, 403 or 410.
MediaPlayer: connecting your media server has each of these end to end.
.m3u8 URLs (or type="hls") play through the bundled hls.js 1.7.3 light build in every browser except
Safari, which plays HLS natively. hls.js attaches through Media Source Extensions, which use blob: URLs, so a
page with a Content-Security-Policy must allow:
media-src 'self' blob:; worker-src 'self' blob:
Without media-src blob: nothing plays, and the player says so: its status line names the blocked directive and
an error event carries code == "csp". Without worker-src blob: HLS still plays, demuxing on the main thread
instead of in a worker.
hls_config is merged into hls.js's configuration. HLS starts at 0, not at the live edge (hls.js's default reads
a growing VOD playlist as live); an item's start sets another position.
-
Exclusive groups. Players sharing
group="music"pause one another: starting a film pauses the album; starting one voice message in a chat pauses the last. -
Keyboard (
keyboard=True): Space / K play and pause, ← / → 5 s, J / L 10 s, ↑ / ↓ volume, M mute, F full screen, N / P next / previous, C subtitles. -
OS media controls (
media_session=True): the most recently played player owns the Media Session, so the keyboard's media keys and the desktop's media controls pause and skip it, and show its title, subtitle, album and cover. On Linux that is MPRIS.
MediaPlayerConfig |
|
|---|---|
items, start_index
|
The initial queue and where it starts |
mode |
auto, video, audio, compact (above) |
dock |
none or bottom (audio mode) |
autoplay |
Start playing; browsers may refuse sound before a click, and the player then waits with its play button |
controls |
False for a player driven entirely from code |
muted, volume, rate
|
Initial state (volume 0–1, rate 0.25–4) |
repeat, shuffle, show_shuffle, show_repeat
|
Queue behaviour and its buttons |
show_quality |
An HLS stream's renditions in the menu |
queue_line |
"3 of 14 · up next: …" in the bar |
group |
Exclusive group name |
keyboard, media_session
|
Keyboard shortcuts; OS media controls |
actions |
Buttons of the app's own (MediaAction) |
time_update_interval |
Seconds between timeupdate events to Python (0 turns them off; the scrubber updates every frame in JS regardless) |
preload |
none, metadata (default), auto
|
cross_origin |
anonymous / use-credentials for media and tracks on another origin |
hls_config |
Options merged into hls.js's |
height |
CSS height (a number means px); video mode otherwise fills its container, so give the container a height |
empty_text, aria_label
|
The bar with nothing loaded; the region's accessible name |
MediaItem |
|
|---|---|
src |
A file or an HLS playlist; leave out to resolve it when reached |
title, subtitle, album
|
The bar's two lines, and the OS media controls |
poster |
Video poster, and the bar's cover |
type, kind
|
"hls" or a MIME type; "audio" / "video" when the URL doesn't say (a blob: URL never does) |
headers |
HTTP headers for an HLS stream's requests |
live |
A live stream: start at the live edge |
start |
Start position in seconds |
text_tracks |
MediaTextTrack entries |
data |
Handed back in every event about the item |
MediaAction |
|
|---|---|
id |
Names the action in the event |
icon, label
|
MDI class; accessible name and tooltip (default: the id) |
pressed |
A toggle's state (aria-pressed); None is a plain button |
disabled |
Shown but not pressable |
| Method | |
|---|---|
load(items, start_index=0, autoplay=True), play_item(item)
|
Replace the queue; play one item as a queue of one |
add(items, at=None), remove(index), clear()
|
Edit the queue |
play_index(i), next(), previous()
|
Move through it |
resolve(index, src=None, **fields) |
Answer a resolve (or set error= to fail the item) |
play(), pause(), toggle(), stop(), seek(s), skip(s)
|
Transport |
set_volume, set_muted, set_rate, set_shuffle, set_repeat
|
State |
set_text_track(i), set_audio_track(i), set_mode(m), fullscreen(on=None)
|
Tracks, shape, full screen |
set_actions(list), set_action(id, …)
|
The app's own buttons |
refresh(index=None, keep_position=True) |
Fetch an item's URL again and carry on |
set_quality(index or None) |
A fixed HLS rendition, or automatic |
state() |
index, count, paused, ended, position, duration (None when live), live, volume, muted, rate, shuffle, repeat, mode, order, tracks, quality
|
destroy() |
Stop and remove it (an unload with reason destroy) |
Every event about an item carries {index, title, subtitle, kind, data}.
| Event | Also carries |
|---|---|
on_play, on_pause, on_ended
|
— |
on_change |
previous, reason (load, select, next, previous, ended, remove) |
on_resolve |
refresh and position when refresh() asked (answer with a dict, or resolve()) |
on_unload |
reason — the place to close a session (refresh when the URL is being renewed) |
on_time_update |
position, duration (throttled by time_update_interval) |
on_error |
message, fatal, code (network, media, decode, unsupported, csp, aborted, error), status (HTTP, for HLS) |
on_levels |
levels, current, auto
|
on_action |
action, rect
|
on_queue_end |
The queue ran out (repeat off) |
on_tracks, on_volume, on_mode, on_fullscreen
|
What changed |
Every control is a button with a name, reachable by Tab; toggles (shuffle, repeat, subtitles, the app's
pressed actions) carry aria-pressed; the status line is a live region, so an error or "This browser cannot
play HLS streams" is read out. The region is named by aria_label.
pyTincture only runs scripts from its own server, so wapyt ships hls.js itself (the light build, Apache-2.0,
licence beside it in wapyt/assets/). It loads with the other widget assets, before mediaplayer.js, and adds
about 120 KB (gzipped) to every app.
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