diff --git a/blocks/block-index/component.js b/blocks/block-index/component.js index 4abf8d087..2400c7c6d 100644 --- a/blocks/block-index/component.js +++ b/blocks/block-index/component.js @@ -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', diff --git a/blocks/block-youtube/component.js b/blocks/block-youtube/component.js new file mode 100644 index 000000000..36975dd8f --- /dev/null +++ b/blocks/block-youtube/component.js @@ -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` +
+ ${ + this.url?.trim() + ? `${this.url} is not the address of a YouTube video.` + : 'This player needs the address of a YouTube video.' + } +
+ ` + } + + 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` +
+ +
+ ` + } +} + +window.customElements.define('block-youtube', BlockYoutubeElement)