Skip to content

Instantly share code, notes, and snippets.

@EricSeastrand
Last active November 7, 2022 18:04
Show Gist options
  • Save EricSeastrand/c2320e6673e9137a581b66314807da46 to your computer and use it in GitHub Desktop.
Save EricSeastrand/c2320e6673e9137a581b66314807da46 to your computer and use it in GitHub Desktop.
Browser side of my alexa skill
// ==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