feat: youtube player block

scarlett
NGPixel 4 weeks ago
parent ad861ae377
commit 1aefb34d92
No known key found for this signature in database

@ -16,9 +16,12 @@ export class BlockIndexElement extends LitElement {
* runtime, this one describes it to a person so a property meant to be authored belongs in both.
* It is also what survives being saved: the renderer strips any attribute a block does not declare.
*
* A `boolean` prop must default to false. MDC writes attributes as strings and Lit reads any
* attribute that is present as true, so `showThing="false"` would come out true the picker leaves
* a prop out entirely when it still holds its default, which is what keeps false meaning false.
* A `boolean` prop must default to false, unless the block reads the attribute itself. MDC writes
* attributes as strings and Lit reads any attribute that is present as true, so `showThing="false"`
* would come out true the picker leaves a prop out entirely when it still holds its default,
* which is what keeps false meaning false. A block declaring the converter `block-asciinema` and
* `block-youtube` share is free of that, and so free to default a prop to true: `false` written out
* is then read back as false, which is the only case the stock converter gets wrong.
*/
static definition = {
block: 'index',

@ -0,0 +1,345 @@
import { LitElement, html, css } from 'lit'
/**
* An attribute that means "off" when it says so.
*
* MDC writes every prop with a value `autoplay="false"` is what the block picker produces for a
* toggle that was switched on and off again and Lit's own Boolean converter reads any string at all
* as true, that one included.
*
* It is also what lets a prop here default to ON, which the note in `block-index` rules out for a
* block using the stock converter: `controls` is left out of the markup while it holds its default,
* and written as `controls="false"` the moment it does not, which this reads back correctly.
*/
const boolean = {
converter: {
fromAttribute: (value) => value !== null && value !== 'false',
toAttribute: (value) => (value ? 'true' : null)
}
}
/** Every YouTube host a link can arrive on, including the one their own privacy mode hands out. */
const HOSTS = /^(?:www\.|m\.)?youtube(?:-nocookie)?\.com$/
/** The paths that carry the id in them, rather than in `?v=`. */
const ID_PATHS = /^\/(?:embed|shorts|live|v)\/([^/?#]+)/
/** What a video id is made of. Length is not checked: that is YouTube's to change, not ours. */
const ID = /^[A-Za-z0-9_-]+$/
/** A timestamp as YouTube writes it in a share link: `90`, `1m30s`, `1h2m3s`. */
const TIMESTAMP = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/
/**
* The video a link points at, or null for a link that points at no video.
*
* Every shape YouTube hands out: `watch?v=`, `youtu.be/`, `/embed/`, `/shorts/`, `/live/`. A bare id
* is taken as one too it is what an author who copied the id rather than the link will paste, and
* there is nothing else an eleven-character word could be meant as here.
*/
function videoId(source) {
const value = source.trim()
if (!value) {
return null
}
if (ID.test(value)) {
return value
}
// -> A link written without a scheme is still a link; `new URL` disagrees, so it is given one
const url = URL.parse(value) ?? URL.parse(`https://${value}`)
if (!url) {
return null
}
const host = url.hostname.toLowerCase()
const id =
host === 'youtu.be'
? url.pathname.slice(1).split('/')[0]
: !HOSTS.test(host)
? null
: url.pathname === '/watch'
? url.searchParams.get('v')
: (ID_PATHS.exec(url.pathname)?.[1] ?? null)
return id && ID.test(id) ? id : null
}
/**
* Where in the video a link says to start, in seconds. 0 for one that does not say.
*
* `t` is what the "copy link at current time" button adds, and it arrives either as a plain count of
* seconds or as `1m30s`. `start` is the same thing spelled the way the embed parameter is.
*/
function linkStart(source) {
const url = URL.parse(source.trim()) ?? URL.parse(`https://${source.trim()}`)
const value = url?.searchParams.get('t') ?? url?.searchParams.get('start') ?? ''
if (/^\d+$/.test(value)) {
return Number(value)
}
const parts = TIMESTAMP.exec(value)
if (!parts || !parts.slice(1).some(Boolean)) {
return 0
}
return Number(parts[1] ?? 0) * 3600 + Number(parts[2] ?? 0) * 60 + Number(parts[3] ?? 0)
}
/**
* Block YouTube
*
* A YouTube player, from the address of a video. Nothing is fetched until the frame is scrolled near,
* and the frame is the only thing here: the player, its controls and everything it does are YouTube's,
* driven by the parameters below.
*/
export class BlockYoutubeElement extends LitElement {
/**
* Metadata for the admin area and the editor's block picker. Collected at build time into
* `compiled/blocks.manifest.json`, which the server reads to register the block. Values must be
* plain literals. See `props` in `block-index` for what the picker does with that list.
*/
static definition = {
block: 'youtube',
name: 'YouTube Player',
description: 'Embeds a YouTube video.',
icon: 'youtube',
props: [
{
name: 'url',
type: 'string',
label: 'Video URL',
hint: 'Address of the video, as YouTube gives it — a watch, youtu.be or shorts link.',
required: true
},
{
name: 'width',
type: 'number',
label: 'Width',
hint: 'Width of the player in pixels. Empty fills the width of the page.'
},
{
name: 'height',
type: 'number',
label: 'Height',
hint: 'Height of the player in pixels. Empty keeps the widescreen shape.'
},
{
name: 'autoplay',
type: 'boolean',
label: 'Autoplay',
hint: 'Start as soon as the page is opened. Browsers only allow that muted, so it is.',
default: false
},
{
name: 'controls',
type: 'boolean',
label: 'Show Controls',
hint: 'Show the play bar over the video.',
default: true
},
{
name: 'fs',
type: 'boolean',
label: 'Allow Fullscreen',
hint: 'Offer the fullscreen button.',
default: true
},
{
name: 'loop',
type: 'boolean',
label: 'Loop',
hint: 'Start again on reaching the end.',
default: false
},
{
name: 'start',
type: 'number',
label: 'Start At',
hint: 'Seconds into the video to start at. 0 uses the time in the URL, if it carries one.',
default: 0
}
]
}
static get styles() {
return css`
:host {
display: block;
}
/*
The frame's box, and the gap below the block. On this element rather than :host: see
block-index.
-> A max-width rather than a plain width, so a player asked for at 1280 on a phone is the
width of the phone instead of pushing the page sideways. The aspect ratio then keeps it
widescreen at whatever width it ends up with, which is what a fixed height would not.
*/
.player {
max-width: 100%;
margin-bottom: 16px;
border-radius: 5px;
overflow: hidden;
background-color: #000;
}
iframe {
display: block;
width: 100%;
height: 100%;
border: 0;
}
.error {
margin-bottom: 16px;
padding: 1rem;
border: 1px dashed color-mix(in srgb, currentColor 50%, transparent);
border-radius: 5px;
color: var(--q-negative, #c10015);
}
`
}
static get properties() {
return {
/**
* Address of the video
* @type {string}
*/
url: { type: String },
/**
* Width of the player in pixels
* @type {number}
*/
width: { type: Number },
/**
* Height of the player in pixels
* @type {number}
*/
height: { type: Number },
/**
* Whether to start without being asked
* @type {boolean}
*/
autoplay: boolean,
/**
* Whether the play bar is shown
* @type {boolean}
*/
controls: boolean,
/**
* Whether the fullscreen button is offered
* @type {boolean}
*/
fs: boolean,
/**
* Whether to start again at the end
* @type {boolean}
*/
loop: boolean,
/**
* Seconds into the video to start at
* @type {number}
*/
start: { type: Number }
}
}
constructor() {
super()
this.url = ''
this.width = null
this.height = null
this.autoplay = false
this.controls = true
this.fs = true
this.loop = false
this.start = 0
}
/** A prop given a usable number, or null for one left empty. */
_size(value) {
const size = Number(value)
return Number.isFinite(size) && size > 0 ? size : null
}
/**
* The address of the player, with what it was asked for.
*
* Only the parameters that were actually changed: an option left out is YouTube's own default,
* which is the one that goes on being maintained.
*/
_embedUrl(id) {
const params = new URLSearchParams()
if (this.autoplay) {
params.set('autoplay', '1')
/*
-> Muted, because that is the only way it plays. Every browser refuses to start a video with
sound before the reader has interacted with the page, and refuses silently: the player
simply sits there, which reads as a block that is broken rather than one being overruled.
*/
params.set('mute', '1')
}
if (!this.controls) {
params.set('controls', '0')
}
if (!this.fs) {
params.set('fs', '0')
}
if (this.loop) {
params.set('loop', '1')
// -> A single video loops only as a playlist of itself; `loop` alone does nothing to one
params.set('playlist', id)
}
const start = this._size(this.start) ?? linkStart(this.url)
if (start > 0) {
params.set('start', String(start))
}
const query = params.toString()
return `https://www.youtube.com/embed/${id}${query ? `?${query}` : ''}`
}
render() {
const id = videoId(this.url ?? '')
if (!id) {
return html`
<div class="error">
${
this.url?.trim()
? `${this.url} is not the address of a YouTube video.`
: 'This player needs the address of a YouTube video.'
}
</div>
`
}
const width = this._size(this.width)
const height = this._size(this.height)
/*
A height that was asked for wins outright; without one the frame is widescreen, which is the
shape all but the oldest videos are. Letterboxing inside the frame is YouTube's business either
way -- the player fits the video to whatever box it is given.
*/
const style = [
width ? `width: ${width}px` : 'width: 100%',
height ? `height: ${height}px` : 'aspect-ratio: 16 / 9'
].join('; ')
return html`
<div class="player" style=${style}>
<iframe
src=${this._embedUrl(id)}
title="YouTube video player"
loading="lazy"
referrerpolicy="strict-origin-when-cross-origin"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
?allowfullscreen=${this.fs}></iframe>
</div>
`
}
}
window.customElements.define('block-youtube', BlockYoutubeElement)
Loading…
Cancel
Save