Skip to content

MediaPlayer

oldmangan edited this page Oct 10, 2026 · 3 revisions

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").

Three shapes

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.

Items fetched when they're reached

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_resolve handler may be async and return {"src", "type", "kind", "text_tracks", ...}, or call player.resolve(index, src, **fields) itself. Raising shows the error in the player.
  • unload fires when an item stops being current; its reason says 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_resolve exists. Create the player empty and load the 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.

Queue, shuffle and repeat

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.

Buttons of the app's own

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=True disables 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.

Subtitles, audio tracks and quality

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.

Authentication, live streams and expiring URLs

  • 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 (reason refresh), then resolve with refresh: True and the position. Call it from on_error when status is 401, 403 or 410.

MediaPlayer: connecting your media server has each of these end to end.

HLS and the page's Content-Security-Policy

.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.

Groups, the keyboard and the OS media keys

  • 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.

Config

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

Methods

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)

Events

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

Accessibility

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.

About the hls.js copy

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.

Clone this wiki locally