mirror of https://github.com/requarks/wiki
parent
ad861ae377
commit
1aefb34d92
@ -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…
Reference in new issue