mirror of
https://github.com/hensm/fx_cast.git
synced 2026-06-08 08:39:59 +00:00
Implement initial media overlay
This commit is contained in:
@@ -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."
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Options } from "./lib/options";
|
||||
export default {
|
||||
bridgeApplicationName: APPLICATION_NAME
|
||||
, mediaEnabled: true
|
||||
, mediaOverlayEnabled: false
|
||||
, mediaSyncElement: false
|
||||
, mediaStopOnUnload: false
|
||||
, localMediaEnabled: true
|
||||
|
||||
6
ext/src/icons/AirPlay_Audio.svg
Normal file
6
ext/src/icons/AirPlay_Audio.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 125 125" fill="white">
|
||||
<path d="M43.5 84.1l1.3-1.5c.3-.3.3-.8 0-1.1-10.5-9.7-11.2-26.2-1.4-36.7s26.2-11.2 36.7-1.4 11.2 26.2 1.4 36.7c-.5.5-.9 1-1.4 1.4-.3.3-.3.8 0 1.1l1.3 1.5c.3.3.8.3 1.1.1 12-11.1 12.7-29.7 1.7-41.7-11.1-12-29.7-12.7-41.7-1.7s-12.7 29.7-1.7 41.7c.5.6 1.1 1.1 1.7 1.7.3.2.7.2 1-.1z"/>
|
||||
<path d="M44.8 62.5c0-9.7 7.9-17.6 17.6-17.6S80 52.9 80 62.6c0 4.8-2 9.5-5.5 12.8-.3.3-.3.8 0 1.1l1.3 1.5c.3.3.8.4 1.1.1 8.5-8 8.9-21.3 1-29.8s-21.3-8.9-29.8-1-9 21.2-1.1 29.7c.3.3.6.7 1 1 .3.3.8.3 1.1 0l1.3-1.5c.3-.3.3-.8 0-1.1-3.5-3.3-5.6-8-5.6-12.9z"/>
|
||||
<path d="M53.2 62.5c0-5.1 4.1-9.2 9.2-9.2s9.2 4.1 9.2 9.2c0 2.5-1 4.8-2.8 6.6-.3.3-.3.8 0 1.1l1.3 1.5c.3.3.8.3 1.1 0 5-4.9 5.2-12.9.3-18s-12.9-5.2-18-.3-5.2 12.9-.3 18l.3.3c.3.3.8.3 1.1 0l1.3-1.5c.3-.3.3-.8 0-1.1-1.7-1.7-2.7-4.1-2.7-6.6z"/>
|
||||
<path d="M80.9 89.1L63.5 69.3c-.5-.6-1.3-.6-1.9-.1l-.1.1-17.6 19.8c-.4.5-.4 1.2.1 1.7.2.2.5.3.7.3H80c.6 0 1.2-.5 1.2-1.2 0-.3-.1-.6-.3-.8z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1016 B |
3
ext/src/icons/AirPlay_Video.svg
Normal file
3
ext/src/icons/AirPlay_Video.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 125 125" fill="white">
|
||||
<path d="M81 88.8c.4.5.4 1.3-.1 1.7-.2.2-.5.3-.8.3H44.9c-.7 0-1.2-.5-1.2-1.2 0-.3.1-.6.3-.8l17.5-20.1c.5-.6 1.3-.6 1.9-.1l.1.1L81 88.8zm-4.1-11.1l-2.8-3.3h10.5c.9.1 1.7-.1 2.5-.4.5-.3 1-.7 1.2-1.2.4-.8.5-1.7.4-2.5V45.8c.1-.9-.1-1.7-.4-2.5-.3-.5-.7-1-1.2-1.2-.8-.4-1.7-.5-2.5-.4h-44c-.9-.1-1.7.1-2.5.4-.5.3-1 .7-1.2 1.2-.4.8-.5 1.7-.4 2.5v24.4c-.1.9.1 1.7.4 2.5.3.5.7 1 1.2 1.2.8.4 1.7.5 2.5.4h10.5l-2.8 3.3h-6.7c-3 0-4-.3-5-.9-1.1-.6-1.9-1.4-2.5-2.5-.6-1.1-.9-2.1-.9-5V46.7c0-3 .3-4 .9-5.1.6-1.1 1.4-1.9 2.5-2.5 1.1-.6 2.1-.9 5-.9h42.2c3 0 4 .3 5.1.9 1.1.6 1.9 1.4 2.5 2.5.6 1.1.9 2.1.9 5.1v22.5c0 3-.3 4-.9 5-.6 1.1-1.4 1.9-2.5 2.5-1.1.6-2.1.9-5.1.9l-6.9.1z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 749 B |
@@ -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
|
||||
|
||||
@@ -30,7 +30,10 @@
|
||||
, "content_scripts": [
|
||||
{
|
||||
"all_frames": true
|
||||
, "js": [ "shim/content.js" ]
|
||||
, "js": [
|
||||
"shim/content.js"
|
||||
, "senders/media/overlay/overlayContentLoader.js"
|
||||
]
|
||||
, "matches": [ "<all_urls>" ]
|
||||
, "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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
6
ext/src/senders/media/overlay/AirPlay_Audio.svg
Normal file
6
ext/src/senders/media/overlay/AirPlay_Audio.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 125 125" fill="white">
|
||||
<path d="M43.5 84.1l1.3-1.5c.3-.3.3-.8 0-1.1-10.5-9.7-11.2-26.2-1.4-36.7s26.2-11.2 36.7-1.4 11.2 26.2 1.4 36.7c-.5.5-.9 1-1.4 1.4-.3.3-.3.8 0 1.1l1.3 1.5c.3.3.8.3 1.1.1 12-11.1 12.7-29.7 1.7-41.7-11.1-12-29.7-12.7-41.7-1.7s-12.7 29.7-1.7 41.7c.5.6 1.1 1.1 1.7 1.7.3.2.7.2 1-.1z"/>
|
||||
<path d="M44.8 62.5c0-9.7 7.9-17.6 17.6-17.6S80 52.9 80 62.6c0 4.8-2 9.5-5.5 12.8-.3.3-.3.8 0 1.1l1.3 1.5c.3.3.8.4 1.1.1 8.5-8 8.9-21.3 1-29.8s-21.3-8.9-29.8-1-9 21.2-1.1 29.7c.3.3.6.7 1 1 .3.3.8.3 1.1 0l1.3-1.5c.3-.3.3-.8 0-1.1-3.5-3.3-5.6-8-5.6-12.9z"/>
|
||||
<path d="M53.2 62.5c0-5.1 4.1-9.2 9.2-9.2s9.2 4.1 9.2 9.2c0 2.5-1 4.8-2.8 6.6-.3.3-.3.8 0 1.1l1.3 1.5c.3.3.8.3 1.1 0 5-4.9 5.2-12.9.3-18s-12.9-5.2-18-.3-5.2 12.9-.3 18l.3.3c.3.3.8.3 1.1 0l1.3-1.5c.3-.3.3-.8 0-1.1-1.7-1.7-2.7-4.1-2.7-6.6z"/>
|
||||
<path d="M80.9 89.1L63.5 69.3c-.5-.6-1.3-.6-1.9-.1l-.1.1-17.6 19.8c-.4.5-.4 1.2.1 1.7.2.2.5.3.7.3H80c.6 0 1.2-.5 1.2-1.2 0-.3-.1-.6-.3-.8z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1016 B |
3
ext/src/senders/media/overlay/AirPlay_Video.svg
Normal file
3
ext/src/senders/media/overlay/AirPlay_Video.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 125 125" fill="white">
|
||||
<path d="M81 88.8c.4.5.4 1.3-.1 1.7-.2.2-.5.3-.8.3H44.9c-.7 0-1.2-.5-1.2-1.2 0-.3.1-.6.3-.8l17.5-20.1c.5-.6 1.3-.6 1.9-.1l.1.1L81 88.8zm-4.1-11.1l-2.8-3.3h10.5c.9.1 1.7-.1 2.5-.4.5-.3 1-.7 1.2-1.2.4-.8.5-1.7.4-2.5V45.8c.1-.9-.1-1.7-.4-2.5-.3-.5-.7-1-1.2-1.2-.8-.4-1.7-.5-2.5-.4h-44c-.9-.1-1.7.1-2.5.4-.5.3-1 .7-1.2 1.2-.4.8-.5 1.7-.4 2.5v24.4c-.1.9.1 1.7.4 2.5.3.5.7 1 1.2 1.2.8.4 1.7.5 2.5.4h10.5l-2.8 3.3h-6.7c-3 0-4-.3-5-.9-1.1-.6-1.9-1.4-2.5-2.5-.6-1.1-.9-2.1-.9-5V46.7c0-3 .3-4 .9-5.1.6-1.1 1.4-1.9 2.5-2.5 1.1-.6 2.1-.9 5-.9h42.2c3 0 4 .3 5.1.9 1.1.6 1.9 1.4 2.5 2.5.6 1.1.9 2.1.9 5.1v22.5c0 3-.3 4-.9 5-.6 1.1-1.4 1.9-2.5 2.5-1.1.6-2.1.9-5.1.9l-6.9.1z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 749 B |
75
ext/src/senders/media/overlay/descriptorUtils.ts
Normal file
75
ext/src/senders/media/overlay/descriptorUtils.ts
Normal file
@@ -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<T> (
|
||||
target: T, props: Array<any/*keyof typeof T*/>)
|
||||
: PropertyDescriptorMap {
|
||||
|
||||
return props.reduce<PropertyDescriptorMap>((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
|
||||
};
|
||||
}
|
||||
366
ext/src/senders/media/overlay/overlayContent.ts
Normal file
366
ext/src/senders/media/overlay/overlayContent.ts
Normal file
@@ -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<Element, ShadowRoot>();
|
||||
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 = `
|
||||
<style>
|
||||
:host {
|
||||
display: inline-flex;
|
||||
font: menu;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
:host[hidden],
|
||||
.overlay[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
video {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
align-items: center;
|
||||
background-color: rgba(0, 0, 0, 0.85);
|
||||
color: white;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.overlay__icon {
|
||||
background-image: url("${iconUrl}");
|
||||
height: 125px;
|
||||
width: 125px;
|
||||
}
|
||||
.overlay__text {
|
||||
font-size: 18px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="overlay" hidden>
|
||||
<div class="overlay__icon"></div>
|
||||
<div class="overlay__text">
|
||||
${mediaOverlayTitle}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 `<audio>`/`<video>` 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<Iterable<any>>) {
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
31
ext/src/senders/media/overlay/overlayContentLoader.ts
Normal file
31
ext/src/senders/media/overlay/overlayContentLoader.ts
Normal file
@@ -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}
|
||||
})();`;
|
||||
|
||||
// <head> probably doesn't exist yet
|
||||
(document.head || document.documentElement).append(scriptElement);
|
||||
}
|
||||
@@ -198,6 +198,21 @@ class OptionsApp extends Component<{}, OptionsAppState> {
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="option option--inline">
|
||||
<div className="option__control">
|
||||
<input name="mediaOverlayEnabled"
|
||||
type="checkbox"
|
||||
checked={ this.state.options.mediaOverlayEnabled }
|
||||
onChange={ this.handleInputChange } />
|
||||
</div>
|
||||
<div className="option__label">
|
||||
{ _("optionsMediaOverlayEnabledTemp") }
|
||||
</div>
|
||||
<div className="option__description">
|
||||
{ _("optionsMediaOverlayEnabledDescrption") }
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<hr />
|
||||
|
||||
<label className="option option--inline">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"extends": "../tsconfig"
|
||||
, "compilerOptions": {
|
||||
"jsx": "react"
|
||||
, "lib": [ "esnext", "dom" ]
|
||||
, "lib": [ "esnext", "dom", "dom.iterable" ]
|
||||
, "moduleResolution": "node"
|
||||
, "sourceMap": true
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
"esModuleInterop": true
|
||||
, "module": "commonjs"
|
||||
, "noImplicitAny": true
|
||||
, "noUnusedLocals": true
|
||||
, "noUnusedParameters": true
|
||||
, "removeComments": true
|
||||
, "resolveJsonModule": true
|
||||
|
||||
Reference in New Issue
Block a user