diff --git a/add-on/src/addon/javascript/background.js b/add-on/src/addon/javascript/background.js index 5e448af..7dd515e 100644 --- a/add-on/src/addon/javascript/background.js +++ b/add-on/src/addon/javascript/background.js @@ -1,42 +1,258 @@ -console.log("Hello from Universal Music addon!") +let logger = new Logger("background"); + +logger.pushUpdate = function (message) +{ + if (interfacePort) + { + interfacePort.postMessage({ + "messageNum": -1, + "message": message + }); + return true; + } + else + { + return false; + } +}; + +logger.log("Hello from Universal Music addon!") + +/** + * This variable contains a mapping of message IDs to the message promises they correspond to, as + * long as those messages are still pending a response. + */ const tabMessages = new Map(); + +/** + * This counter variable is used to generate session-unique message IDs between this background and + * the various tabs. + */ var numTabMessages = 0; + /* On startup, connect to the "ping_pong" app. */ var interfacePort = browser.runtime.connectNative("universalmusic"); -var tabPort = null; +/** + * This variable contains a mapping between tab IDs and resolution functions that are to be called + * as soon as a connection port is established. + */ +let waitingPorts = new Map(); +/** + * This variable contains a mapping between tab IDs and respective communication ports. + */ +let ports = new Map(); + +/** + * Obtains the host name of a URL + */ +function getHost(url) +{ + return url.replace(/^\w+:\/{2,3}/, "").replace(/[\/#?:]\w+.+$/, "") +} + +/** + * Obtains the domain of a URL + */ +function getDomain(url) +{ + let host = getHost(url); + let parts = host.split("."); + return parts[parts.length - 2] + "." + parts[parts.length - 1]; +} + +/** + * Loads up a tab and returns a promise for when it has completely loaded. + * + * @param {string} url The URL to load. + * @param {boolean} pinned Whether the tab should be pinned or not. Pinned tabs can be reused. + * Defaults to true. + * @return {Promise} A promise that loads the tab. The resolution function is passed the tab + * object upon completion. + */ +function loadTab(url, pinned) +{ + if (typeof pinned != "boolean" && !pinned) + { + pinned = true; + } + let chosen; + return new Promise((resolve, reject) => { + let domain = getDomain(url); + + browser.tabs.query({ + url: "*://*." + domain + "/*", + muted: false + }).then(tabs => { + let callback = (tabId, changed, tab) => { + if (tab.status == "complete") + { + logger.debug("Tab %d complete", tab.id); + if (browser.tabs.onUpdated.hasListener(callback)) + { + browser.tabs.onUpdated.removeListener(callback) + } + resolve(tab); + } + }; + let navigate = (tabId, changed, tab) => { + if (tab.status == "complete") + { + if (browser.tabs.onUpdated.hasListener(navigate)) + { + browser.tabs.onUpdated.removeListener(navigate) + } + chosen = tabId; + logger.debug("Redirecting tab %d %o", tabId, tab); + /* + * Reloading the tab disconnects the port. We will have to wait for a + * reconnection. + */ + waitingPorts.set(tab.id, { + "resolve": callback, + "reject": reject + }); +// browser.tabs.onUpdated.addListener(callback, { +// urls: [url], +// properties: ["status"] +// }); + browser.tabs.update(tabId, { + url: url + }).then(() => {}, reject); + } + }; + let tab; + + /* + * If there are no available tabs (or if we are instructed to create a new tab for this + * task), then create a new tab and wait for a connection to be established. + */ + logger.log("Found %o tabs", tabs); + if (tabs.length == 0 || !pinned) + { +// browser.tabs.onUpdated.addListener(callback, { +// urls: [url], +// properties: ["status"] +// }); + browser.tabs.create({ + url: url + }).then(tab => { + chosen = tab.id; + waitingPorts.set(tab.id, { + "resolve": callback, + "reject": reject + }); + browser.tabs.update(tab.id, { + muted: !pinned + }); + }, reject); + } + /* + * Otherwise, use an existing tab and set it to a new URL. + */ + else + { + let i = 0; + while (tabs[i].mutedInfo.muted) + { + i++; + } + tab = tabs[i]; + if (tab.status == "loading") + { + /* + * Only navigate if the tab has finished its task already. + */ + browser.tabs.onUpdated.addListener(navigate, { + urls: [tab.url], + properties: ["status"], + tabId: tab.id + }); + } + else + { + navigate(tab.id, {}, tab); + } + } + }, reject); + }).finally(() => { + if (!pinned) + { + browser.tabs.remove(chosen); + } + }); +} + +/** + * This callback is called every time a new tab attempts to connect to this background process. + * + * @param {runtime.Port} port - The port object that the tab will communicate through. + * @return {boolean} true if the connection was successful, false otherwise. + */ function setupTab(port) { if (port.sender) { - console.info("Tab " + port.sender.tab.url + " loaded."); + logger.info("Tab " + port.sender.tab.url + " loaded."); } else { - console.info("Tab loaded"); + logger.info("Tab loaded"); } - tabPort = port; - tabPort.onDisconnect.addListener(e => { - tabPort = null; + ports.set(port.sender.tab.id, port); + if (waitingPorts.has(port.sender.tab.id)) + { + console.log("Setting up port %o", port.sender.tab); + waitingPorts.get(port.sender.tab.id).resolve(port.sender.tab.id, {}, port.sender.tab); + waitingPorts.delete(port.sender.tab.id); + } + else + { + logger.warn("We just connected to a tab that we didn't request."); + } + port.onDisconnect.addListener(e => { + ports.delete(e.sender.tab.id); if (e.error) { - console.error("Tab error: ", e.error) + logger.error("Tab error: %o", e.error) } else { - console.info("Tab " + e.sender.tab.url + " disconnected."); + logger.info("Tab " + e.sender.tab.url + " disconnected."); } - browser.tabs.remove(e.sender.tab.id); +// browser.tabs.remove(e.sender.tab.id); }); - tabPort.onMessage.addListener(message => { + + /** + * Listens for messages from the tab and processes their promises + */ + port.onMessage.addListener(message => { + let returnValue; if (typeof message == "object") { if (message.type == "response") { - tabMessages.set(message.num, message.data); + returnValue = tabMessages.get(message.num); + logger.trace("Returning message %d, %o", message.num, returnValue) + if (returnValue) + { + tabMessages.delete(message.num); + if (message.data instanceof Error) + { + returnValue.reject(message.data); + } + else + { + returnValue.resolve(message.data); + } + } + else + { + logger.warn("Couldn't find message number %d", message.num); + } } else if (message.type == "update") { @@ -44,45 +260,121 @@ function setupTab(port) "messageNum": -1, "message": message.data } - console.trace("Sending update ", returnValue) + logger.trace("Sending update %o", returnValue) interfacePort.postMessage(returnValue); } + else + { + logger.warn("Unknown message type %s", message.type); + } + } + else + { + logger.warn("Unknown message type %s", typeof message, message); } }); return true; } -function queryTab(message) +/** + * Sends a message to specified tab and handles the response. + * @param {id,string,tabs.Tab} tab -The tab to send to. This can be the ID of the tab, the hostname + * or domain name of the tab, an array of url matchers, or the tab + * object itself. + * @return {Promise} A promise that passes the tab's response to the resolver. + */ +function queryTab(tab, message) { let num = numTabMessages++; - let promise = new Promise((resolve, reject) => { - tabPort.postMessage({ - num: num, - data: message - }); - (function awaitResponse() { - if (tabMessages.has(num)) + + let tabId; + if (typeof tab == "string") + { + if (tab.match(/^\w+\.\w+$/)) + { + tab = "*://*." + tab + "/*"; + } + else if (tab.match(/^(\w\.)+\w+\.\w+$/)) + { + tab = "*://" + tab + "/*"; + } + tab = [tab]; + } + else if ("id" in tab) + { + tab = tab.id; + } + + return new Promise((resolve, reject) => { + let query = port => { + let messageReturn = { + resolve: resolve, + reject: reject + }; + tabMessages.set(num, messageReturn); + logger.log("Querying tab %o", message); + port.postMessage({ + num: num, + data: message + }) + }; + + if (typeof tab == "number") + { + if (ports.has(tab)) { - response = tabMessages.get(num); - tabMessages.delete(num); - return resolve(response); + query(ports.get(tab)); } else { - setTimeout(awaitResponse, 30); + reject(new ReferenceError("Could not find tab with communications port under " + tab)); } - })(); + } + else if (tab instanceof Array) + { + tabs = tabs.query({ + url: tab, + status: "complete" + }).then(tabs => { + if (tabs && tabs.length > 0) + { + for (let i = 0; i < tabs.length; i++) + { + if (ports.has(tabs[i].id)) + { + query(ports.get(tabs[i].id)); + return; + } + } + /* + * We only reach here if we couldn't find a valid tab. While we could just fall + * through to the other reject statement, this one can provide a clearer error + * message. + */ + reject(new ReferenceError("Could not find tab with communications under " + tab)); + } + else + { + reject(new ReferenceError("Could not find tab under " + tab)); + } + }, reject); + } }); - tabMessages.set(numTabMessages, promise); - numTabMessages++; - return promise; } +/** + * A callback for when the native application sends a message. + * + * @param {object} message - The message to process. + * @param {object} returnValue -The value returned by the last message listener. Return either this + * or a new value. + * @return Either the value passed on returnValue or a new value. + */ var listeners = [function (message, returnValue) { if (typeof message == "object" && "type" in message) { let type = message.type; - lastPeriod = type.lastIndexOf("."); + let lastPeriod = type.lastIndexOf("."); if (lastPeriod != -1) { type = type.substring(lastPeriod + 1); @@ -90,8 +382,28 @@ var listeners = [function (message, returnValue) { switch (type) { case "NumberPing": - returnValue = message.number; + if (message.url) + { + let tabId; + return loadTab(message.url).then(tab => { + tabId = tab.id; + return queryTab(tab, message); + }).then(data => { +// browser.tabs.remove(tabId); + return data; + }); + } + else + { + logger.log("Pinging back %d from background", message.number); + returnValue = message.number; + } break; + case "QuerySongData": + return loadTab(message.url, false).then(tab => { + logger.log("Tab " + tab.url + " loaded. Querying."); + return queryTab(tab, message); + }); case "CommandLoadSong": returnValue = () => { if (message.song) @@ -101,7 +413,7 @@ var listeners = [function (message, returnValue) { } if (tabPort) { - console.log("Replacing tab"); + logger.log("Replacing tab"); /* * Closes the existing tab first */ @@ -109,7 +421,7 @@ var listeners = [function (message, returnValue) { } else { - console.log("Opening tab"); + logger.log("Opening tab"); returnValue = returnValue(); } break; @@ -129,7 +441,7 @@ var listeners = [function (message, returnValue) { returnValue = quit(); break; case "CommandError": - console.error("Error message", message); + logger.error("Error message %o", message); if (message.forward) { returnValue = queryTab(message); @@ -153,9 +465,14 @@ var listeners = [function (message, returnValue) { return returnValue; }]; +/** + * Closes all tabs in the browser, thus closing the entire browser. + * + * @return {Promise} The promise that closes the tabs. + */ function quit() { - console.log("Quitting browser"); + logger.log("Quitting browser"); return browser.tabs.query({}).then(tabs => { for (let tab of tabs) { @@ -168,6 +485,15 @@ function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } +/** + * This method is triggered when the interface sends a message to the browser. This method will + * trigger all of the relevant message listeners and return a promise for whichever value they + * decide to output. + * + * @param {object} message -The message to analyze. + * @return {Promise} A promise for the method completion, resolving to a value to return to + * the interface. + */ function handleMessage(message) { return new Promise((resolve, reject) => { @@ -178,7 +504,7 @@ function handleMessage(message) { returnValue = listeners[index](message, returnValue); } - console.debug("Message returned ", returnValue); + logger.debug("Message returned: %o", returnValue); if (returnValue instanceof Promise) { returnValue.then(resolve).catch(reject); @@ -199,7 +525,7 @@ function handleMessage(message) * Listen for messages from the app. */ interfacePort.onMessage.addListener((message) => { - console.log("Received from interface: ", message); + logger.log("Received from interface: %o", message); handleMessage(message.message).then((response) => { returnValue = { @@ -214,10 +540,10 @@ interfacePort.onMessage.addListener((message) => { } } } - console.log("Sending ", returnValue) + logger.log("Sending %o", returnValue) interfacePort.postMessage(returnValue); }).catch(error => { -// console.error("Error in evaluating message: ", error); +// logger.error("Error in evaluating message: %o", error); interfacePort.postMessage({ "messageNum": message.messageNum, "message": { @@ -226,7 +552,12 @@ interfacePort.onMessage.addListener((message) => { "confirmation": { type: "edu.regis.universeplayer.browserCommands.CommandConfirmation", message: "Error in executing request", - errorCode: JSON.parse(JSON.stringify(error, Object.getOwnPropertyNames(error))) + errorCode: { + type: "edu.regis.universeplayer.browserCommands.BrowserError", + name: error.name, + message: error.message, + stack: error.stack + } } } }); @@ -234,3 +565,5 @@ interfacePort.onMessage.addListener((message) => { }); browser.runtime.onConnect.addListener(setupTab); + +logger.log("Background script setup complete!") diff --git a/add-on/src/addon/javascript/defs/songs.js b/add-on/src/addon/javascript/defs/songs.js new file mode 100644 index 0000000..697103b --- /dev/null +++ b/add-on/src/addon/javascript/defs/songs.js @@ -0,0 +1,46 @@ +class Album +{ + name; + artists; + year; + genres; + totalTracks; + totalDiscs; +} + +class Song +{ + /** + * The name of the song. + */ + title; + /** + * Artists who contributed to the song. + */ + artists; + /** + * Which track number in the album the song belongs to. + */ + trackNum; + /** + * Which disc + */ + disc; + /** + * How long the song is in milliseconds. + */ + duration; + + /** + * A reference to the album this song is part of. + */ + album; +} + +class InternetSong extends Song +{ + /** + * The location of the song. + */ + location; +} \ No newline at end of file diff --git a/add-on/src/addon/javascript/foreground.js b/add-on/src/addon/javascript/foreground.js index 6f95dbe..e4af99d 100644 --- a/add-on/src/addon/javascript/foreground.js +++ b/add-on/src/addon/javascript/foreground.js @@ -1,6 +1,31 @@ -console.debug("Loading foreground.js") +let logger = new Logger("foreground"); +logger.debug("Loading foreground.js"); let background; +/** + * Contains functions to call before establishing a connection. + */ +let preload = []; + +logger.pushUpdate = function (message) +{ + if (background) + { + sendUpdate(message); + return true; + } + else + { + return false; + } +}; + +/** + * Handles messages sent from the background script. + * + * @param {object} message -The object containing the request. + * @return {Promise} A promise containing the result of the request. + */ function handleMessage(message) { if (typeof message == "object" && "type" in message) @@ -13,6 +38,12 @@ function handleMessage(message) } switch (type) { + case "NumberPing": + logger.log("Pinging back %d from foreground", message.number); + return Promise.resolve(message.number); + case "QuerySongData": + logger.log("Gathering song data"); + return getSongData(); case "QueryStatus": return getState(); case "QueryTime": @@ -37,6 +68,15 @@ function handleMessage(message) } } +/** + * Calling this function will forward playback data to the browser background (and by extension, the + * interface) as specified by the parameters. + * + * @param {string} status The playback status. This can be "PLAYING", "PAUSED", "STOPPED", + * "FINISHED", or "EMPTY" + * @param {number} time The current playback time in seconds. + * @param {object} songData Data on the song currently playing. + */ function onStatusUpdate(status, time, songData) { sendUpdate({ @@ -47,9 +87,15 @@ function onStatusUpdate(status, time, songData) }); } +/** + * Forwards an update to the background (and by extension, the interface). + * + * @param {object|Promise} response The data to forward. If this data is a Promise, then the data + * will be forwarded upon completion. + */ function sendUpdate(response) { - post = data => background.postMessage({ + let post = data => background.postMessage({ type: "update", data: data }); @@ -63,33 +109,128 @@ function sendUpdate(response) } } -background = browser.runtime.connect({name:"universalMusic"}); -background.onMessage.addListener(message => { - let num = message.num; - response = handleMessage(message.data); - post = data => background.postMessage({ - type: "response", - num: num, - data: data - }); - if (response instanceof Promise) +$(function () { + while (preload.length > 0) { - response.then(post); + preload.pop()(); } - else - { - post(response); - } -}); + background = browser.runtime.connect({name:"universalMusic"}); -if (document.readyState === "complete") -{ - sendUpdate("loaded"); -} -else -{ - sendUpdate("loaded"); - window.addEventListener("load", () => { - sendUpdate("loaded"); +// sendUpdate("loaded"); + + /** + * Listens for messages from the browser background and sends responses back. + */ + background.onMessage.addListener(message => { + let num = message.num; + post = data => { + logger.log("Sending to interface %o", data); + background.postMessage({ + type: "response", + num: num, + data: data + }); + } + try + { + let response = handleMessage(message.data); + if (response instanceof Promise) + { + logger.log("Posting promised information."); + response.then(post); + } + else + { + logger.log("Posting non-promise information."); + post(response); + } + } + catch (e) + { + logger.error(e); + post(e); + } }); +}) + +/** + * Obtains information on the song currently playing. + * + * @return {Promise} A serialized form of the song data for this tab. + */ +function getSongData() +{ + return Promise.reject(new ReferenceError("getSongData unimplemented for " + location.href)); +} + +/** + * Obtains the current tab's playback state. + * + * @return {Promise} The playback status. This can be "PLAYING", "PAUSED", "STOPPED", + * "FINISHED", or "EMPTY" + */ +function getState() +{ + return Promise.reject(new ReferenceError("getState unimplemented for " + location.href)); +} + +/** + * Obtains the current playback time. + * + * @return {Promise} The current song playback time in milliseconds. + */ +function getTime() +{ + return Promise.reject(new ReferenceError("getTime unimplemented for " + location.href)); +} + +/** + * Obtains the total length of the song. + * + * @return {Promise} The total song length, in milliseconds. + */ +function getLength() +{ + return Promise.reject(new ReferenceError("getLength unimplemented for " + location.href)); +} + +/** + * Enables playback of the current song. + * + * @return {Promise} The success of the command. + */ +function play() +{ + return Promise.reject(new ReferenceError("play unimplemented for " + location.href)); +} + +/** + * Pauses playback of the current song. + * + * @return {Promise} The success of the command. + */ +function pause() +{ + return Promise.reject(new ReferenceError("play unimplemented for " + location.href)); +} + +/** + * Halts playback of the current song. + * + * @return {Promise} The success of the command. + */ +function stop() +{ + return Promise.reject(new ReferenceError("stop unimplemented for " + location.href)); +} + +/** + * Sets the playback to a certain time. + * + * @param {number} time The time to skip to, in milliseconds. + * @return {Promise} The success of the command. + */ +function seek(time) +{ + return Promise.reject(new ReferenceError("seek unimplemented for " + location.href)); } \ No newline at end of file diff --git a/add-on/src/addon/javascript/jquery.js b/add-on/src/addon/javascript/jquery.js new file mode 100644 index 0000000..7556941 --- /dev/null +++ b/add-on/src/addon/javascript/jquery.js @@ -0,0 +1,2 @@ +/*! jQuery v3.6.0 -ajax,-ajax/jsonp,-ajax/load,-ajax/script,-ajax/var/location,-ajax/var/nonce,-ajax/var/rquery,-ajax/xhr,-manipulation/_evalUrl,-deprecated/ajax-event-alias,-effects,-effects/Tween,-effects/animatedSelector | (c) OpenJS Foundation and other contributors | jquery.org/license */ +!function(e,t){"use strict";"object"==typeof module&&"object"==typeof module.exports?module.exports=e.document?t(e,!0):function(e){if(!e.document)throw new Error("jQuery requires a window with a document");return t(e)}:t(e)}("undefined"!=typeof window?window:this,function(g,e){"use strict";var t=[],r=Object.getPrototypeOf,s=t.slice,v=t.flat?function(e){return t.flat.call(e)}:function(e){return t.concat.apply([],e)},u=t.push,i=t.indexOf,n={},o=n.toString,y=n.hasOwnProperty,a=y.toString,l=a.call(Object),m={},b=function(e){return"function"==typeof e&&"number"!=typeof e.nodeType&&"function"!=typeof e.item},x=function(e){return null!=e&&e===e.window},w=g.document,c={type:!0,src:!0,nonce:!0,noModule:!0};function C(e,t,n){var r,i,o=(n=n||w).createElement("script");if(o.text=e,t)for(r in c)(i=t[r]||t.getAttribute&&t.getAttribute(r))&&o.setAttribute(r,i);n.head.appendChild(o).parentNode.removeChild(o)}function T(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?n[o.call(e)]||"object":typeof e}var f="3.6.0 -ajax,-ajax/jsonp,-ajax/load,-ajax/script,-ajax/var/location,-ajax/var/nonce,-ajax/var/rquery,-ajax/xhr,-manipulation/_evalUrl,-deprecated/ajax-event-alias,-effects,-effects/Tween,-effects/animatedSelector",E=function(e,t){return new E.fn.init(e,t)};function d(e){var t=!!e&&"length"in e&&e.length,n=T(e);return!b(e)&&!x(e)&&("array"===n||0===t||"number"==typeof t&&0+~]|"+R+")"+R+"*"),U=new RegExp(R+"|>"),V=new RegExp(W),X=new RegExp("^"+B+"$"),Q={ID:new RegExp("^#("+B+")"),CLASS:new RegExp("^\\.("+B+")"),TAG:new RegExp("^("+B+"|[*])"),ATTR:new RegExp("^"+M),PSEUDO:new RegExp("^"+W),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+R+"*(even|odd|(([+-]|)(\\d*)n|)"+R+"*(?:([+-]|)"+R+"*(\\d+)|))"+R+"*\\)|)","i"),bool:new RegExp("^(?:"+I+")$","i"),needsContext:new RegExp("^"+R+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+R+"*((?:-\\d)?\\d*)"+R+"*\\)|)(?=[^-]|$)","i")},Y=/HTML$/i,G=/^(?:input|select|textarea|button)$/i,K=/^h\d$/i,J=/^[^{]+\{\s*\[native \w/,Z=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,ee=/[+~]/,te=new RegExp("\\\\[\\da-fA-F]{1,6}"+R+"?|\\\\([^\\r\\n\\f])","g"),ne=function(e,t){var n="0x"+e.slice(1)-65536;return t||(n<0?String.fromCharCode(n+65536):String.fromCharCode(n>>10|55296,1023&n|56320))},re=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g,ie=function(e,t){return t?"\0"===e?"\ufffd":e.slice(0,-1)+"\\"+e.charCodeAt(e.length-1).toString(16)+" ":"\\"+e},oe=function(){C()},ae=xe(function(e){return!0===e.disabled&&"fieldset"===e.nodeName.toLowerCase()},{dir:"parentNode",next:"legend"});try{O.apply(t=P.call(d.childNodes),d.childNodes),t[d.childNodes.length].nodeType}catch(e){O={apply:t.length?function(e,t){q.apply(e,P.call(t))}:function(e,t){var n=e.length,r=0;while(e[n++]=t[r++]);e.length=n-1}}}function se(t,e,n,r){var i,o,a,s,u,l,c,f=e&&e.ownerDocument,d=e?e.nodeType:9;if(n=n||[],"string"!=typeof t||!t||1!==d&&9!==d&&11!==d)return n;if(!r&&(C(e),e=e||T,E)){if(11!==d&&(u=Z.exec(t)))if(i=u[1]){if(9===d){if(!(a=e.getElementById(i)))return n;if(a.id===i)return n.push(a),n}else if(f&&(a=f.getElementById(i))&&y(e,a)&&a.id===i)return n.push(a),n}else{if(u[2])return O.apply(n,e.getElementsByTagName(t)),n;if((i=u[3])&&p.getElementsByClassName&&e.getElementsByClassName)return O.apply(n,e.getElementsByClassName(i)),n}if(p.qsa&&!k[t+" "]&&(!v||!v.test(t))&&(1!==d||"object"!==e.nodeName.toLowerCase())){if(c=t,f=e,1===d&&(U.test(t)||_.test(t))){(f=ee.test(t)&&ye(e.parentNode)||e)===e&&p.scope||((s=e.getAttribute("id"))?s=s.replace(re,ie):e.setAttribute("id",s=A)),o=(l=h(t)).length;while(o--)l[o]=(s?"#"+s:":scope")+" "+be(l[o]);c=l.join(",")}try{return O.apply(n,f.querySelectorAll(c)),n}catch(e){k(t,!0)}finally{s===A&&e.removeAttribute("id")}}}return g(t.replace($,"$1"),e,n,r)}function ue(){var r=[];return function e(t,n){return r.push(t+" ")>x.cacheLength&&delete e[r.shift()],e[t+" "]=n}}function le(e){return e[A]=!0,e}function ce(e){var t=T.createElement("fieldset");try{return!!e(t)}catch(e){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function fe(e,t){var n=e.split("|"),r=n.length;while(r--)x.attrHandle[n[r]]=t}function de(e,t){var n=t&&e,r=n&&1===e.nodeType&&1===t.nodeType&&e.sourceIndex-t.sourceIndex;if(r)return r;if(n)while(n=n.nextSibling)if(n===t)return-1;return e?1:-1}function pe(t){return function(e){return"input"===e.nodeName.toLowerCase()&&e.type===t}}function he(n){return function(e){var t=e.nodeName.toLowerCase();return("input"===t||"button"===t)&&e.type===n}}function ge(t){return function(e){return"form"in e?e.parentNode&&!1===e.disabled?"label"in e?"label"in e.parentNode?e.parentNode.disabled===t:e.disabled===t:e.isDisabled===t||e.isDisabled!==!t&&ae(e)===t:e.disabled===t:"label"in e&&e.disabled===t}}function ve(a){return le(function(o){return o=+o,le(function(e,t){var n,r=a([],e.length,o),i=r.length;while(i--)e[n=r[i]]&&(e[n]=!(t[n]=e[n]))})})}function ye(e){return e&&"undefined"!=typeof e.getElementsByTagName&&e}for(e in p=se.support={},i=se.isXML=function(e){var t=e&&e.namespaceURI,n=e&&(e.ownerDocument||e).documentElement;return!Y.test(t||n&&n.nodeName||"HTML")},C=se.setDocument=function(e){var t,n,r=e?e.ownerDocument||e:d;return r!=T&&9===r.nodeType&&r.documentElement&&(a=(T=r).documentElement,E=!i(T),d!=T&&(n=T.defaultView)&&n.top!==n&&(n.addEventListener?n.addEventListener("unload",oe,!1):n.attachEvent&&n.attachEvent("onunload",oe)),p.scope=ce(function(e){return a.appendChild(e).appendChild(T.createElement("div")),"undefined"!=typeof e.querySelectorAll&&!e.querySelectorAll(":scope fieldset div").length}),p.attributes=ce(function(e){return e.className="i",!e.getAttribute("className")}),p.getElementsByTagName=ce(function(e){return e.appendChild(T.createComment("")),!e.getElementsByTagName("*").length}),p.getElementsByClassName=J.test(T.getElementsByClassName),p.getById=ce(function(e){return a.appendChild(e).id=A,!T.getElementsByName||!T.getElementsByName(A).length}),p.getById?(x.filter.ID=function(e){var t=e.replace(te,ne);return function(e){return e.getAttribute("id")===t}},x.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n=t.getElementById(e);return n?[n]:[]}}):(x.filter.ID=function(e){var n=e.replace(te,ne);return function(e){var t="undefined"!=typeof e.getAttributeNode&&e.getAttributeNode("id");return t&&t.value===n}},x.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n,r,i,o=t.getElementById(e);if(o){if((n=o.getAttributeNode("id"))&&n.value===e)return[o];i=t.getElementsByName(e),r=0;while(o=i[r++])if((n=o.getAttributeNode("id"))&&n.value===e)return[o]}return[]}}),x.find.TAG=p.getElementsByTagName?function(e,t){return"undefined"!=typeof t.getElementsByTagName?t.getElementsByTagName(e):p.qsa?t.querySelectorAll(e):void 0}:function(e,t){var n,r=[],i=0,o=t.getElementsByTagName(e);if("*"===e){while(n=o[i++])1===n.nodeType&&r.push(n);return r}return o},x.find.CLASS=p.getElementsByClassName&&function(e,t){if("undefined"!=typeof t.getElementsByClassName&&E)return t.getElementsByClassName(e)},s=[],v=[],(p.qsa=J.test(T.querySelectorAll))&&(ce(function(e){var t;a.appendChild(e).innerHTML="",e.querySelectorAll("[msallowcapture^='']").length&&v.push("[*^$]="+R+"*(?:''|\"\")"),e.querySelectorAll("[selected]").length||v.push("\\["+R+"*(?:value|"+I+")"),e.querySelectorAll("[id~="+A+"-]").length||v.push("~="),(t=T.createElement("input")).setAttribute("name",""),e.appendChild(t),e.querySelectorAll("[name='']").length||v.push("\\["+R+"*name"+R+"*="+R+"*(?:''|\"\")"),e.querySelectorAll(":checked").length||v.push(":checked"),e.querySelectorAll("a#"+A+"+*").length||v.push(".#.+[+~]"),e.querySelectorAll("\\\f"),v.push("[\\r\\n\\f]")}),ce(function(e){e.innerHTML="";var t=T.createElement("input");t.setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),e.querySelectorAll("[name=d]").length&&v.push("name"+R+"*[*^$|!~]?="),2!==e.querySelectorAll(":enabled").length&&v.push(":enabled",":disabled"),a.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&v.push(":enabled",":disabled"),e.querySelectorAll("*,:x"),v.push(",.*:")})),(p.matchesSelector=J.test(c=a.matches||a.webkitMatchesSelector||a.mozMatchesSelector||a.oMatchesSelector||a.msMatchesSelector))&&ce(function(e){p.disconnectedMatch=c.call(e,"*"),c.call(e,"[s!='']:x"),s.push("!=",W)}),v=v.length&&new RegExp(v.join("|")),s=s.length&&new RegExp(s.join("|")),t=J.test(a.compareDocumentPosition),y=t||J.test(a.contains)?function(e,t){var n=9===e.nodeType?e.documentElement:e,r=t&&t.parentNode;return e===r||!(!r||1!==r.nodeType||!(n.contains?n.contains(r):e.compareDocumentPosition&&16&e.compareDocumentPosition(r)))}:function(e,t){if(t)while(t=t.parentNode)if(t===e)return!0;return!1},D=t?function(e,t){if(e===t)return l=!0,0;var n=!e.compareDocumentPosition-!t.compareDocumentPosition;return n||(1&(n=(e.ownerDocument||e)==(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!p.sortDetached&&t.compareDocumentPosition(e)===n?e==T||e.ownerDocument==d&&y(d,e)?-1:t==T||t.ownerDocument==d&&y(d,t)?1:u?H(u,e)-H(u,t):0:4&n?-1:1)}:function(e,t){if(e===t)return l=!0,0;var n,r=0,i=e.parentNode,o=t.parentNode,a=[e],s=[t];if(!i||!o)return e==T?-1:t==T?1:i?-1:o?1:u?H(u,e)-H(u,t):0;if(i===o)return de(e,t);n=e;while(n=n.parentNode)a.unshift(n);n=t;while(n=n.parentNode)s.unshift(n);while(a[r]===s[r])r++;return r?de(a[r],s[r]):a[r]==d?-1:s[r]==d?1:0}),T},se.matches=function(e,t){return se(e,null,null,t)},se.matchesSelector=function(e,t){if(C(e),p.matchesSelector&&E&&!k[t+" "]&&(!s||!s.test(t))&&(!v||!v.test(t)))try{var n=c.call(e,t);if(n||p.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(e){k(t,!0)}return 0":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(te,ne),e[3]=(e[3]||e[4]||e[5]||"").replace(te,ne),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||se.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&se.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return Q.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&V.test(n)&&(t=h(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(te,ne).toLowerCase();return"*"===e?function(){return!0}:function(e){return e.nodeName&&e.nodeName.toLowerCase()===t}},CLASS:function(e){var t=m[e+" "];return t||(t=new RegExp("(^|"+R+")"+e+"("+R+"|$)"))&&m(e,function(e){return t.test("string"==typeof e.className&&e.className||"undefined"!=typeof e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(n,r,i){return function(e){var t=se.attr(e,n);return null==t?"!="===r:!r||(t+="","="===r?t===i:"!="===r?t!==i:"^="===r?i&&0===t.indexOf(i):"*="===r?i&&-1:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function D(e,n,r){return b(n)?E.grep(e,function(e,t){return!!n.call(e,t,e)!==r}):n.nodeType?E.grep(e,function(e){return e===n!==r}):"string"!=typeof n?E.grep(e,function(e){return-1)[^>]*|#([\w-]+))$/;(E.fn.init=function(e,t,n){var r,i;if(!e)return this;if(n=n||L,"string"==typeof e){if(!(r="<"===e[0]&&">"===e[e.length-1]&&3<=e.length?[null,e,null]:j.exec(e))||!r[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(r[1]){if(t=t instanceof E?t[0]:t,E.merge(this,E.parseHTML(r[1],t&&t.nodeType?t.ownerDocument||t:w,!0)),k.test(r[1])&&E.isPlainObject(t))for(r in t)b(this[r])?this[r](t[r]):this.attr(r,t[r]);return this}return(i=w.getElementById(r[2]))&&(this[0]=i,this.length=1),this}return e.nodeType?(this[0]=e,this.length=1,this):b(e)?void 0!==n.ready?n.ready(e):e(E):E.makeArray(e,this)}).prototype=E.fn,L=E(w);var q=/^(?:parents|prev(?:Until|All))/,O={children:!0,contents:!0,next:!0,prev:!0};function P(e,t){while((e=e[t])&&1!==e.nodeType);return e}E.fn.extend({has:function(e){var t=E(e,this),n=t.length;return this.filter(function(){for(var e=0;e\x20\t\r\n\f]*)/i,pe=/^$|^module$|\/(?:java|ecma)script/i;le=w.createDocumentFragment().appendChild(w.createElement("div")),(ce=w.createElement("input")).setAttribute("type","radio"),ce.setAttribute("checked","checked"),ce.setAttribute("name","t"),le.appendChild(ce),m.checkClone=le.cloneNode(!0).cloneNode(!0).lastChild.checked,le.innerHTML="",m.noCloneChecked=!!le.cloneNode(!0).lastChild.defaultValue,le.innerHTML="",m.option=!!le.lastChild;var he={thead:[1,"","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_default:[0,"",""]};function ge(e,t){var n;return n="undefined"!=typeof e.getElementsByTagName?e.getElementsByTagName(t||"*"):"undefined"!=typeof e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&S(e,t)?E.merge([e],n):n}function ve(e,t){for(var n=0,r=e.length;n",""]);var ye=/<|&#?\w+;/;function me(e,t,n,r,i){for(var o,a,s,u,l,c,f=t.createDocumentFragment(),d=[],p=0,h=e.length;p\s*$/g;function ke(e,t){return S(e,"table")&&S(11!==t.nodeType?t:t.firstChild,"tr")&&E(e).children("tbody")[0]||e}function De(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function Le(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function je(e,t){var n,r,i,o,a,s;if(1===t.nodeType){if(Y.hasData(e)&&(s=Y.get(e).events))for(i in Y.remove(t,"handle events"),s)for(n=0,r=s[i].length;n
",2===lt.childNodes.length),E.parseHTML=function(e,t,n){return"string"!=typeof e?[]:("boolean"==typeof t&&(n=t,t=!1),t||(m.createHTMLDocument?((r=(t=w.implementation.createHTMLDocument("")).createElement("base")).href=w.location.href,t.head.appendChild(r)):t=w),o=!n&&[],(i=k.exec(e))?[t.createElement(i[1])]:(i=me([e],t,o),o&&o.length&&E(o).remove(),E.merge([],i.childNodes)));var r,i,o},E.offset={setOffset:function(e,t,n){var r,i,o,a,s,u,l=E.css(e,"position"),c=E(e),f={};"static"===l&&(e.style.position="relative"),s=c.offset(),o=E.css(e,"top"),u=E.css(e,"left"),("absolute"===l||"fixed"===l)&&-1<(o+u).indexOf("auto")?(a=(r=c.position()).top,i=r.left):(a=parseFloat(o)||0,i=parseFloat(u)||0),b(t)&&(t=t.call(e,n,E.extend({},s))),null!=t.top&&(f.top=t.top-s.top+a),null!=t.left&&(f.left=t.left-s.left+i),"using"in t?t.using.call(e,f):c.css(f)}},E.fn.extend({offset:function(t){if(arguments.length)return void 0===t?this:this.each(function(e){E.offset.setOffset(this,t,e)});var e,n,r=this[0];return r?r.getClientRects().length?(e=r.getBoundingClientRect(),n=r.ownerDocument.defaultView,{top:e.top+n.pageYOffset,left:e.left+n.pageXOffset}):{top:0,left:0}:void 0},position:function(){if(this[0]){var e,t,n,r=this[0],i={top:0,left:0};if("fixed"===E.css(r,"position"))t=r.getBoundingClientRect();else{t=this.offset(),n=r.ownerDocument,e=r.offsetParent||n.documentElement;while(e&&(e===n.body||e===n.documentElement)&&"static"===E.css(e,"position"))e=e.parentNode;e&&e!==r&&1===e.nodeType&&((i=E(e).offset()).top+=E.css(e,"borderTopWidth",!0),i.left+=E.css(e,"borderLeftWidth",!0))}return{top:t.top-i.top-E.css(r,"marginTop",!0),left:t.left-i.left-E.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var e=this.offsetParent;while(e&&"static"===E.css(e,"position"))e=e.offsetParent;return e||re})}}),E.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(t,i){var o="pageYOffset"===i;E.fn[t]=function(e){return $(this,function(e,t,n){var r;if(x(e)?r=e:9===e.nodeType&&(r=e.defaultView),void 0===n)return r?r[i]:e[t];r?r.scrollTo(o?r.pageXOffset:n,o?n:r.pageYOffset):e[t]=n},t,e,arguments.length)}}),E.each(["top","left"],function(e,n){E.cssHooks[n]=Me(m.pixelPosition,function(e,t){if(t)return t=Be(e,n),Pe.test(t)?E(e).position()[n]+"px":t})}),E.each({Height:"height",Width:"width"},function(a,s){E.each({padding:"inner"+a,content:s,"":"outer"+a},function(r,o){E.fn[o]=function(e,t){var n=arguments.length&&(r||"boolean"!=typeof e),i=r||(!0===e||!0===t?"margin":"border");return $(this,function(e,t,n){var r;return x(e)?0===o.indexOf("outer")?e["inner"+a]:e.document.documentElement["client"+a]:9===e.nodeType?(r=e.documentElement,Math.max(e.body["scroll"+a],r["scroll"+a],e.body["offset"+a],r["offset"+a],r["client"+a])):void 0===n?E.css(e,t,i):E.style(e,t,n,i)},s,n?e:void 0,n)}})}),E.fn.extend({bind:function(e,t,n){return this.on(e,null,t,n)},unbind:function(e,t){return this.off(e,null,t)},delegate:function(e,t,n,r){return this.on(t,e,n,r)},undelegate:function(e,t,n){return 1===arguments.length?this.off(e,"**"):this.off(t,e||"**",n)},hover:function(e,t){return this.mouseenter(e).mouseleave(t||e)}}),E.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(e,n){E.fn[n]=function(e,t){return 0 1 && typeof message[0] == "string") + { + /* + * Format the initial string in a way that SLF4J can understand it. + */ + message[0] = message[0].replaceAll(/%[a-z]/gi, "{}"); + } + this.queuedMessages.push(new MessageData(this.name, level, message)); + message = this.queuedMessages.pop(); + while (message && this.pushUpdate(message)) + { + message = this.queuedMessages.pop(); + } + /* + * If we popped a message but got here, then a message was not sent. Put it back. + */ + if (message) + { + this.queuedMessages.unshift(message); + } + } + + /** + * This method is used to actually push data to the interface. Abstract. + * + * @param [MessageData] message The message to push. + * @return [boolean] Whether or not the push was successful. + */ + pushUpdate(message) + { + return false; + } + + trace() + { + console.trace.apply(console, arguments); + this.passLog("trace", arguments); + } + debug() + { + console.debug.apply(console, arguments); + this.passLog("debug", arguments); + } + info() + { + console.info.apply(console, arguments); + this.passLog("info", arguments); + } + log() + { + console.log.apply(console, arguments); + this.passLog("info", arguments); + } + warn() + { + console.warn.apply(console, arguments); + this.passLog("warn", arguments); + } + error() + { + console.error.apply(console, arguments); + this.passLog("error", arguments); + } +} \ No newline at end of file diff --git a/add-on/src/addon/javascript/youtube.js b/add-on/src/addon/javascript/youtube.js index 0218fd1..a55c431 100644 --- a/add-on/src/addon/javascript/youtube.js +++ b/add-on/src/addon/javascript/youtube.js @@ -1,9 +1,26 @@ -console.debug("Loading youtube.js") +let ylogger = new Logger("youtube"); + +logger.pushUpdate = function (message) +{ + if (background) + { + sendUpdate(message); + return true; + } + else + { + return false; + } +}; + +ylogger.debug("Loading youtube.js") var video; function onload() { - video = document.getElementsByClassName('video-stream html5-main-video')[0]; + ylogger.info("YouTube loaded"); + $(".more-button").click(); + video = $('.video-stream.html5-main-video')[0]; statusUpdate = e => { return onStatusUpdate(getState(), e.srcElement.currentTime, getSongData()); }; @@ -19,16 +36,177 @@ function onload() function getSongData() { - return { - type: "edu.regis.universeplayer.browser.InternetSong", - location: "window.location.href", - title: getTitle(), - artists: getArtists(), - trackNum: 0, - discNum: 0, - duration: parseInt(getLength() * 1000), - album: null + let song = getAutogeneratedMetadata(); + if (!song) + { + song = getDetectedMetadata(); + if (!song) + { + song = getBasicMetadata(); + } } + return Promise.resolve(song); +} + +/** + * Obtains basic song metadata for if nothing else is detected. + */ +function getBasicMetadata() +{ + let data = new InternetSong(); + data.location = window.location.href; + data.title = getTitle(); + data.artists = getArtists(); + data.duration = getLength(); + + data.album = new Album(); + data.album.art = getArt(); + + return Promise.resolve(data); +} + +/** + * Obtains song metadata detected by YouTube. + * + * @param {string} type The type of metadata to get. Can be "Song", "Artist", "Album", + * "Writers", or "Licensed to YouTube by". May be null. + * @return {jQuery|InternetSong} If a type is provided, this returns a jQuery array of string + * values matching that type. Otherwise, it returns an InternetSong + * created from detected metadata. + */ +function getDetectedMetadata(type) +{ + if (type) + { + return $("ytd-metadata-row-container-renderer.ytd-video-secondary-info-renderer ytd-metadata-row-renderer:contains('" + type + "') div>yt-formatted-string").map(function () {return $(this).text()}); + } + else + { + let song = new InternetSong(); + + song.title = Array.prototype.join.apply(getDetectedMetadata("Song"), [" / "]); + if (!song.title) + { + return undefined; + } + song.artists = ";".join(getDetectedMetadata("Artist")); + song.album.name = getDetectedMetadata("Album")[0]; + song.album.artists = ";".join(getDetectedMetadata("Artist")); + song.location = location.href; + song.duration = getLength(); + song.album.art = getArt(); + + return song; + } +} + +/** + * Obtains song metadata detected by YouTube as provided in the description. + * + * @return {InternetSong} The internet song data detected. This will be undefined if no + * autogenerated data was found. + */ +function getAutogeneratedMetadata() +{ + let desc = $("yt-formatted-string.ytd-video-secondary-info-renderer").text().replaceAll(/\n\w*\n/g, "\n").split("\n"); + if (!desc || !desc.length || !desc[desc.length - 1].startsWith("Auto-generated")) + { + return undefined; + } + let data = new InternetSong(); + desc.forEach(str => { + let provHead = "Provided to YouTube by "; + let names; + if (str.startsWith(provHead)) + { + data.provider = str.substring(provHead.length); + } + else if (str.indexOf(" · ") > -1) + { + names = str.split(" · "); + data.title = names[0]; + data.artists = names.slice(1); + if (!data.album.artists) + { + data.album.artists = data.artists; + } + } + else if (str.startsWith('℗')) + { + data.album.artists = [str.substr(str.match(/^℗( [\d]{4})? /)[0].length)] + names = str.match(/\d{4}/); + if (!data.album.year && names) + { + data.album.year = names[0]; + } + } + else if (str.startsWith('Released on: ')) + { + names = str.match(/[\d]{4}/); + if (names) + { + data.album.year = names[0]; + } + } + else if (data.title && !data.album.name && str) + { + ylogger.debug("Album name: %s", str); + data.album.name = str; + } + else + { + ylogger.debug("Ignoring line %s", str); + } + }); + data.location = location.href; + data.duration = getLength(); + data.album.art = getArt(); + return data; +} + +/** + * Obtains the song title. + * + * @return {string} The song title, or null if one could not be found. + */ +function getTitle() +{ + return $(".title.ytd-video-primary-info-renderer").text() +} + +/** + * Obtains the artists who made this song. + * + * @return {string[]} The song artists, or an empty array if none could be found. + */ +function getArtists() +{ + return [$("#channel-name.ytd-video-owner-renderer a:first").text()]; +} + +/** + * Obtains the YouTube ID for this song + * + * @return {string} + */ +function getSongId() +{ + let match = location.search.match(/v=.+(\&|$)/); + if (match) + { + return match[0].substring(2) + } + return ""; +} + +/** + * Obtains the album art + * + * @return {blob|string} Either a blob for the album art or a URL for where it can be downloaded. + */ +function getArt() +{ + return "https://i.ytimg.com/vi/" + getSongId() + "/maxresdefault.jpg" } function getState() @@ -53,22 +231,12 @@ function getState() function getTime() { - return video.currentTime; + return parseInt(video.currentTime * 1000); } function getLength() { - return video.duration; -} - -function getTitle() -{ - return document.getElementsByTagName("meta").title.content; -} - -function getArtists() -{ - return [document.getElementById("channel-name").getElementsByTagName("a")[0].text]; + return parseInt(video.duration * 1000); } function play() @@ -101,11 +269,4 @@ function seek(time) return false; } -if (document.readyState === "complete") -{ - onload(); -} -else -{ - window.addEventListener("load", onload); -} \ No newline at end of file +preload.push(onload); \ No newline at end of file diff --git a/add-on/src/addon/resources/manifest.json b/add-on/src/addon/resources/manifest.json index 8abb092..f9f6646 100644 --- a/add-on/src/addon/resources/manifest.json +++ b/add-on/src/addon/resources/manifest.json @@ -19,13 +19,13 @@ }, "background": { - "scripts": ["background.js"] + "scripts": ["logger.js", "defs/songs.js", "background.js"] }, "content_scripts": [ { "matches": ["*://*.youtube.com/watch?v=*"], - "js": ["foreground.js", "youtube.js"] + "js": ["jquery.js", "logger.js", "defs/songs.js", "foreground.js", "youtube.js"] } ], @@ -33,5 +33,5 @@ "default_icon": "icon.svg" }, - "permissions": ["nativeMessaging"] + "permissions": ["nativeMessaging", "tabs"] } \ No newline at end of file diff --git a/addonInter/build.gradle b/addonInter/build.gradle index e63b5aa..5ed482c 100644 --- a/addonInter/build.gradle +++ b/addonInter/build.gradle @@ -62,4 +62,10 @@ artifacts { builtBy installDist } install(distTar) -} \ No newline at end of file +} + +processResources { + filesMatching("**/log4j2.xml") { + expand(rootProject.properties) + } +} diff --git a/addonInter/src/main/java/edu/regis/universeplayer/addon/SocketAppender.java b/addonInter/src/main/java/edu/regis/universeplayer/QueueAppender.java similarity index 79% rename from addonInter/src/main/java/edu/regis/universeplayer/addon/SocketAppender.java rename to addonInter/src/main/java/edu/regis/universeplayer/QueueAppender.java index 2fd4442..5a25ab2 100644 --- a/addonInter/src/main/java/edu/regis/universeplayer/addon/SocketAppender.java +++ b/addonInter/src/main/java/edu/regis/universeplayer/QueueAppender.java @@ -1,4 +1,4 @@ -package edu.regis.universeplayer.addon; +package edu.regis.universeplayer; import org.apache.logging.log4j.core.*; import org.apache.logging.log4j.core.appender.AbstractAppender; @@ -9,7 +9,6 @@ import org.apache.logging.log4j.core.config.plugins.PluginBuilderFactory; import java.io.Serializable; import java.util.LinkedList; import java.util.List; -import java.util.stream.Collectors; /** * This appender will store all messages sent to it, for the interface link to @@ -18,25 +17,25 @@ import java.util.stream.Collectors; * @author William Hubbard */ @Plugin( - name = "Socket", + name = "Queue", category = Core.CATEGORY_NAME, elementType = Appender.ELEMENT_TYPE) -public class SocketAppender extends AbstractAppender +public class QueueAppender extends AbstractAppender { private static final LinkedList events = new LinkedList<>(); /** - * Builds FileAppender instances. + * Builds QueueAppender instances. * * @param The type to build */ public static class Builder> extends AbstractAppender.Builder - implements org.apache.logging.log4j.core.util.Builder + implements org.apache.logging.log4j.core.util.Builder { @Override - public SocketAppender build() + public QueueAppender build() { - return new SocketAppender(getName(), getFilter(), getOrCreateLayout(), isIgnoreExceptions(), getPropertyArray()); + return new QueueAppender(getName(), getFilter(), getOrCreateLayout(), isIgnoreExceptions(), getPropertyArray()); } } @@ -46,8 +45,8 @@ public class SocketAppender extends AbstractAppender return new Builder().asBuilder(); } - public SocketAppender(final String name, final Filter filter, final Layout layout, - final boolean ignoreExceptions, final Property[] properties) + public QueueAppender(final String name, final Filter filter, final Layout layout, + final boolean ignoreExceptions, final Property[] properties) { super(name, filter, layout, ignoreExceptions, properties); } diff --git a/addonInter/src/main/java/edu/regis/universeplayer/addon/Main.java b/addonInter/src/main/java/edu/regis/universeplayer/addon/Main.java index 5bac7df..19980d5 100644 --- a/addonInter/src/main/java/edu/regis/universeplayer/addon/Main.java +++ b/addonInter/src/main/java/edu/regis/universeplayer/addon/Main.java @@ -4,6 +4,7 @@ package edu.regis.universeplayer.addon; +import edu.regis.universeplayer.QueueAppender; import edu.regis.universeplayer.browserCommands.BrowserConstants; import edu.regis.universeplayer.browserCommands.MessageHandler; import org.apache.logging.log4j.core.LogEvent; @@ -57,10 +58,10 @@ public class Main /* * Make sure that it is active. */ - if (SocketAppender.hasLogs()) + if (QueueAppender.hasLogs()) { lastPing = System.currentTimeMillis(); - for (LogEvent event : SocketAppender.retrieveLogEvents()) + for (LogEvent event : QueueAppender.retrieveLogEvents()) { this.sendUpdate(event); } diff --git a/addonInter/src/main/resources/log4j2.xml b/addonInter/src/main/resources/log4j2.xml index 30479a2..8cd2bf6 100644 --- a/addonInter/src/main/resources/log4j2.xml +++ b/addonInter/src/main/resources/log4j2.xml @@ -2,16 +2,29 @@ - + - + - %d{HH:mm:ss.SSS} - %c.%M(%F:%L) - %-5level - %msg%n + %d{HH:mm:ss.SSS} - %c.%M(%F:%L) - %-5level - %msg%n + - + + + + + + + + + + + diff --git a/browser/src/main/java/edu/regis/universeplayer/browser/Browser.java b/browser/src/main/java/edu/regis/universeplayer/browser/Browser.java index 5593dfe..35e76e1 100644 --- a/browser/src/main/java/edu/regis/universeplayer/browser/Browser.java +++ b/browser/src/main/java/edu/regis/universeplayer/browser/Browser.java @@ -4,6 +4,9 @@ package edu.regis.universeplayer.browser; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.core.DefaultLoggerContextAccessor; +import org.apache.logging.log4j.core.LogEvent; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -11,20 +14,27 @@ import java.io.File; import java.io.FileWriter; import java.io.IOException; import java.io.PrintStream; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; import java.net.ConnectException; import java.net.InetAddress; import java.net.ServerSocket; import java.net.Socket; +import java.util.Arrays; import java.util.Scanner; import java.util.concurrent.atomic.AtomicBoolean; import edu.regis.universeplayer.ConfigManager; +import edu.regis.universeplayer.Log; import edu.regis.universeplayer.browserCommands.BrowserConstants; import edu.regis.universeplayer.browserCommands.MessageRunner; +import edu.regis.universeplayer.browserCommands.UpdateListener; public class Browser extends MessageRunner { private static final Logger logger = LoggerFactory.getLogger(Browser.class); + private static final Logger browserLogger = + LoggerFactory.getLogger("browser"); private static Browser INSTANCE; private static final AtomicBoolean instanceWaiter = new AtomicBoolean(); @@ -116,6 +126,72 @@ public class Browser extends MessageRunner this.socket = socket; this.server = server; this.process = process; + + /* + * Sends browser logs to the main log. + */ + this.addUpdateListener((object, runner) -> + { + Log log; + LogEvent logEvent; + if (object instanceof Log) + { + log = (Log) object; + String methodName; + Class[] types = null; + Object[] params = null; + if (log.message.length == 1) + { + types = new Class[]{String.class}; + params = new Object[]{log.message[0]}; + } + else if (log.message.length == 2) + { + types = new Class[]{String.class, Object.class}; + params = new Object[]{log.message[0], log.message[1]}; + } + else if (log.message.length == 3) + { + types = new Class[]{String.class, Object.class, + Object[].class}; + params = new Object[]{log.message[0], log.message[1], + log.message[1]}; + } + else if (log.message.length > 4) + { + types = new Class[]{String.class, Object[].class}; + params = new Object[]{log.message[0], + Arrays.copyOfRange(log.message, 1, + log.message.length)}; + } + methodName = log.level.toLowerCase(); + if (types != null) + { + Method method = null; + try + { + method = Logger.class.getMethod(methodName, types); + method.invoke(LoggerFactory.getLogger(log.logger), params); + } + catch (NoSuchMethodException e) + { + /* + * This can happen if a zero-argument message was + * received. This is expected. + */ + } + catch (IllegalAccessException | InvocationTargetException e) + { + logger.error("Could not process logger message {}", log, + e); + } + } + } + else if (object instanceof LogEvent) + { + DefaultLoggerContextAccessor.INSTANCE.getLoggerContext().getRootLogger().get().log((LogEvent) object); + } + }); } @Override diff --git a/browserCommands/src/main/java/edu/regis/universeplayer/Log.java b/browserCommands/src/main/java/edu/regis/universeplayer/Log.java new file mode 100644 index 0000000..548cbbf --- /dev/null +++ b/browserCommands/src/main/java/edu/regis/universeplayer/Log.java @@ -0,0 +1,18 @@ +package edu.regis.universeplayer; + +import java.io.Serializable; +import java.util.Arrays; + +public class Log implements Serializable +{ + public String logger; + public String level; + public Object[] message; + + @Override + public String toString() + { + return "LOG " + this.level.toUpperCase() + ": " + + Arrays.toString(message); + } +} diff --git a/browserCommands/src/main/java/edu/regis/universeplayer/NumberPing.java b/browserCommands/src/main/java/edu/regis/universeplayer/NumberPing.java index a089f95..ba3eb63 100644 --- a/browserCommands/src/main/java/edu/regis/universeplayer/NumberPing.java +++ b/browserCommands/src/main/java/edu/regis/universeplayer/NumberPing.java @@ -1,21 +1,30 @@ package edu.regis.universeplayer; import java.io.Serializable; +import java.net.URL; /** * A test class for pinging the browser. */ public class NumberPing implements Serializable { - public int number; + private final URL url; + public double number; public NumberPing() { this(0); } - public NumberPing(int number) + public NumberPing(double number) { + this.url = null; + this.number = number; + } + + public NumberPing(URL url, double number) + { + this.url = url; this.number = number; } } diff --git a/build.gradle b/build.gradle index c1c5f26..d233f82 100644 --- a/build.gradle +++ b/build.gradle @@ -1,9 +1,20 @@ +import net.harawata.appdirs.AppDirsFactory + import java.util.stream.Collectors /* * Copyright (c) 2021 William Hubbard. All Rights Reserved. */ +buildscript { + repositories { + mavenCentral() + } + dependencies { + classpath 'net.harawata:appdirs:1.2.1' + } +} + /* * This build file was generated by the Gradle 'init' task. * @@ -63,4 +74,6 @@ task buildscriptNix(type: Copy) { file("${buildDir}/install.sh").append "\n__APPDATA__\n".bytes file("${buildDir}/install.sh").append mergeApps.archiveFile.get().asFile.bytes } -} \ No newline at end of file +} + +ext.LOG_DIR = AppDirsFactory.getInstance().getUserLogDir("universemusic", "0.1", "edu.regis"); diff --git a/interface/build.gradle b/interface/build.gradle index 2768582..9fa8354 100644 --- a/interface/build.gradle +++ b/interface/build.gradle @@ -56,4 +56,10 @@ mainClassName = defaultPackage + '.PlayerEnvironment' artifacts { install(distTar) -} \ No newline at end of file +} + +processResources { + filesMatching("**/log4j2.xml") { + expand(rootProject.properties) + } +} diff --git a/interface/src/main/java/edu/regis/universeplayer/PlayerEnvironment.java b/interface/src/main/java/edu/regis/universeplayer/PlayerEnvironment.java index f99eed3..279587e 100644 --- a/interface/src/main/java/edu/regis/universeplayer/PlayerEnvironment.java +++ b/interface/src/main/java/edu/regis/universeplayer/PlayerEnvironment.java @@ -131,8 +131,8 @@ public class PlayerEnvironment } } - private static void init(HashMap ops, - ArrayList params) + public static void init(Map ops, + List params) { /* * Add this just in case of a crash or something. It won't work if the @@ -154,25 +154,28 @@ public class PlayerEnvironment queue.addSongChangeListener(queue1 -> ForkJoinPool.commonPool().submit(() -> { ForkJoinTask command = PlayerManager.getPlayers() .stopSong(); - command.join(); - if (command.isCompletedAbnormally()) + if (command != null) { - logger.error("Could not run command", - command.getException()); - if (Interface.getInstance() != null) + command.join(); + if (command.isCompletedAbnormally()) { - JOptionPane - .showMessageDialog(Interface.getInstance(), - command.getException(), - command.getException().getMessage(), - JOptionPane.ERROR_MESSAGE); + logger.error("Could not run command", + command.getException()); + if (Interface.getInstance() != null) + { + JOptionPane + .showMessageDialog(Interface.getInstance(), + command.getException(), + command.getException().getMessage(), + JOptionPane.ERROR_MESSAGE); + } } } - else if (queue1.getCurrentSong() != null) + if (queue1.getCurrentSong() != null) { command = PlayerManager.getPlayers() - .playSong(queue1.getCurrentSong()); + .playSong(queue1.getCurrentSong()); command.join(); if (command.isCompletedAbnormally()) { @@ -249,7 +252,6 @@ public class PlayerEnvironment else { equals = args[i].length(); - valStr = null; } key = args[i].substring(2, equals); } @@ -478,7 +480,7 @@ public class PlayerEnvironment .map(Map.Entry::getKey) .collect(Collectors.toList()); Collections.reverse(songs); - out.println("Playing " + songs.toString()); + out.println("Playing " + songs); Queue.getInstance().addAll(songs); } }