From 9e196465e5b54fd7e667dfe6aef4b1c0962d00b9 Mon Sep 17 00:00:00 2001 From: hensm Date: Sat, 27 Jul 2019 07:56:43 +0100 Subject: [PATCH] Allow mediaCast sender to run in background context --- app/src/bridge/index.ts | 2 - ext/src/SelectorManager.ts | 4 +- ext/src/createShim.ts | 327 +++++++++------- ext/src/defaultOptions.ts | 2 +- ext/src/lib/loadSender.ts | 10 +- ext/src/lib/mediaCasting.ts | 81 ---- ext/src/main.ts | 79 +--- ext/src/senders/mediaCast.ts | 462 ++++++++++++----------- ext/src/shim/cast/classes/Session.ts | 20 +- ext/src/shim/cast/index.ts | 7 +- ext/src/shim/cast/media/classes/Media.ts | 2 +- ext/src/shim/export.ts | 54 ++- 12 files changed, 527 insertions(+), 523 deletions(-) delete mode 100644 ext/src/lib/mediaCasting.ts diff --git a/app/src/bridge/index.ts b/app/src/bridge/index.ts index 30ad251..fd71643 100755 --- a/app/src/bridge/index.ts +++ b/app/src/bridge/index.ts @@ -276,11 +276,9 @@ function handleMediaServerMessage (message: Message) { sendMessage("mediaCast:/mediaServer/started"); }); mediaServer.on("close", () => { - console.error("mediaServer close"); sendMessage("mediaCast:/mediaServer/stopped"); }); mediaServer.on("error", (a) => { - console.error("mediaServer error", a); sendMessage("mediaCast:/mediaServer/error"); }); diff --git a/ext/src/SelectorManager.ts b/ext/src/SelectorManager.ts index 80802c7..32eb4d1 100644 --- a/ext/src/SelectorManager.ts +++ b/ext/src/SelectorManager.ts @@ -41,8 +41,8 @@ async function getSelection ( ReceiverSelectorMediaType.Tab , availableMediaTypes = ReceiverSelectorMediaType.Tab - | ReceiverSelectorMediaType.Screen) - // | ReceiverSelectorMediaType.File) + | ReceiverSelectorMediaType.Screen + | ReceiverSelectorMediaType.File) : Promise { return new Promise(async (resolve, reject) => { diff --git a/ext/src/createShim.ts b/ext/src/createShim.ts index 5d20752..d5a9db7 100644 --- a/ext/src/createShim.ts +++ b/ext/src/createShim.ts @@ -13,151 +13,218 @@ import SelectorManager from "./SelectorManager"; import StatusManager from "./StatusManager"; +type Port = browser.runtime.Port | MessagePort; + export interface Shim { bridgePort: browser.runtime.Port; - contentPort?: browser.runtime.Port; + contentPort: Port; contentTabId?: number; contentFrameId?: number; } -export default async function createShim ( - port: browser.runtime.Port): Promise { - const contentPort = port; - const contentTabId = port.sender.tab.id; - const contentFrameId = port.sender.frameId; +const activeShims = new Set(); - const bridgePort = await bridge.connect(); - - - /** - * If either the bridge port or the content port disconnects, - * just teardown all communication. - */ - function onDisconnect () { - bridgePort.onMessage.removeListener(onBridgePortMessage); - contentPort.onMessage.removeListener(onContentPortMessage); - - // Ensure all ports are disconnected - contentPort.disconnect(); - bridgePort.disconnect(); +StatusManager.addEventListener("serviceUp", ev => { + for (const shim of activeShims) { + shim.contentPort.postMessage({ + subject: "shim:/serviceUp" + , data: { id: ev.detail.id } + }); } +}); - bridgePort.onDisconnect.addListener(onDisconnect); - contentPort.onDisconnect.addListener(onDisconnect); - - - // Add listeners - bridgePort.onMessage.addListener(onBridgePortMessage); - contentPort.onMessage.addListener(onContentPortMessage); - - function onBridgePortMessage (message: Message) { - contentPort.postMessage(message); - } - - async function onContentPortMessage (message: Message) { - const [ destination ] = message.subject.split(":/"); - if (destination === "bridge") { - bridgePort.postMessage(message); - } - - switch (message.subject) { - case "main:/shimInitialized": { - for (const receiver of StatusManager.getReceivers()) { - contentPort.postMessage({ - subject: "shim:/serviceUp" - , data: { id: receiver.id } - }); - } - - break; - } - - case "main:/selectReceiverBegin": { - const allMediaTypes = - ReceiverSelectorMediaType.App - | ReceiverSelectorMediaType.Tab - | ReceiverSelectorMediaType.Screen - | ReceiverSelectorMediaType.File; - - try { - const selection = await SelectorManager.getSelection( - ReceiverSelectorMediaType.App - , allMediaTypes); - - // Handle cancellation - if (!selection) { - contentPort.postMessage({ - subject: "shim:/selectReceiverCancelled" - }); - - break; - } - - /** - * If the media type returned from the selector has been - * changed, we need to cancel the current sender and switch - * it out for the right one. - */ - if (selection.mediaType !== ReceiverSelectorMediaType.App) { - contentPort.postMessage({ - subject: "shim:/selectReceiverCancelled" - }); - - loadSender({ - tabId: contentTabId - , frameId: contentFrameId - , selection - }); - - break; - } - - // Pass selection back to shim - contentPort.postMessage({ - subject: "shim:/selectReceiverEnd" - , data: selection - }); - - } catch (err) { - // TODO: Report errors properly - contentPort.postMessage({ - subject: "shim:/selectReceiverCancelled" - }); - } - - break; - } - - /** - * TODO: If we're closing a selector, make sure it's the - * same one that caused the session creation. - */ - case "main:/sessionCreated": { - const selector = await SelectorManager.getSharedSelector(); - - const shouldClose = await options.get( - "receiverSelectorWaitForConnection"); - - if (selector.isOpen && shouldClose) { - selector.close(); - } - - break; - } - } +StatusManager.addEventListener("serviceDown", ev => { + for (const shim of activeShims) { + shim.contentPort.postMessage({ + subject: "shim:/serviceDown" + , data: { id: ev.detail.id } + }); } +}); - contentPort.postMessage({ +async function createShim (port: Port): Promise { + const shim = await (port instanceof MessagePort + ? createShimFromBackground(port) + : createShimFromContent(port)); + + shim.contentPort.postMessage({ subject: "shim:/initialized" , data: await bridge.getInfo() }); - return { - bridgePort - , contentPort - , contentTabId - , contentFrameId - }; + activeShims.add(shim); } + + +async function createShimFromBackground ( + contentPort: MessagePort): Promise { + + const shim: Shim = { + bridgePort: await bridge.connect() + , contentPort + }; + + shim.bridgePort.onDisconnect.addListener(() => { + contentPort.close(); + activeShims.delete(shim); + }); + + shim.bridgePort.onMessage.addListener((message: Message) => { + contentPort.postMessage(message); + }); + + contentPort.onmessage = ev => { + const message = ev.data as Message; + handleContentMessage(shim, message); + }; + + return shim; +} + + +async function createShimFromContent ( + contentPort: browser.runtime.Port): Promise { + + /** + * If there's already an active shim for the sender + * tab/frame ID, disconnect it. + */ + for (const activeShim of activeShims) { + if (activeShim.contentTabId === contentPort.sender.tab.id + && activeShim.contentFrameId === contentPort.sender.frameId) { + activeShim.bridgePort.disconnect(); + } + } + + const shim: Shim = { + bridgePort: await bridge.connect() + , contentPort + , contentTabId: contentPort.sender.tab.id + , contentFrameId: contentPort.sender.frameId + }; + + function onContentPortMessage (message: Message) { + handleContentMessage(shim, message); + } + function onBridgePortMessage (message: Message) { + contentPort.postMessage(message); + } + + function onDisconnect () { + shim.bridgePort.onMessage.removeListener(onBridgePortMessage); + contentPort.onMessage.removeListener(onContentPortMessage); + + shim.bridgePort.disconnect(); + contentPort.disconnect(); + + activeShims.delete(shim); + } + + + shim.bridgePort.onDisconnect.addListener(onDisconnect); + shim.bridgePort.onMessage.addListener(onBridgePortMessage); + + contentPort.onDisconnect.addListener(onDisconnect); + contentPort.onMessage.addListener(onContentPortMessage); + + + return shim; +} + + +async function handleContentMessage (shim: Shim, message: Message) { + const [ destination ] = message.subject.split(":/"); + if (destination === "bridge") { + shim.bridgePort.postMessage(message); + } + + switch (message.subject) { + case "main:/shimInitialized": { + for (const receiver of StatusManager.getReceivers()) { + shim.contentPort.postMessage({ + subject: "shim:/serviceUp" + , data: { id: receiver.id } + }); + } + + break; + } + + case "main:/selectReceiverBegin": { + const allMediaTypes = + ReceiverSelectorMediaType.App + | ReceiverSelectorMediaType.Tab + | ReceiverSelectorMediaType.Screen + | ReceiverSelectorMediaType.File; + + try { + const selection = await SelectorManager.getSelection( + ReceiverSelectorMediaType.App + , allMediaTypes); + + // Handle cancellation + if (!selection) { + shim.contentPort.postMessage({ + subject: "shim:/selectReceiverCancelled" + }); + + break; + } + + /** + * If the media type returned from the selector has been + * changed, we need to cancel the current sender and switch + * it out for the right one. + */ + if (selection.mediaType !== ReceiverSelectorMediaType.App) { + shim.contentPort.postMessage({ + subject: "shim:/selectReceiverCancelled" + }); + + loadSender({ + tabId: shim.contentTabId + , frameId: shim.contentFrameId + , selection + }); + + break; + } + + // Pass selection back to shim + shim.contentPort.postMessage({ + subject: "shim:/selectReceiverEnd" + , data: selection + }); + + } catch (err) { + // TODO: Report errors properly + shim.contentPort.postMessage({ + subject: "shim:/selectReceiverCancelled" + }); + } + + break; + } + + /** + * TODO: If we're closing a selector, make sure it's the + * same one that caused the session creation. + */ + case "main:/sessionCreated": { + const selector = await SelectorManager.getSharedSelector(); + + const shouldClose = await options.get( + "receiverSelectorWaitForConnection"); + + if (selector.isOpen && shouldClose) { + selector.close(); + } + + break; + } + } +} + +export default createShim; diff --git a/ext/src/defaultOptions.ts b/ext/src/defaultOptions.ts index c0792b3..1bfd55d 100644 --- a/ext/src/defaultOptions.ts +++ b/ext/src/defaultOptions.ts @@ -15,7 +15,7 @@ export default { , mirroringAppId: MIRRORING_APP_ID , receiverSelectorType: ReceiverSelectorType.Popup , receiverSelectorCloseIfFocusLost: true - , receiverSelectorWaitForConnection: false + , receiverSelectorWaitForConnection: true , userAgentWhitelistEnabled: true , userAgentWhitelist: [ "https://www.netflix.com/*" diff --git a/ext/src/lib/loadSender.ts b/ext/src/lib/loadSender.ts index a841f67..6b5a469 100644 --- a/ext/src/lib/loadSender.ts +++ b/ext/src/lib/loadSender.ts @@ -1,7 +1,5 @@ "use strict"; -import mediaCasting from "./mediaCasting"; - import { stringify } from "./utils"; import { ReceiverSelection @@ -45,8 +43,12 @@ export default async function loadSender (opts: LoadSenderOptions) { case ReceiverSelectorMediaType.File: { const fileUrl = new URL(`file://${opts.selection.filePath}`); - const mediaSession = await mediaCasting.loadMediaUrl( - fileUrl.href, opts.selection.receiver); + const { init } = await import("../senders/mediaCast"); + + init({ + mediaUrl: fileUrl.href + , receiver: opts.selection.receiver + }); break; } diff --git a/ext/src/lib/mediaCasting.ts b/ext/src/lib/mediaCasting.ts deleted file mode 100644 index 5035aa8..0000000 --- a/ext/src/lib/mediaCasting.ts +++ /dev/null @@ -1,81 +0,0 @@ -"use strict"; - -import cast, { ensureInit } from "../shim/export"; -import options from "./options"; - -import { Receiver } from "../types"; - - -function getMediaSession ( - receiver?: Receiver): Promise { - - return new Promise(async (resolve, reject) => { - await ensureInit(); - - /** - * If a receiver is available, call requestSession. If a - * specific receiver was specified, bypass receiver selector - * and create session directly. - */ - function receiverListener (availability: string) { - if (availability === cast.ReceiverAvailability.AVAILABLE) { - if (receiver) { - cast._requestSession(receiver, resolve, reject); - } else { - cast.requestSession(resolve, reject); - } - } - } - - const sessionRequest = new cast.SessionRequest( - cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID); - - const apiConfig = new cast.ApiConfig( - sessionRequest - , null // sessionListener - , receiverListener); // receiverListener - - cast.initialize(apiConfig); - }); -} - -function loadMediaUrl ( - mediaUrl: string - , receiver: Receiver): Promise { - - return new Promise(async (resolve, reject) => { - - const isLocalMedia = mediaUrl.startsWith("file://"); - const isLocalMediaEnabled = await options.get("localMediaEnabled"); - - if (isLocalMedia && !isLocalMediaEnabled) { - console.error("fx_cast (Debug): Local media casting not enabled"); - return; - } - - - const mediaUrlObject = new URL(mediaUrl); - const mediaInfo = new cast.media.MediaInfo(mediaUrlObject.href, null); - - mediaInfo.metadata = new cast.media.GenericMediaMetadata(); - mediaInfo.metadata.metadataType = cast.media.MetadataType.GENERIC; - mediaInfo.metadata.title = mediaUrlObject.pathname; - - - const mediaSession = await getMediaSession(receiver); - - const loadRequest = new cast.media.LoadRequest(mediaInfo); - loadRequest.autoplay = false; - - mediaSession.loadMedia(loadRequest - , null // successCallback - , () => { reject(); }); // errorCallback - }); -} - - -export default { - getMediaSession - , loadMediaUrl -}; - diff --git a/ext/src/main.ts b/ext/src/main.ts index fb1e5fb..3133501 100755 --- a/ext/src/main.ts +++ b/ext/src/main.ts @@ -3,7 +3,6 @@ import defaultOptions from "./defaultOptions"; import bridge from "./lib/bridge"; import loadSender from "./lib/loadSender"; -import mediaCasting from "./lib/mediaCasting"; import options, { Options } from "./lib/options"; import { getChromeUserAgent } from "./lib/userAgents"; @@ -49,6 +48,17 @@ browser.runtime.onInstalled.addListener(async details => { }); +/** + * When a message port connection with the name "shim" is + * established, pass it to createShim to handle the setup + * and maintenance. + */ +browser.runtime.onConnect.addListener(async port => { + if (port.name === "shim") { + createShim(port); + } +}); + /** * When the browser action is clicked, open a receiver * selector and load a sender for the response. The @@ -66,61 +76,6 @@ browser.browserAction.onClicked.addListener(async tab => { }); - -const activeShims = new Set(); - -browser.runtime.onConnect.addListener(async port => { - if (port.name === "shim") { - /** - * If there's already an active shim for the sender - * tab/frame ID, disconnect it. - */ - for (const activeShim of activeShims) { - if (activeShim.contentTabId === port.sender.tab.id - && activeShim.contentFrameId === port.sender.frameId) { - - activeShim.contentPort.disconnect(); - activeShim.bridgePort.disconnect(); - } - } - - - const shim = await createShim(port); - - shim.bridgePort.onDisconnect.addListener(() => { - activeShims.delete(shim); - }); - shim.contentPort.onDisconnect.addListener(() => { - activeShims.delete(shim); - }); - - activeShims.add(shim); - } -}); - -StatusManager.addEventListener("serviceUp", ev => { - for (const shim of activeShims) { - shim.contentPort.postMessage({ - subject: "shim:/serviceUp" - , data: { id: ev.detail.id } - }); - } -}); - -StatusManager.addEventListener("serviceDown", ev => { - for (const shim of activeShims) { - shim.contentPort.postMessage({ - subject: "shim:/serviceDown" - , data: { id: ev.detail.id } - }); - } -}); - - - -let mediaCastTabId: number; -let mediaCastFrameId: number; - async function initMenus () { console.info("fx_cast (Debug): init (menus)"); @@ -135,8 +90,8 @@ async function initMenus () { const allMediaTypes = ReceiverSelectorMediaType.App | ReceiverSelectorMediaType.Tab - | ReceiverSelectorMediaType.Screen; - // | ReceiverSelectorMediaType.File; + | ReceiverSelectorMediaType.Screen + | ReceiverSelectorMediaType.File; const selection = await SelectorManager.getSelection( ReceiverSelectorMediaType.App @@ -154,8 +109,8 @@ async function initMenus () { if (selection.mediaType === ReceiverSelectorMediaType.App) { await browser.tabs.executeScript(tab.id, { code: stringify` - window.selectedReceiver = ${selection.receiver}; - window.srcUrl = ${info.srcUrl}; + window.receiver = ${selection.receiver}; + window.mediaUrl = ${info.srcUrl}; window.targetElementId = ${info.targetElementId}; ` , frameId: info.frameId @@ -165,10 +120,6 @@ async function initMenus () { file: "senders/mediaCast.js" , frameId: info.frameId }); - - // Store for later - mediaCastTabId = tab.id; - mediaCastFrameId = info.frameId; } else { // Handle other responses diff --git a/ext/src/senders/mediaCast.ts b/ext/src/senders/mediaCast.ts index 8fd1416..186af52 100644 --- a/ext/src/senders/mediaCast.ts +++ b/ext/src/senders/mediaCast.ts @@ -1,44 +1,11 @@ "use strict"; -import mediaCasting from "../lib/mediaCasting"; import options from "../lib/options"; import cast, { ensureInit } from "../shim/export"; import { Message, Receiver } from "../types"; -// Variables passed from background -const { selectedReceiver - , srcUrl - , targetElementId } - : { selectedReceiver: Receiver - , srcUrl: string - , targetElementId: number } = (window as any); - - -let backgroundPort: browser.runtime.Port; - -let session: cast.Session; -let currentMedia: cast.media.Media; - -let ignoreMediaEvents = false; - - -const isLocalFile = srcUrl.startsWith("file:"); - -const mediaElement = browser.menus.getTargetElement( - targetElementId) as HTMLMediaElement; - -window.addEventListener("beforeunload", async () => { - backgroundPort.postMessage({ - subject: "bridge:/mediaServer/stop" - }); - - if (await options.get("mediaStopOnUnload")) { - session.stop(null, null); - } -}); - function getLocalAddress () { const pc = new RTCPeerConnection(); pc.createDataChannel(null); @@ -56,7 +23,6 @@ function getLocalAddress () { }); } - function startMediaServer (filePath: string, port: number) { return new Promise((resolve, reject) => { backgroundPort.postMessage({ @@ -67,177 +33,235 @@ function startMediaServer (filePath: string, port: number) { } }); - backgroundPort.onMessage.addListener( - function onMessage (message: Message) { + backgroundPort.addEventListener("message", function onMessage (ev) { + const message = ev.data as Message; + + if (message.subject.startsWith("mediaCast:/mediaServer/")) { + backgroundPort.removeEventListener("message", onMessage); + } switch (message.subject) { case "mediaCast:/mediaServer/started": { - backgroundPort.onMessage.removeListener(onMessage); resolve(); + break; } case "mediaCast:/mediaServer/error": { - backgroundPort.onMessage.removeListener(onMessage); reject(); + break; } } }); + + backgroundPort.start(); }); } -async function loadMedia () { - let mediaUrl = new URL(srcUrl); - const mediaTitle = mediaUrl.pathname; - /** - * If the media is a local file, start an HTTP media server - * and change the media URL to point to it. - */ - if (isLocalFile) { - const host = await getLocalAddress(); - const port = await options.get("localMediaServerPort"); +let backgroundPort: MessagePort; - try { - // Wait until media server is listening - await startMediaServer(mediaUrl.pathname, port); - } catch (err) { - console.error("Failed to start media server"); - return; +let currentSession: cast.Session; +let currentMedia: cast.media.Media; + +let mediaElement: HTMLMediaElement; + + +function getSession (opts: InitOptions): Promise { + return new Promise(async (resolve, reject) => { + /** + * If a receiver is available, call requestSession. If a + * specific receiver was specified, bypass receiver selector + * and create session directly. + */ + function receiverListener (availability: string) { + if (availability === cast.ReceiverAvailability.AVAILABLE) { + if (opts.receiver) { + cast._requestSession( + opts.receiver + , onRequestSessionSuccess + , onRequestSessionError); + } else { + cast.requestSession( + onRequestSessionSuccess + , onRequestSessionError); + } + } } - mediaUrl = new URL(`http://${host}:${port}/`); - } - - - const mediaInfo = new cast.media.MediaInfo(mediaUrl.href, null); - - // Media metadata (title/poster) - mediaInfo.metadata = new cast.media.GenericMediaMetadata(); - mediaInfo.metadata.metadataType = cast.media.MetadataType.GENERIC; - mediaInfo.metadata.title = mediaTitle; - - if (mediaElement instanceof HTMLVideoElement && mediaElement.poster) { - mediaInfo.metadata.images = [ - new cast.Image(mediaElement.poster) - ]; - } - - - const activeTrackIds = []; - - if (mediaElement.textTracks.length) { - const trackElements = mediaElement.querySelectorAll("track"); - - let index = 0; - for (const textTrack of Array.from(mediaElement.textTracks)) { - const trackElement = trackElements[index]; - - // Create Track object - const track = new cast.media.Track( - index // trackId - , cast.media.TrackType.TEXT); // trackType - - // Copy TextTrack properties to Track - track.name = textTrack.label; - track.language = textTrack.language; - track.trackContentId = trackElement.src; - track.trackContentType = "text/vtt"; - - const { TextTrackType } = cast.media; - - switch (textTrack.kind) { - case "subtitles": - track.subtype = TextTrackType.SUBTITLES; - break; - case "captions": - track.subtype = TextTrackType.CAPTIONS; - break; - case "descriptions": - track.subtype = TextTrackType.DESCRIPTIONS; - break; - case "chapters": - track.subtype = TextTrackType.CHAPTERS; - break; - case "metadata": - track.subtype = TextTrackType.METADATA; - break; - - // Default to subtitles - default: - track.subtype = TextTrackType.SUBTITLES; - } - - // Add track to mediaInfo - mediaInfo.tracks.push(track); - - // If enabled, set as active track for load request - if (textTrack.mode === "showing" || trackElement.default) { - activeTrackIds.push(index); - } - - index++; + function onRequestSessionSuccess (session: cast.Session) { + resolve(session); + } + function onRequestSessionError (err: cast.Error) { + reject(err.description); } - } - const loadRequest = new cast.media.LoadRequest(mediaInfo); - loadRequest.autoplay = false; - loadRequest.activeTrackIds = activeTrackIds; - session.loadMedia(loadRequest - , onLoadMediaSuccess - , onLoadMediaError); + const sessionRequest = new cast.SessionRequest( + cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID); + + const apiConfig = new cast.ApiConfig( + sessionRequest + , null // sessionListener + , receiverListener); // receiverListener + + + cast.initialize(apiConfig); + }); +} + +function getMedia (opts: InitOptions): Promise { + return new Promise(async (resolve, reject) => { + let mediaUrlObject = new URL(opts.mediaUrl); + const mediaTitle = mediaUrlObject.pathname; + + /** + * If the media is a local file, start an HTTP media server + * and change the media URL to point to it. + */ + if (opts.mediaUrl.startsWith("file://")) { + const host = await getLocalAddress(); + const port = await options.get("localMediaServerPort"); + + try { + // Wait until media server is listening + await startMediaServer(mediaUrlObject.pathname, port); + } catch (err) { + console.error("Failed to start media server"); + return; + } + + mediaUrlObject = new URL(`http://${host}:${port}/`); + } + + + const activeTrackIds: number[] = []; + const mediaInfo = new cast.media.MediaInfo(mediaUrlObject.href, null); + + mediaInfo.metadata = new cast.media.GenericMediaMetadata(); + mediaInfo.metadata.metadataType = cast.media.MetadataType.GENERIC; + mediaInfo.metadata.title = mediaTitle; + mediaInfo.tracks = []; + + + if (mediaElement) { + if (mediaElement instanceof HTMLVideoElement) { + if (mediaElement.poster) { + mediaInfo.metadata.images = [ + new cast.Image(mediaElement.poster) + ]; + } + } + + if (mediaElement.textTracks.length) { + const tracks = Array.from(mediaElement.textTracks); + const trackElements = mediaElement.querySelectorAll("track"); + + tracks.forEach((track, index) => { + const trackElement = trackElements[index]; + + /** + * Create media.Track object with the index as the track ID + * and type as TrackType.TEXT. + */ + const castTrack = new cast.media.Track( + index, cast.media.TrackType.TEXT); + + // Copy TextTrack properties + castTrack.name = track.label; + castTrack.language = track.language; + castTrack.trackContentId = trackElement.src; + castTrack.trackContentType = "text/vtt"; + + switch (track.kind) { + case "subtitles": + castTrack.subtype = + cast.media.TextTrackType.SUBTITLES; + break; + case "captions": + castTrack.subtype = + cast.media.TextTrackType.CAPTIONS; + break; + case "descriptions": + castTrack.subtype = + cast.media.TextTrackType.DESCRIPTIONS; + break; + case "chapters": + castTrack.subtype = + cast.media.TextTrackType.CHAPTERS; + break; + case "metadata": + castTrack.subtype = + cast.media.TextTrackType.METADATA; + break; + + // Default to subtitles + default: + castTrack.subtype = + cast.media.TextTrackType.SUBTITLES; + } + + // Add track to mediaInfo + mediaInfo.tracks.push(castTrack); + + // If enabled, mark as active track for load request + if (track.mode === "showing" || trackElement.default) { + activeTrackIds.push(index); + } + }); + } + } + + const loadRequest = new cast.media.LoadRequest(mediaInfo); + loadRequest.autoplay = false; + loadRequest.activeTrackIds = activeTrackIds; + + currentSession.loadMedia(loadRequest + , (media) => resolve(media) + , null); + }); } -async function onLoadMediaSuccess (media: cast.media.Media) { - cast.logMessage("onLoadMediaSuccess"); - - currentMedia = media; +let ignoreMediaEvents = false; +async function registerMediaElementListeners () { if (await options.get("mediaSyncElement")) { - mediaElement.addEventListener("play", () => { + + function checkIgnore (ev: Event) { if (ignoreMediaEvents) { ignoreMediaEvents = false; - return; + ev.stopImmediatePropagation(); } + } - currentMedia.play(null - , onMediaPlaySuccess - , onMediaPlayError); + mediaElement.addEventListener("play", checkIgnore, true); + mediaElement.addEventListener("pause", checkIgnore, true); + mediaElement.addEventListener("suspend", checkIgnore, true); + mediaElement.addEventListener("seeking", checkIgnore, true); + mediaElement.addEventListener("ratechange", checkIgnore, true); + mediaElement.addEventListener("volumechange", checkIgnore, true); + + + mediaElement.addEventListener("play", () => { + currentMedia.play(null, null, null); }); mediaElement.addEventListener("pause", () => { - if (ignoreMediaEvents) { - ignoreMediaEvents = false; - return; - } - - currentMedia.pause(null - , onMediaPauseSuccess - , onMediaPauseError); + currentMedia.pause(null, null, null); }); mediaElement.addEventListener("suspend", () => { - /*currentMedia.stop(null - , onMediaStopSuccess - , onMediaStopError);*/ + // currentMedia.stop(null, null, null); }); - mediaElement.addEventListener("seeking", () => { - if (ignoreMediaEvents) { - ignoreMediaEvents = false; - return; - } - + mediaElement.addEventListener("seeked", () => { const seekRequest = new cast.media.SeekRequest(); seekRequest.currentTime = mediaElement.currentTime; - currentMedia.seek(seekRequest - , onMediaSeekSuccess - , onMediaSeekError); + currentMedia.seek(seekRequest, null, null); }); mediaElement.addEventListener("ratechange", () => { - (currentMedia as any)._sendMediaMessage({ + currentMedia._sendMediaMessage({ type: "SET_PLAYBACK_RATE" , playbackRate: mediaElement.playbackRate }); @@ -248,10 +272,8 @@ async function onLoadMediaSuccess (media: cast.media.Media) { currentMedia.volume.level , currentMedia.volume.muted); - const volumeRequest = - new cast.media.VolumeRequest(newVolume); + const volumeRequest = new cast.media.VolumeRequest(newVolume); - cast.logMessage("Volume change"); currentMedia.setVolume(volumeRequest); }); @@ -261,44 +283,46 @@ async function onLoadMediaSuccess (media: cast.media.Media) { return; } - // PlayerState const localPlayerState = mediaElement.paused ? cast.media.PlayerState.PAUSED : cast.media.PlayerState.PLAYING; if (localPlayerState !== currentMedia.playerState) { ignoreMediaEvents = true; + switch (currentMedia.playerState) { - case cast.media.PlayerState.PLAYING: + case cast.media.PlayerState.PLAYING: { mediaElement.play(); break; - - case cast.media.PlayerState.PAUSED: + } + case cast.media.PlayerState.PAUSED: { mediaElement.pause(); break; + } } } - // RepeatMode + const localRepeatMode = mediaElement.loop ? cast.media.RepeatMode.SINGLE : cast.media.RepeatMode.OFF; if (localRepeatMode !== currentMedia.repeatMode) { ignoreMediaEvents = true; + switch (currentMedia.repeatMode) { - case cast.media.RepeatMode.SINGLE: + case cast.media.RepeatMode.SINGLE: { mediaElement.loop = true; break; - - case cast.media.RepeatMode.OFF: + } + case cast.media.RepeatMode.OFF: { mediaElement.loop = false; break; + } } } - // currentTime if (currentMedia.currentTime !== mediaElement.currentTime) { ignoreMediaEvents = true; mediaElement.currentTime = currentMedia.currentTime; @@ -307,57 +331,57 @@ async function onLoadMediaSuccess (media: cast.media.Media) { } } -function onRequestSessionError () { - cast.logMessage("onRequestSessionError"); -} -function sessionListener (newSession: cast.Session) { - cast.logMessage("sessionListener"); -} -function onInitializeSuccess () { - cast.logMessage("onInitializeSuccess"); -} -function onInitializeError () { - cast.logMessage("onInitializeError"); -} -function onLoadMediaError () { - cast.logMessage("onLoadMediaError"); -} -function onMediaPlaySuccess () { - cast.logMessage("onMediaPlaySuccess"); -} -function onMediaPlayError (err: cast.Error) { - cast.logMessage("onMediaPlayError"); -} -function onMediaPauseSuccess () { - cast.logMessage("onMediaPauseSuccess"); -} -function onMediaPauseError (err: cast.Error) { - cast.logMessage("onMediaPauseError"); -} -function onMediaStopSuccess () { - cast.logMessage("onMediaStopSuccess"); -} -function onMediaStopError (err: cast.Error) { - cast.logMessage("onMediaStopError"); -} -function onMediaSeekSuccess () { - cast.logMessage("onMediaSeekSuccess"); -} -function onMediaSeekError (err: cast.Error) { - cast.logMessage("onMediaSeekError"); + +interface InitOptions { + mediaUrl: string; + receiver: Receiver; + targetElementId?: number; } +export async function init (opts: InitOptions) { + backgroundPort = await ensureInit(); -ensureInit().then(async (port) => { - backgroundPort = port; - + const isLocalMedia = opts.mediaUrl.startsWith("file://"); const isLocalMediaEnabled = await options.get("localMediaEnabled"); - if (isLocalFile && !isLocalMediaEnabled) { + + if (isLocalMedia && !isLocalMediaEnabled) { cast.logMessage("Local media casting not enabled"); return; } - session = await mediaCasting.getMediaSession(selectedReceiver); + if (opts.targetElementId) { + mediaElement = browser.menus.getTargetElement( + opts.targetElementId) as HTMLMediaElement; + } - loadMedia(); -}); + currentSession = await getSession(opts); + currentMedia = await getMedia(opts); + + if (opts.targetElementId) { + registerMediaElementListeners(); + + window.addEventListener("beforeunload", async () => { + backgroundPort.postMessage({ + subject: "bridge:/mediaServer/stop" + }); + + if (await options.get("mediaStopOnUnload")) { + currentSession.stop(null, null); + } + }); + } +} + +/** + * If loaded as a content script, the init values are + * provided on the window object. + */ +if (window.location.protocol !== "moz-extension:") { + const _window = (window as any); + + init({ + mediaUrl: _window.mediaUrl + , receiver: _window.receiver + , targetElementId: _window.targetElementId + }); +} diff --git a/ext/src/shim/cast/classes/Session.ts b/ext/src/shim/cast/classes/Session.ts index 168915a..5c8132d 100755 --- a/ext/src/shim/cast/classes/Session.ts +++ b/ext/src/shim/cast/classes/Session.ts @@ -16,6 +16,8 @@ import { ErrorCode , SessionStatus , VolumeControlType } from "../enums"; +import { RepeatMode } from "../media/enums"; + import { ListenerObject , onMessage , sendMessageResponse } from "../../eventMessageChannel"; @@ -305,9 +307,10 @@ export default class Session { , autoplay: loadRequest.autoplay || false , currentTime: loadRequest.currentTime || 0 , customData: loadRequest.customData || {} - , repeatMode: "REPEAT_OFF" + , repeatMode: RepeatMode.OFF }); + let hasResponded = false; this.addMessageListener( @@ -318,23 +321,28 @@ export default class Session { return; } - const mediaObject = JSON.parse(data); + const message = JSON.parse(data); - if (mediaObject.status && mediaObject.status.length > 0) { + if (message.status && message.status.length > 0) { hasResponded = true; const media = new Media( this.sessionId - , mediaObject.status[0].mediaSessionId + , message.status[0].mediaSessionId , _id.get(this)); media.media = loadRequest.media; this.media = [ media ]; media.play(); - successCallback(media); + + if (successCallback) { + successCallback(media); + } } else { - errorCallback(new _Error(ErrorCode.SESSION_ERROR)); + if (errorCallback) { + errorCallback(new _Error(ErrorCode.SESSION_ERROR)); + } } }); } diff --git a/ext/src/shim/cast/index.ts b/ext/src/shim/cast/index.ts index 3fb90fb..f2aa891 100755 --- a/ext/src/shim/cast/index.ts +++ b/ext/src/shim/cast/index.ts @@ -210,9 +210,6 @@ export function _requestSession ( sessionRequestInProgress = true; - sessionSuccessCallback = successCallback; - sessionErrorCallback = errorCallback; - const selectedReceiver = new Receiver_( _receiver.id @@ -235,8 +232,8 @@ export function _requestSession ( sessionRequestInProgress = false; - if (sessionSuccessCallback) { - sessionSuccessCallback(session); + if (successCallback) { + successCallback(session); } })); } diff --git a/ext/src/shim/cast/media/classes/Media.ts b/ext/src/shim/cast/media/classes/Media.ts index 8958b21..124fe4f 100644 --- a/ext/src/shim/cast/media/classes/Media.ts +++ b/ext/src/shim/cast/media/classes/Media.ts @@ -293,7 +293,7 @@ export default class Media { } - private _sendMediaMessage ( + public _sendMediaMessage ( message: any , successCallback?: SuccessCallback , errorCallback?: ErrorCallback) { diff --git a/ext/src/shim/export.ts b/ext/src/shim/export.ts index ab8056f..7b4f7ac 100644 --- a/ext/src/shim/export.ts +++ b/ext/src/shim/export.ts @@ -4,11 +4,12 @@ import * as cast from "./cast"; import { BridgeInfo } from "../lib/bridge"; import { Message } from "../types"; -import { onMessage } from "./eventMessageChannel"; + +import { onMessage, onMessageResponse, sendMessage } from "./eventMessageChannel"; let initializedBridgeInfo: BridgeInfo; -let initializedBackgroundPort: browser.runtime.Port; +let initializedBackgroundPort: MessagePort; /** * To support exporting an API from a module, we need to @@ -17,7 +18,7 @@ let initializedBackgroundPort: browser.runtime.Port; * for and emits these messages, and changing that behavior * is too messy. */ -export function ensureInit (): Promise { +export function ensureInit (): Promise { return new Promise(async (resolve, reject) => { // If already initialized, just return existing bridge info @@ -31,6 +32,9 @@ export function ensureInit (): Promise { return; } + const channel = new MessageChannel(); + initializedBackgroundPort = channel.port1; + /** * If the module is imported into a background script * context, the location will be the internal extension URL, @@ -38,14 +42,48 @@ export function ensureInit (): Promise { * URL. */ if (window.location.protocol === "moz-extension:") { - // + const { default: createShim } = await import("../createShim"); + + // port2 will post bridge messages to port 1 + await createShim(channel.port2); + + // bridge -> shim + channel.port1.onmessage = ev => { + const message = ev.data as Message; + + // Send message to shim + sendMessage(message); + handleIncomingMessageToShim(message); + }; + + // shim -> bridge + onMessageResponse(message => { + channel.port1.postMessage(message); + }); } else { - // Trigger message port setup side-effects + /** + * Import reference to message port created by contentBridge. + * Creation of the port triggers side-effects in the + * background script. + */ const { backgroundPort } = await import("./contentBridge"); - initializedBackgroundPort = backgroundPort; + + // backgroundPort -> channel.port2 + backgroundPort.onMessage.addListener((message: Message) => { + channel.port2.postMessage(message); + }); + + // channel.port2 -> backgroundPort + channel.port2.onmessage = ev => { + const message = ev.data as Message; + backgroundPort.postMessage(message); + }; + + // Handle shim messages + onMessage(handleIncomingMessageToShim); } - onMessage(message => { + function handleIncomingMessageToShim (message: Message) { switch (message.subject) { case "shim:/initialized": { initializedBridgeInfo = message.data; @@ -57,7 +95,7 @@ export function ensureInit (): Promise { } } } - }); + } }); }