From d65c60747932a69ae5962b9569e942e449625b68 Mon Sep 17 00:00:00 2001 From: hensm Date: Sun, 12 Jan 2020 12:08:29 +0000 Subject: [PATCH] Implement initial media overlay --- ext/src/_locales/en/messages.json | 24 ++ ext/src/background/background.ts | 2 +- ext/src/defaultOptions.ts | 1 + ext/src/icons/AirPlay_Audio.svg | 6 + ext/src/icons/AirPlay_Video.svg | 3 + ext/src/lib/loadSender.ts | 4 +- ext/src/manifest.json | 8 +- .../senders/{mediaCast.ts => media/index.ts} | 10 +- .../senders/media/overlay/AirPlay_Audio.svg | 6 + .../senders/media/overlay/AirPlay_Video.svg | 3 + .../senders/media/overlay/descriptorUtils.ts | 75 ++++ .../senders/media/overlay/overlayContent.ts | 366 ++++++++++++++++++ .../media/overlay/overlayContentLoader.ts | 31 ++ .../{mirroringCast.ts => mirroring.ts} | 0 ext/src/ui/options/index.tsx | 15 + ext/tsconfig.json | 2 +- ext/webpack.config.js | 6 +- tsconfig.json | 1 - 18 files changed, 552 insertions(+), 11 deletions(-) create mode 100644 ext/src/icons/AirPlay_Audio.svg create mode 100644 ext/src/icons/AirPlay_Video.svg rename ext/src/senders/{mediaCast.ts => media/index.ts} (98%) create mode 100644 ext/src/senders/media/overlay/AirPlay_Audio.svg create mode 100644 ext/src/senders/media/overlay/AirPlay_Video.svg create mode 100644 ext/src/senders/media/overlay/descriptorUtils.ts create mode 100644 ext/src/senders/media/overlay/overlayContent.ts create mode 100644 ext/src/senders/media/overlay/overlayContentLoader.ts rename ext/src/senders/{mirroringCast.ts => mirroring.ts} (100%) diff --git a/ext/src/_locales/en/messages.json b/ext/src/_locales/en/messages.json index 105032c..17d30e6 100755 --- a/ext/src/_locales/en/messages.json +++ b/ext/src/_locales/en/messages.json @@ -85,6 +85,18 @@ } + , "mediaOverlayTitle": { + "message": "Playing on $receiverName$" + , "description": "Main title for overlay displayed on media elements whilst casting." + , "placeholders": { + "receiverName": { + "content": "$1" + , "example": "Living Room TV" + } + } + } + + , "optionsBridgeLoading": { "message": "Loading bridge info..." , "description": "Loading placeholder text for bridge section on options page." @@ -193,6 +205,18 @@ "message": "Enable media casting" , "description": "Media casting enabled checkbox label." } + , "optionsMediaOverlayEnabled": { + "message": "Enable media element overlay" + , "description": "Media element overlay checkbox label." + } + , "optionsMediaOverlayEnabledTemp": { + "message": "Enable media element overlay (experimental)" + , "description": "Experimental-labelled version of above." + } + , "optionsMediaOverlayEnabledDescrption": { + "message": "Overlay on media elements displaying information about the current session if connected." + , "description": "Media element overlay option description." + } , "optionsMediaSyncElement": { "message": "Sync receiver state with media element" , "description": "Media casting sync checkbox label." diff --git a/ext/src/background/background.ts b/ext/src/background/background.ts index 2fe3f71..418ba09 100755 --- a/ext/src/background/background.ts +++ b/ext/src/background/background.ts @@ -183,7 +183,7 @@ async function initMenus () { }); await browser.tabs.executeScript(tab.id, { - file: "senders/mediaCast.js" + file: "senders/media/bundle.js" , frameId: info.frameId }); } else { diff --git a/ext/src/defaultOptions.ts b/ext/src/defaultOptions.ts index a0bea00..0763e8a 100644 --- a/ext/src/defaultOptions.ts +++ b/ext/src/defaultOptions.ts @@ -7,6 +7,7 @@ import { Options } from "./lib/options"; export default { bridgeApplicationName: APPLICATION_NAME , mediaEnabled: true + , mediaOverlayEnabled: false , mediaSyncElement: false , mediaStopOnUnload: false , localMediaEnabled: true diff --git a/ext/src/icons/AirPlay_Audio.svg b/ext/src/icons/AirPlay_Audio.svg new file mode 100644 index 0000000..4992c91 --- /dev/null +++ b/ext/src/icons/AirPlay_Audio.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/ext/src/icons/AirPlay_Video.svg b/ext/src/icons/AirPlay_Video.svg new file mode 100644 index 0000000..51aa7fa --- /dev/null +++ b/ext/src/icons/AirPlay_Video.svg @@ -0,0 +1,3 @@ + + + diff --git a/ext/src/lib/loadSender.ts b/ext/src/lib/loadSender.ts index b93e33c..3ee2ddc 100644 --- a/ext/src/lib/loadSender.ts +++ b/ext/src/lib/loadSender.ts @@ -34,7 +34,7 @@ export default async function loadSender (opts: LoadSenderOptions) { }); await browser.tabs.executeScript(opts.tabId, { - file: "senders/mirroringCast.js" + file: "senders/mirroring.js" , frameId: opts.frameId }); @@ -43,7 +43,7 @@ export default async function loadSender (opts: LoadSenderOptions) { case ReceiverSelectorMediaType.File: { const fileUrl = new URL(`file://${opts.selection.filePath}`); - const { init } = await import("../senders/mediaCast"); + const { init } = await import("../senders/media"); init({ mediaUrl: fileUrl.href diff --git a/ext/src/manifest.json b/ext/src/manifest.json index 56eb520..9882da4 100755 --- a/ext/src/manifest.json +++ b/ext/src/manifest.json @@ -30,7 +30,10 @@ , "content_scripts": [ { "all_frames": true - , "js": [ "shim/content.js" ] + , "js": [ + "shim/content.js" + , "senders/media/overlay/overlayContentLoader.js" + ] , "matches": [ "" ] , "run_at": "document_start" } @@ -58,5 +61,8 @@ , "web_accessible_resources": [ "shim/bundle.js" , "vendor/webcomponents-lite.js" + , "senders/media/overlay/overlayContent.js" + , "senders/media/overlay/AirPlay_Audio.svg" + , "senders/media/overlay/AirPlay_Video.svg" ] } diff --git a/ext/src/senders/mediaCast.ts b/ext/src/senders/media/index.ts similarity index 98% rename from ext/src/senders/mediaCast.ts rename to ext/src/senders/media/index.ts index 88e9f5e..2d66e32 100644 --- a/ext/src/senders/mediaCast.ts +++ b/ext/src/senders/media/index.ts @@ -1,9 +1,9 @@ "use strict"; -import options from "../lib/options"; -import cast, { ensureInit } from "../shim/export"; +import options from "../../lib/options"; +import cast, { ensureInit } from "../../shim/export"; -import { Message, Receiver } from "../types"; +import { Message, Receiver } from "../../types"; function getLocalAddress () { @@ -360,6 +360,10 @@ export async function init (opts: InitOptions) { if (opts.targetElementId) { registerMediaElementListeners(); + if (options.get("mediaOverlayEnabled")) { + // TODO: Un-hide overlay here + } + window.addEventListener("beforeunload", async () => { backgroundPort.postMessage({ subject: "bridge:/mediaServer/stop" diff --git a/ext/src/senders/media/overlay/AirPlay_Audio.svg b/ext/src/senders/media/overlay/AirPlay_Audio.svg new file mode 100644 index 0000000..4992c91 --- /dev/null +++ b/ext/src/senders/media/overlay/AirPlay_Audio.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/ext/src/senders/media/overlay/AirPlay_Video.svg b/ext/src/senders/media/overlay/AirPlay_Video.svg new file mode 100644 index 0000000..51aa7fa --- /dev/null +++ b/ext/src/senders/media/overlay/AirPlay_Video.svg @@ -0,0 +1,3 @@ + + + diff --git a/ext/src/senders/media/overlay/descriptorUtils.ts b/ext/src/senders/media/overlay/descriptorUtils.ts new file mode 100644 index 0000000..691386d --- /dev/null +++ b/ext/src/senders/media/overlay/descriptorUtils.ts @@ -0,0 +1,75 @@ +"use strict"; + +/** + * Walk up the prototype chain until the specified property + * descriptor is found, otherwise return undefined. + */ +export function getPropertyDescriptor ( + target: any, prop: string | number | symbol) + : PropertyDescriptor | undefined { + + let desc: PropertyDescriptor | undefined; + while (!desc && target !== null) { + desc = Object.getOwnPropertyDescriptor(target, prop); + if (!desc) { + target = Object.getPrototypeOf(target); + } + } + + return desc; +} + +/** + * Bind either the getter/setter functions or the value function + * to a target object. + */ +export function bindPropertyDescriptor ( + desc: PropertyDescriptor, target: any) + : PropertyDescriptor { + + if (typeof desc.value === "function") { + desc.value = desc.value.bind(target); + } else { + if (desc.get) { desc.get = desc.get.bind(target); } + if (desc.set) { desc.set = desc.set.bind(target); } + } + + return desc; +} + +/** + * For each attribute handler, fetch the property descriptor (which may + * be further up in the prototype chain), re-bind it to the target + * element and collect them into a property descriptor map. + */ +export function clonePropsDescriptor ( + target: T, props: Array) + : PropertyDescriptorMap { + + return props.reduce((descriptorMap, prop) => { + const desc = getPropertyDescriptor(target, prop); + if (desc) { + bindPropertyDescriptor(desc, target); + descriptorMap[prop as any] = desc; + } + + return descriptorMap; + }, {}); +} + +export function makeGetterDescriptor (val: any): PropertyDescriptor { + return { + enumerable: true + , configurable: true + , get () { return val; } + }; +} + +export function makeValueDescriptor (val: any): PropertyDescriptor { + return { + enumerable: true + , configurable: true + , writable: true + , value: val + }; +} diff --git a/ext/src/senders/media/overlay/overlayContent.ts b/ext/src/senders/media/overlay/overlayContent.ts new file mode 100644 index 0000000..90f2f5f --- /dev/null +++ b/ext/src/senders/media/overlay/overlayContent.ts @@ -0,0 +1,366 @@ +"use strict"; + +import { bindPropertyDescriptor + , clonePropsDescriptor + , getPropertyDescriptor + , makeGetterDescriptor + , makeValueDescriptor } from "./descriptorUtils"; + +// Injected by content loader +declare const iconAirPlayAudio: string; +declare const iconAirPlayVideo: string; +declare const mediaOverlayTitle: string; + + +/** + * Intercept and store references to shadow root nodes created by + * calls to `attachShadow`. Used to reference shadow roots, even when + * created in closed mode without exposing them to other page scripts. + */ +const internalShadowRoots = new WeakMap(); +const _attachShadow = Element.prototype.attachShadow; +Element.prototype.attachShadow = function (init) { + const shadowRoot = _attachShadow.call(this, init); + internalShadowRoots.set(this, shadowRoot); + return shadowRoot; +}; + + + +function getShadowRootFromNode (node: Node): ShadowRoot | undefined { + // Don't touch our custom element + if (node instanceof PlayerElement) { + return; + } + + return internalShadowRoots.get(node as Element); +} + + +const DQS_XPATH_EXPRESSION = `//*[contains(name(), "-")]`; + +/** + * Return the first matching querySelector result on any ShadowRoot + * nodes present in the document. + */ +function deepQuerySelector (selector: string): Element | null { + const result = document.evaluate( + DQS_XPATH_EXPRESSION, document, null + , XPathResult.ORDERED_NODE_ITERATOR_TYPE); + + let node: Node | null; + while (node = result.iterateNext()) { + const shadowRoot = getShadowRootFromNode(node); + if (!shadowRoot) { + continue; + } + + const queryResult = shadowRoot.querySelector(selector); + if (queryResult) { + return queryResult; + } + } + + return null; +} + +/** + * Collect and return the results of querySelectorAll on any + * ShadowRoot nodes present in the document. + */ +function deepQuerySelectorAll (selector: string): Node[] { + const result = document.evaluate( + DQS_XPATH_EXPRESSION, document, null + , XPathResult.ORDERED_NODE_ITERATOR_TYPE); + + const nodes: Node[] = []; + + let node: Node | null; + while (node = result.iterateNext()) { + const shadowRoot = getShadowRootFromNode(node); + if (shadowRoot) { + nodes.push(...shadowRoot.querySelectorAll(selector)); + } + } + + return nodes; +} + + +const mediaElementTypes = [ + HTMLMediaElement + , HTMLVideoElement + , HTMLAudioElement +]; + +const mediaElementEvents = [ + "abort", "canplay", "canplaythrough", "durationchange", "emptied" + , "encrypted", "ended", "error", "interruptbegin", "interruptend" + , "loadeddata", "loadedmetadata", "loadstart", "mozaudioavailable", "pause" + , "play", "playing", "progress", "ratechange", "seeked", "seeking", "stalled" + , "suspend", "timeupdate", "volumechange", "waiting" +]; + +const mediaElementAttributes = mediaElementTypes + .flatMap(type => Object.getOwnPropertyNames(type.prototype)) + .concat(mediaElementEvents.map(ev => `on${ev}`)); + + +/** + * Opaque wrapper around the media element to provide an overlay without + * author interference. Relevant properties, attributes, events and + * functions are proxied to the internal media element. + */ +class PlayerElement extends HTMLElement { + constructor () { + super(); + + const shadowRoot = this.attachShadow({ mode: "closed" }); + const { host } = shadowRoot; + + let iconUrl; + switch (this.constructor) { + // URL variables injected ahead of current script + + case AudioPlayerElement: { iconUrl = iconAirPlayAudio; break; } + case VideoPlayerElement: { iconUrl = iconAirPlayVideo; break; } + } + + shadowRoot.innerHTML = ` + + + + `; + + const videoElement = _createElement.call(document, "video"); + + for (const attr of mediaElementAttributes) { + if (host.hasOwnProperty(attr)) { + // @ts-ignore + videoElement[attr] = host[attr]; + } + } + + /** + * Page scripts need to be able to read/write attributes, event + * listeners, etc... on the media element, but since it's hidden + * within the shadow DOM, these properties must be proxied. + */ + Object.defineProperties(host, clonePropsDescriptor(videoElement, [ + "attributes", "setAttribute", "removeAttribute", "setAttribute" + , "addEventListener", "removeEventListener", "hasEventListener" + , ...mediaElementAttributes as any ])); + + shadowRoot.prepend(videoElement); + } +} + +class AudioPlayerElement extends PlayerElement {} +class VideoPlayerElement extends PlayerElement { + set overlayHidden (val: boolean) { + const shadowRoot = internalShadowRoots.get(this); + (shadowRoot.querySelector(".overlay") as HTMLDivElement).hidden = val; + } + get overlayHidden () { + const shadowRoot = internalShadowRoots.get(this); + return (shadowRoot.querySelector(".overlay") as HTMLDivElement).hidden; + } +} + +try { + customElements.define("audio-player-element", AudioPlayerElement); + customElements.define("video-player-element", VideoPlayerElement); +} catch (err) { + if (err instanceof DOMException + && err.code === DOMException.NOT_SUPPORTED_ERR) { + // Script already injected + } +} + + +// Original functions +const _createElement = document.createElement; +const _createElementNS = document.createElementNS; + +/** + * Intercepts `