Last active
November 7, 2022 18:04
-
-
Save EricSeastrand/c2320e6673e9137a581b66314807da46 to your computer and use it in GitHub Desktop.
Browser side of my alexa skill
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==UserScript== | |
// @name Computer Control Alexa Skill | |
// @namespace http://ericseastrand.com/ | |
// @version 0.6 | |
// @description Connects to a websocket server to listen for instructions like pausing media, searching youtube, etc.. | |
// @author Eric Seastrand | |
// @match https://www.youtube.com/* | |
// @grant none | |
//@downloadURL https://gist.githubusercontent.com/willcodeforfood/c2320e6673e9137a581b66314807da46/raw/computer-control.user.js | |
// ==/UserScript== | |
(function() { | |
'use strict'; | |
console.log('Computer Control Alexa Skill loaded!') | |
labelApplierInit() | |
window.setInterval(keepAliveSocket, 1000); | |
})(); | |
var socketListener; | |
function keepAliveSocket() { | |
if (socketListener === undefined || (socketListener && socketListener.readyState === 3)) { | |
console.log("Socket Keepalive is attempting to restart connection.") | |
socketListener = initSocketConnection() | |
} | |
if(socketListener.readyState === 1) { | |
document.body.classList.add('alexa-computer-control__connection-live') | |
document.body.classList.remove('alexa-computer-control__connection-error') | |
} else { | |
document.body.classList.add('alexa-computer-control__connection-error') | |
document.body.classList.remove('alexa-computer-control__connection-live') | |
} | |
} | |
function initSocketConnection() { | |
var socketListener = new WebSocket("wss://ccs.defplayswow.com") | |
console.log('Alexa Skill connected to server.', socketListener) | |
socketListener.onmessage = function (event) { | |
console.log('Alexa said something:', event) | |
if(document.hidden) { | |
window.setTimeout(function(){ | |
sendBackHandlerResponse({'say': 'Request ignored because tab is not active.', 'priority': 'low'}) | |
}, 500)// Delay is hacky way of making sure a [possibly] active tab sends back a message first. Ours is just for troubleshooting. | |
return | |
} | |
try { | |
const handlerResponse = handleWebsocketMessage(event.data, socketListener) | |
sendBackHandlerResponse(handlerResponse) | |
} catch(e){ | |
console.warn(e) | |
sendBackHandlerResponse("Request was received but computer errored out when handling it.") | |
} | |
} | |
function sendBackHandlerResponse(handlerResponse) { | |
const responseToSend = handlerResponseToSocketMessage(handlerResponse) | |
socketListener.send(JSON.stringify(responseToSend)) | |
} | |
return socketListener | |
} | |
function handleWebsocketMessage(_message) { | |
let message | |
try { | |
message = JSON.parse(_message) | |
}catch(e) { | |
console.warn("Could not parse websocket message as JSON") | |
return; | |
} | |
const _intent = message.path | |
/* | |
if(!_intent.includes('Intent')) { | |
console.warn('Received message with no obvious intent', message) | |
return | |
} | |
*/ | |
const intent = (_intent | |
.replace(/^\/+/, '') // Trim leading slash. | |
.replace(/Intent$/, '') // Remove 'Intent' from end of string like PauseIntent | |
) | |
console.log('Intent seems to be', intent) | |
let handlerToUse = intentHandlers[intent]; | |
if(!handlerToUse) { | |
handlerToUse = intentHandlers.__default | |
} | |
console.log('Using intent handler:', handlerToUse) | |
return handlerToUse(message) | |
} | |
function handlerResponseToSocketMessage(handlerResponse) { | |
let responseToSend; | |
if(!handlerResponse) { | |
return {'say': 'Request completed without confirmation.'} | |
} | |
if(typeof handlerResponse === 'string') { | |
return { 'say': handlerResponse } | |
} | |
if(handlerResponse === true) { | |
return { 'say': 'Request Completed Successfully.' } | |
} | |
return handlerResponse | |
} | |
function clickButton(querySelector) { | |
var button = [...document.querySelectorAll(querySelector)].filter(e => !!e.offsetParent)[0] // visible elements only. | |
if(!button) { | |
return "Error. Could not find a button to simulate a click." | |
} | |
button.click(); | |
return "Done" | |
} | |
const intentHandlers = {} | |
intentHandlers.__default = function(message) { | |
console.log("Received intent that wasn't mapped. Message:", message) | |
} | |
intentHandlers.Pause = function(message) { | |
return clickButton('button.ytp-play-button') | |
} | |
intentHandlers.YouTubeSearch = function(message) { | |
var searchTerm = message.data.slots.thing.value | |
var url = "https://www.youtube.com/results?search_query=" + encodeURIComponent(searchTerm) | |
window.location.href = url | |
} | |
intentHandlers.GoHome = function(message) { | |
window.setTimeout(function(){ | |
window.location.href = '/' | |
}, 100); | |
return "Going Home" | |
} | |
function waitFor(t, e) { | |
if ("function" == typeof t) var r = t; | |
else var r = function() { | |
return !!window[t] | |
}; | |
if (r()) return e(); | |
var n = setInterval((function() { | |
r() && (clearInterval(n), e()) | |
}), 10) | |
} | |
intentHandlers.GoToYoutubeChannel = function(message) { | |
var searchTerm = message.data.slots.channelName.value | |
var searchInput = document.querySelector('input#search') | |
searchInput.value = searchTerm | |
var searchForm = searchInput.closest('form') | |
searchForm.submit() | |
var link; | |
function linkReady(){ | |
try { | |
link = document.querySelector('ytd-channel-renderer').querySelector('a.channel-link') | |
return !!link | |
} catch(e){} | |
} | |
waitFor(linkReady, | |
function clickLink() { link.click() } | |
) | |
} | |
intentHandlers.Fullscreen = function(message) { | |
document.querySelector('video.html5-main-video').requestFullscreen() | |
return "Fullscreen Requested" | |
} | |
intentHandlers.TheaterMode = function(message) { | |
return clickButton('button[title="Theater mode (t)"], button[title="Default view (t)"]') | |
return 'Done' | |
} | |
intentHandlers.GoBack = function(message) { | |
window.history.back() | |
return 'Done' | |
} | |
intentHandlers.GoForward = function(message) { | |
window.history.forward() | |
return 'Done' | |
} | |
intentHandlers.StartScrolling = function(message) { | |
autoScroller.start(); | |
return 'Scrolling' | |
} | |
intentHandlers.StopScrolling = function(message) { | |
autoScroller.stop(); | |
return 'Stopped' | |
} | |
intentHandlers.SelectItem = function(message) { | |
autoScroller.stop(); | |
const itemNumber = parseInt(message.data.slots.itemNumber.value) | |
const videoToChoose = document.querySelector(`[data-alexa-computer-control__item-number="${itemNumber}"]`) | |
if(!videoToChoose) { | |
const allItems = document.querySelectorAll(`[data-alexa-computer-control__item-number]`) | |
return {say:`Could not find a video number ${itemNumber} on the page. We found ${allVideos.length} possible items.`} | |
} | |
console.log("Playing video number", itemNumber, videoToChoose) | |
if(!!videoToChoose.href) { | |
var playLink = videoToChoose | |
} else { | |
var playLink = videoToChoose.querySelector('#thumbnail, #video-title, #main-link, a.ytp-ce-covering-overlay') | |
} | |
playLink.click() | |
return {say: `Clicked on video number ${itemNumber}`} | |
} | |
window.autoScroller = (function () { | |
var minimumPixelNudge = 1 // Only actually do a scroll if it'll move more than this number of pixels. | |
var scrollingShouldStop = false | |
var animationFrameRequest; | |
function startScrolling(scrollSpeedPixelsPerSecond = 100) { | |
stopScrolling() //Clear old stuff out. | |
scrollingShouldStop = false | |
var start, previousTimeStamp | |
var startingScrollY = window.scrollY | |
function step(timestamp) { | |
if (start === undefined) | |
start = timestamp; | |
const elapsed = timestamp - start; | |
const timeSincePrevious = timestamp - previousTimeStamp | |
if (previousTimeStamp !== timestamp) { | |
const totalDistanceScrolled = scrollSpeedPixelsPerSecond / 1000 * elapsed; | |
const newScrollY = Math.round(startingScrollY + totalDistanceScrolled) | |
const distanceToScroll = Math.abs(newScrollY - window.scrollY) | |
if(distanceToScroll > minimumPixelNudge) { | |
//console.log("Nudge is", distanceToScroll) | |
window.scroll(0, newScrollY) | |
} else { | |
//console.log(`Change of ${distanceToScroll}px did not meet the ${minimumPixelNudge} threshold.`) | |
} | |
} | |
if (!scrollingShouldStop) { | |
previousTimeStamp = timestamp | |
animationFrameRequest = window.requestAnimationFrame(step); | |
} | |
} | |
animationFrameRequest = window.requestAnimationFrame(step); | |
} | |
function stopScrolling() { | |
scrollingShouldStop = true | |
window.cancelAnimationFrame(animationFrameRequest); | |
} | |
window.addEventListener('popstate', stopScrolling); | |
return { | |
start: startScrolling, | |
stop: stopScrolling | |
} | |
})() | |
function labelApplierInit() { | |
function allocateItemNumber() { | |
return ++allocateItemNumber.currentIndex | |
} | |
allocateItemNumber.currentIndex = 0; | |
function buildLabelElement(itemNumber) { | |
var wrapper = document.createElement('div') | |
wrapper.setAttribute('class', 'alexa-computer-control__item-number__badge') | |
wrapper.setAttribute('style', `--badge-content:"${itemNumber}"`) | |
//wrapper.textContent = itemNumber // Text comes from CSS | |
return wrapper | |
} | |
function applyLabel(container) { | |
// ToDo: Check if label already applied | |
const itemNumber = allocateItemNumber() | |
const labelElement = buildLabelElement(itemNumber) | |
container.appendChild(labelElement) | |
container.setAttribute('data-alexa-computer-control__item-number', itemNumber) | |
} | |
function applyLabels(){ | |
const selectorsToLabel = [ | |
//ytd-rich-grid-media // Removed this because it was causing duplicate labels. | |
'ytd-thumbnail', | |
'.ytp-ce-video', | |
'.ytp-videowall-still', | |
'ytd-channel-renderer' | |
] | |
var selectorString = selectorsToLabel.join(',') | |
const _containers = [...document.querySelectorAll(selectorString)] | |
const containers = _containers.filter(c => !c.hasAttribute('data-alexa-computer-control__item-number')) // Exclude anything already labeled. | |
//console.log(containers) | |
containers.forEach(applyLabel) | |
} | |
var styles = ` | |
.alexa-computer-control__connection-error .alexa-computer-control__item-number__badge:after { | |
background: linear-gradient(#C90D0D 0%, #A70A0A 100%); | |
} | |
.alexa-computer-control__item-number__badge:after { | |
content: var(--badge-content); | |
z-index: 1; | |
overflow: hidden; | |
font-size: 5em; | |
font-weight: bold; | |
color: #FFF; | |
text-transform: uppercase; | |
text-align: center; | |
padding: .1em; | |
display: block; | |
background: linear-gradient(#9BC90D 0%, #79A70A 100%); | |
box-shadow: 0 3px 10px -5px rgb(0 0 0); | |
position: absolute; | |
top: 0em; | |
left: 0; | |
} | |
.ytd-watch-card-compact-video-renderer .alexa-computer-control__item-number__badge { | |
font-size: 0.6em; | |
} | |
.ytp-ce-video .alexa-computer-control__item-number__badge:after { | |
bottom: 0; | |
top: auto; | |
line-height: 0.8em; | |
} | |
ytd-channel-renderer {position: relative} | |
` | |
styleElement = document.querySelector('style#alexa-computer-control__item-number') || document.createElement('style') | |
styleElement.setAttribute('id', 'alexa-computer-control__item-number') | |
styleElement.innerHTML = styles | |
document.head.appendChild(styleElement) | |
applyLabels() | |
window.setInterval(applyLabels, 1000) // New elements may load at any time... | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment