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 = ` + + + + + + ${mediaOverlayTitle} + + + `; + + 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 ``/`` element creation and returns a wrapped + * custom element version that imitates the original. Otherwise, returns + * the result of the original. + */ +function createElement ( + tagName: string + , options?: ElementCreationOptions) { + + // Normalize formatting + const lowerTagName = tagName.toLowerCase(); + const upperTagName = tagName.toUpperCase(); + + if (lowerTagName === "audio" || lowerTagName === "video") { + const fakeElement = _createElement.call(document + , `${lowerTagName}-player-element`) as HTMLMediaElement; + + // Ensure all references to the element name match tagName + Object.defineProperties(fakeElement, { + tagName: makeGetterDescriptor(upperTagName) + , nodeName: makeGetterDescriptor(upperTagName) + , localName: makeGetterDescriptor(lowerTagName) + }); + + return fakeElement; + } + + return _createElement.call(document, tagName, options); +} + +/** + * If the namespace matches the current document, redirect to the + * patched `createElement` function, otherwise return the result of the + * original. + */ +function createElementNS ( + namespaceURI: string + , qualifiedName: string + , options?: ElementCreationOptions) { + + if (namespaceURI === document.namespaceURI) { + return createElement(qualifiedName, options); + } + + return _createElementNS.call(document + , namespaceURI, qualifiedName, options); +} + +/** + * Attempt to hide function source from page scripts by returning the + * toString/toSource values of the native function. + */ +Object.defineProperties(createElement, clonePropsDescriptor( + _createElement, ["toString", "toSource"])); +Object.defineProperties(createElement, clonePropsDescriptor( + _createElementNS, ["toString", "toSource"])); + +// Re-define element creation functions +Object.defineProperties(document, { + createElement: makeValueDescriptor(createElement) + , createElementNS: makeValueDescriptor(createElementNS) +}); + + +/** + * Takes a media element, creates a `PlayerElement` via the patched + * `createElement` function, fetches the shadow root and copies any + * attributes before swapping with the original element in-place. + */ +function wrapMediaElement (mediaElement: HTMLMediaElement) { + const wrappedMedia = document.createElement(mediaElement.tagName); + const shadowRoot = internalShadowRoots.get(wrappedMedia); + + if (!shadowRoot) { + console.error("err: Failed to fetch shadow root!"); + return; + } + + /** + * Copy attributes, any non-media specific attributes are set to the + * wrapper element for identification (id, class, etc...) or styling. + */ + for (const attr of mediaElement.attributes) { + if (mediaElementAttributes.includes(attr.name)) { + wrappedMedia.setAttribute(attr.name, attr.value); + } else { + /** + * Since the wrapped element has a patched `setAttribute` + * method, need to call the original from the `HTMLElement` + * prototype, otherwise attributes will be set on the + * internal media element instead. + */ + HTMLElement.prototype.setAttribute.call( + wrappedMedia, attr.name, attr.value); + } + } + + /** + * Clone and append any HTMLSourceElement children to the + * internal media element within the wrapped media shadow root. + */ + for (const source of mediaElement.getElementsByTagName("source")) { + const internalMedia = shadowRoot.querySelector("audio,video"); + if (!internalMedia) { + console.error("err: Failed to fetch internal video element!"); + return; + } + + internalMedia.appendChild(source.cloneNode()); + } + + // Replace media element on page with wrapped media + mediaElement.replaceWith(wrappedMedia); +} + +/*function* joinIterables (...iterables: Array>) { + for (const iterable of iterables) { + for (const item of iterable) { + yield item; + } + } +}*/ + +/** + * Find all media elements (both in the main DOM and any shadow DOMs) + * and wrap them. + */ +document.addEventListener("DOMContentLoaded", () => { + const mediaSelector = "audio,video"; + + setTimeout(() => { + const mediaElements = document.querySelectorAll(mediaSelector); + const deepMediaElements = deepQuerySelectorAll(mediaSelector); + + for (const mediaElement of [...Array.from(mediaElements), ...deepMediaElements]) { + wrapMediaElement(mediaElement as HTMLMediaElement); + } + }); +}); diff --git a/ext/src/senders/media/overlay/overlayContentLoader.ts b/ext/src/senders/media/overlay/overlayContentLoader.ts new file mode 100644 index 0000000..3c6cde9 --- /dev/null +++ b/ext/src/senders/media/overlay/overlayContentLoader.ts @@ -0,0 +1,31 @@ +"use strict"; + +const _ = browser.i18n.getMessage; + +/** + * Make synchronous request for another script to keep other page + * scripts from loading before its execution. + */ +const req = new XMLHttpRequest(); +req.open("GET", browser.runtime.getURL( + "senders/media/overlay/overlayContent.js"), false); +req.send(); + +if (req.status === 200) { + // TODO: Replace with cast icons until AirPlay support is ready + const iconAirPlayAudio = browser.runtime.getURL( + "senders/media/overlay/AirPlay_Audio.svg"); + const iconAirPlayVideo = browser.runtime.getURL( + "senders/media/overlay/AirPlay_Audio.svg"); + + const scriptElement = document.createElement("script"); + scriptElement.textContent = `(function(){ + const iconAirPlayAudio = "${iconAirPlayAudio}"; + const iconAirPlayVideo = "${iconAirPlayVideo}"; + const mediaOverlayTitle = "${_("mediaOverlayTitle", "X")}"; + ${req.responseText} + })();`; + + // probably doesn't exist yet + (document.head || document.documentElement).append(scriptElement); +} diff --git a/ext/src/senders/mirroringCast.ts b/ext/src/senders/mirroring.ts similarity index 100% rename from ext/src/senders/mirroringCast.ts rename to ext/src/senders/mirroring.ts diff --git a/ext/src/ui/options/index.tsx b/ext/src/ui/options/index.tsx index 9954e7a..cf4c094 100644 --- a/ext/src/ui/options/index.tsx +++ b/ext/src/ui/options/index.tsx @@ -198,6 +198,21 @@ class OptionsApp extends Component<{}, OptionsAppState> { + + + + + + { _("optionsMediaOverlayEnabledTemp") } + + + { _("optionsMediaOverlayEnabledDescrption") } + + + diff --git a/ext/tsconfig.json b/ext/tsconfig.json index 044a820..d561538 100644 --- a/ext/tsconfig.json +++ b/ext/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../tsconfig" , "compilerOptions": { "jsx": "react" - , "lib": [ "esnext", "dom" ] + , "lib": [ "esnext", "dom", "dom.iterable" ] , "moduleResolution": "node" , "sourceMap": true } diff --git a/ext/webpack.config.js b/ext/webpack.config.js index 05eee30..0c83324 100755 --- a/ext/webpack.config.js +++ b/ext/webpack.config.js @@ -15,8 +15,10 @@ module.exports = (env) => ({ "background": `${env.includePath}/background/background.ts` // Sender apps - , "senders/mediaCast": `${env.includePath}/senders/mediaCast.ts` - , "senders/mirroringCast": `${env.includePath}/senders/mirroringCast.ts` + , "senders/media/bundle": `${env.includePath}/senders/media/index.ts` + , "senders/media/overlay/overlayContent": `${env.includePath}/senders/media/overlay/overlayContent.ts` + , "senders/media/overlay/overlayContentLoader": `${env.includePath}/senders/media/overlay/overlayContentLoader.ts` + , "senders/mirroring": `${env.includePath}/senders/mirroring.ts` // Shim entries , "shim/bundle": `${env.includePath}/shim/index.ts` diff --git a/tsconfig.json b/tsconfig.json index d6d4d00..1d9ab69 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,6 @@ "esModuleInterop": true , "module": "commonjs" , "noImplicitAny": true - , "noUnusedLocals": true , "noUnusedParameters": true , "removeComments": true , "resolveJsonModule": true