VideoPlayer
A video with the kit's own controls: play, seek, time, volume, full screen and the keyboard.
Demo
Nothing yet.
Space plays, the arrows seek and set the volume, M mutes, F goes full screen.
Get it
It needs theme.qwe, as every component does; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.
$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/video_player.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/video_player.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/video_player.qwe
// A poster drawn in SVG, so the demo needs no image files; the clip is one of MDN's shared sample videos.let video_player_demo_poster = `data:image/svg+xml,${url_encode( `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 360" width="640" height="360">` + `<rect width="640" height="360" fill="#2a3b2c"/><circle cx="320" cy="170" r="70" fill="#e8a0c8"/>` + `<circle cx="320" cy="170" r="28" fill="#ffd36b"/><rect x="310" y="240" width="20" height="120" fill="#4b7a3a"/></svg>`)}`let video_player_demo_sources = [ { src: "https://mdn.github.io/shared-assets/videos/flower.webm", type: "video/webm" }, { src: "https://mdn.github.io/shared-assets/videos/flower.mp4", type: "video/mp4" },]component VideoPlayerDemoLive() { state events = [] <div.demo-stack> <VideoPlayer sources={video_player_demo_sources} poster={video_player_demo_poster} label="A flower, muted" muted={true} on_play={() => { events = events | push("play") }} on_pause={() => { events = events | push("pause") }} on_end={() => { events = events | push("end") }}/> <p.demo-note id="video-player-demo-events">{len(events) == 0 ? "Nothing yet." : join(events, ", ")}</p> </div>}view VideoPlayerDemo() = <div.demo-stack> <VideoPlayer sources={video_player_demo_sources} poster={video_player_demo_poster} label="A flower opening"/> <VideoPlayerDemoLive/> <p.demo-note>Space plays, the arrows seek and set the volume, M mutes, F goes full screen.</p> </div>Source
ui/video_player.qwe
// VideoPlayer: a <video> with the kit's own controls: play and pause, a seek// bar, the time, volume and mute, full screen, and the keyboard (Space or K// plays and pauses, the left and right arrows seek 5 seconds, up and down set// the volume, M mutes, F goes full screen). A big play button sits on the// poster until it starts. Screen readers get each control by name and the// seek bar as a slider that says the time.//// <VideoPlayer src="/media/intro.mp4" poster="/media/intro.jpg" label="Product tour"/>// <VideoPlayer sources={[{ src: "/m/a.webm", type: "video/webm" }, { src: "/m/a.mp4", type: "video/mp4" }]}// track="/m/a.vtt" muted={true} loop={true} on_end={() => { done = true }}/>//// src: the video; or sources, a list of { src, type } for the browser to pick// from. poster: a picture shown before it starts. label: what the player is// called ("Video"). ratio: its shape ("16/9"). muted, loop, autoplay: as on// <video> (autoplay needs muted in most browsers). track: a captions file (WebVTT)// and track_lang its language ("en"). preload: "metadata" (default), "auto" or// "none". on_play(), on_pause(), on_end(): inside one of your components.import ui/theme.qwe use ui_clslet video_player_css = css`.ui-video-player { position: relative; overflow: hidden; width: 100%; border-radius: var(--ui-radius); background: #000; color: #fff; font: 13px/1 var(--ui-font); outline: none; }.ui-video-player:focus-visible { box-shadow: var(--ui-focus); }.ui-video-player video { display: block; width: 100%; aspect-ratio: var(--r, 16/9); object-fit: contain; background: #000; }.ui-video-player:fullscreen video { height: 100%; aspect-ratio: auto; }.ui-video-player-big { position: absolute; top: 50%; left: 50%; display: grid; place-items: center; width: 72px; height: 72px; padding: 0; transform: translate(-50%, -50%); border: 0; border-radius: 50%; background: rgba(0, 0, 0, .55); color: #fff; cursor: pointer; transition: background .15s, transform .15s;}.ui-video-player-big:hover { background: var(--ui-accent); transform: translate(-50%, -50%) scale(1.06); }.ui-video-player-big:focus-visible { outline: none; box-shadow: 0 0 0 3px #fff; }.ui-video-player-big svg { width: 32px; height: 32px; margin-left: 4px; }.ui-video-player-started .ui-video-player-big { display: none; }.ui-video-player-bar { position: absolute; inset: auto 0 0 0; display: flex; align-items: center; gap: 6px; padding: 24px 10px 8px; background: linear-gradient(to top, rgba(0, 0, 0, .75), transparent); transition: opacity .25s;}.ui-video-player-started.ui-video-player-playing:not(:hover):not(:focus-within) .ui-video-player-bar { opacity: 0; }.ui-video-player-btn { display: grid; place-items: center; flex: none; width: 34px; height: 34px; padding: 0; border: 0; border-radius: var(--ui-radius-sm); background: transparent; color: #fff; cursor: pointer; }.ui-video-player-btn:hover { background: rgba(255, 255, 255, .18); }.ui-video-player-btn:focus-visible { outline: none; box-shadow: 0 0 0 2px #fff; }.ui-video-player-btn svg { width: 20px; height: 20px; }.ui-video-player-time { flex: none; min-width: 84px; padding: 0 4px; font-variant-numeric: tabular-nums; white-space: nowrap; }.ui-video-player-seek, .ui-video-player-vol { appearance: none; height: 20px; margin: 0; background: transparent; cursor: pointer; }.ui-video-player-seek { flex: 1; min-width: 60px; }.ui-video-player-vol { width: 70px; }.ui-video-player-seek::-webkit-slider-runnable-track, .ui-video-player-vol::-webkit-slider-runnable-track { height: 4px; border-radius: 999px; background: linear-gradient(to right, #fff var(--f, 0%), rgba(255, 255, 255, .35) var(--f, 0%)); }.ui-video-player-seek::-moz-range-track, .ui-video-player-vol::-moz-range-track { height: 4px; border-radius: 999px; background: rgba(255, 255, 255, .35); }.ui-video-player-seek::-moz-range-progress, .ui-video-player-vol::-moz-range-progress { height: 4px; border-radius: 999px; background: #fff; }.ui-video-player-seek::-webkit-slider-thumb, .ui-video-player-vol::-webkit-slider-thumb { appearance: none; width: 12px; height: 12px; margin-top: -4px; border-radius: 50%; background: #fff; }.ui-video-player-seek::-moz-range-thumb, .ui-video-player-vol::-moz-range-thumb { width: 12px; height: 12px; border: 0; border-radius: 50%; background: #fff; }.ui-video-player-seek:focus-visible, .ui-video-player-vol:focus-visible { outline: none; box-shadow: 0 0 0 2px #fff; border-radius: 999px; }.ui-video-player-vol-box { display: flex; align-items: center; }@media (max-width: 480px) { .ui-video-player-vol { display: none; } .ui-video-player-time { min-width: 0; } }@media (prefers-reduced-motion: reduce) { .ui-video-player-big, .ui-video-player-bar { transition: none; } }`// Seconds as "m:ss", or "h:mm:ss" from an hour.fn video_player_time(s: num) -> str { let t = floor(s) let h = floor(t / 3600) let m = floor(t % 3600 / 60) let sec = t % 60 let two = (n: int) => n < 10 ? `0${n}` : str(n) h > 0 ? `${h}:${two(m)}:${two(sec)}` : `${m}:${two(sec)}`}component VideoPlayer(src: str? = null, sources: list = [], poster: str? = null, label: str = "Video", ratio: str = "16/9", muted: bool = false, loop: bool = false, autoplay: bool = false, track: str? = null, track_lang: str = "en", preload: str = "metadata", on_play = null, on_pause = null, on_end = null, class: str? = null) { style global video_player_css state el = null state box = null state playing = false state started = false state time = 0 state dur = 0 state vol = 1 state quiet = muted state full = false let total = dur > 0 ? dur : 0 let fill = total > 0 ? round(time * 100 / total, 2) : 0 let toggle = () => { if el != null { // The browser may refuse to play (no click on the page yet, sound not allowed): the button stays Play. if playing { el.pause() } else { el.play().catch((_ = null) => { playing = false }) } } } let go_full = () => { if full { js("document.exitFullscreen").catch((_ = null) => { full = false }) } else { box.requestFullscreen().catch((_ = null) => { full = false }) } } let seek = (to: num) => { if el != null && total > 0 { el.currentTime = to < 0 ? 0 : to > total ? total : to } } let set_vol = (v: num) => { if el != null { el.volume = v < 0 ? 0 : v > 1 ? 1 : v if v > 0 { el.muted = false } } } <div class={ui_cls(["ui-video-player", started && "ui-video-player-started", playing && "ui-video-player-playing", class])} style={`--r: ${ratio}`} role="region" aria-label={label} tabindex="0" @mount={(b) => { box = b }} @fullscreenchange={() => { full = js("document.fullscreenElement") != null }} @keydown={(e) => { let tag = e.target.tagName if tag != "INPUT" || (e.key != "ArrowLeft" && e.key != "ArrowRight" && e.key != "ArrowUp" && e.key != "ArrowDown") { let done = match e.key { " " | "k" | "K" => { toggle(); true } "ArrowLeft" => { seek(time - 5); true } "ArrowRight" => { seek(time + 5); true } "ArrowUp" => { set_vol(vol + 0.1); true } "ArrowDown" => { set_vol(vol - 0.1); true } "m" | "M" => { if el != null { el.muted = !quiet }; true } "f" | "F" => { go_full(); true } _ => false } if done { e.preventDefault() } } }}> <video src={src} poster={poster} muted={muted} loop={loop} autoplay={autoplay} preload={preload} playsinline={true} @mount={(v) => { el = v; if math.isfinite(v.duration) && v.duration > 0 { dur = v.duration } }} @click={toggle} @loadedmetadata={(e) => { dur = math.isfinite(e.duration) ? e.duration : 0; vol = e.volume; quiet = e.muted }} @durationchange={(e) => { dur = math.isfinite(e.duration) ? e.duration : 0 }} @timeupdate={(e) => { time = e.currentTime }} @play={() => { playing = true; started = true; on_play && on_play() }} @pause={() => { playing = false; on_pause && on_pause() }} @ended={() => { playing = false; on_end && on_end() }} @volumechange={(e) => { vol = e.volume; quiet = e.muted }}> {for s in sources => <source key={s.src} src={s.src} type={get(s, "type")}/>} {track != null && <track kind="captions" src={track} srclang={track_lang} label="Captions"/>} </video> <button.ui-video-player-big type="button" aria-label="Play" @click={toggle}> <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M7 4v16l14-8z"/></svg> </button> <div.ui-video-player-bar> <button.ui-video-player-btn type="button" aria-label={playing ? "Pause" : "Play"} @click={toggle}> {playing ? <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M6 4h4v16H6zM14 4h4v16h-4z"/></svg> : <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M7 4v16l14-8z"/></svg>} </button> <span.ui-video-player-time aria-hidden="true">{video_player_time(time)} / {video_player_time(total)}</span> <input.ui-video-player-seek type="range" min="0" max={total} step="0.1" value={time} aria-label="Seek" disabled={total == 0} aria-valuetext={`${video_player_time(time)} of ${video_player_time(total)}`} style={`--f: ${fill}%`} @input={(e) => { seek(float(e.value)) }}/> <div.ui-video-player-vol-box> <button.ui-video-player-btn type="button" aria-label={quiet || vol == 0 ? "Unmute" : "Mute"} aria-pressed={quiet ? "true" : "false"} @click={() => { if el != null { el.muted = !quiet } }}> {quiet || vol == 0 ? <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M11 5 6 9H2v6h4l5 4V5ZM23 9l-6 6M17 9l6 6"/></svg> : <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M11 5 6 9H2v6h4l5 4V5ZM19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"/></svg>} </button> <input.ui-video-player-vol type="range" min="0" max="1" step="0.05" value={quiet ? 0 : vol} aria-label="Volume" aria-valuetext={`${round((quiet ? 0 : vol) * 100)}%`} style={`--f: ${round((quiet ? 0 : vol) * 100)}%`} @input={(e) => { set_vol(float(e.value)) }}/> </div> <button.ui-video-player-btn type="button" aria-label={full ? "Exit full screen" : "Full screen"} @click={go_full}> {full ? <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 3v3a2 2 0 0 1-2 2H3M21 8h-3a2 2 0 0 1-2-2V3M3 16h3a2 2 0 0 1 2 2v3M16 21v-3a2 2 0 0 1 2-2h3"/></svg> : <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 3H5a2 2 0 0 0-2 2v3M21 8V5a2 2 0 0 0-2-2h-3M3 16v3a2 2 0 0 0 2 2h3M16 21h3a2 2 0 0 0 2-2v-3"/></svg>} </button> </div> </div>}Related
EmbedFrameAnother page in a frame that keeps its shape and fills its width, with a title, permissions and a sandbox.5 views · 4 downloadsYouTubeEmbedA YouTube video that loads nothing but its poster until the play button is clicked, then starts at once.6 views · 7 downloadsAudioPlayerAn audio clip with play, a seek bar that says the time, and a button cycling through playback speeds.6 views · 7 downloadsCameraCaptureTakes a photo with the camera into a hidden input as a data URL, with retake, and says so when there is no camera.6 views · 6 downloadsCoverImageA wide picture with a title, subtitle and actions over a gradient, in three heights.7 views · 7 downloadsFigureAn image (or anything) with a caption and a credit under it, as a figure with its figcaption.8 views · 8 downloads
Thread
Thread 0 posts
On VideoPlayer: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.