Last active
September 9, 2023 16:34
-
-
Save dbarjs/9834c4505cd217ebd230f1ebaa9a68d7 to your computer and use it in GitHub Desktop.
Framerize.js
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
(function () { | |
'use strict'; | |
/** | |
* @typedef {'warn' | 'info' | 'log' | 'error'} LoggerType | |
*/ | |
/** | |
* @typedef {Object} UseSoundAlertOptions | |
* @property {number} timeBetweenPlays | |
* @property {string} defaultSoundId | |
* @property {boolean} isEnabled | |
*/ | |
/** | |
* @typedef {'loading' | 'success' | 'error'} FrameStatus | |
*/ | |
/** | |
* @typedef {Object} FrameOptions | |
* @property {number} autoReloadDelay | |
* @property {number} timeoutDelay | |
* @property {number} reloadTimeAfterTimeout | |
*/ | |
/** | |
* @typedef {Object} FrameLogObject | |
* @property {number} key | |
* @property {string} url | |
* @property {FrameStatus} status | |
* @property {boolean} isMounted | |
*/ | |
/** | |
* Integration with the browser's `console` | |
* @param {LoggerType} type | |
* @param {...any} args | |
*/ | |
function logger(type, ...args) { | |
const prefix = '[framerize.js]'; | |
// eslint-disable-next-line no-console | |
const log = console[type] || console.log; | |
if (typeof log !== 'function') { | |
return; | |
} | |
log(prefix, ...args); | |
} | |
/** | |
* @param {UseSoundAlertOptions} options | |
*/ | |
function useSoundAlert(options = {}) { | |
let isEnabled = !!options.isEnabled; | |
/** | |
* @link https://notificationsounds.com/alarm-sounds-alerts-ringtones | |
*/ | |
const avaiableSounds = { | |
direct: | |
'https://firebasestorage.googleapis.com/v0/b/flux-jobboard.appspot.com/o/direct-545.ogg?alt=media&token=78d73b30-c4ba-4b8c-aeb7-c65413e47ed8', | |
'gentle-alarm': | |
'https://firebasestorage.googleapis.com/v0/b/flux-jobboard.appspot.com/o/gentle-alarm-474.ogg?alt=media&token=eba4a701-d5ee-42b6-817e-0e73e2e755c6', | |
'little-bell': | |
'https://firebasestorage.googleapis.com/v0/b/flux-jobboard.appspot.com/o/little-bell-430.ogg?alt=media&token=43a4036b-7faa-41a2-a62d-f19aeefd7810', | |
'piece-of-cake': | |
'https://firebasestorage.googleapis.com/v0/b/flux-jobboard.appspot.com/o/piece-of-cake-611.ogg?alt=media&token=26a6b67d-5729-4d85-8ee8-dce48557bbce', | |
whistling: | |
'https://firebasestorage.googleapis.com/v0/b/flux-jobboard.appspot.com/o/whistling-ringtone.ogg?alt=media&token=c72b9165-82b1-4332-ac88-5ee94cced9b8', | |
}; | |
const defaultSoundId = options.defaultSoundId || 'gentle-alarm'; | |
const playlist = { | |
queue: [], | |
timeBetweenPlays: options.timeBetweenPlays || 0, | |
debounceTimer: null, | |
isPlaying: false, | |
}; | |
const isDebounceEnabled = !!playlist.timeBetweenPlays; | |
const playNext = () => { | |
if (!playlist.queue.length) { | |
return; | |
} | |
if (playlist.debounceTimer) { | |
return; | |
} | |
const nextSoundId = playlist.queue.shift(); | |
if (isDebounceEnabled) { | |
playlist.debounceTimer = setTimeout(() => { | |
play(nextSoundId); | |
}, playlist.timeBetweenPlays); | |
} else { | |
play(nextSoundId); | |
} | |
}; | |
const addToPlaylist = (soundId) => { | |
playlist.queue.push(soundId); | |
}; | |
/** | |
* @param {string} soundId | |
* @param {boolean} ignoreDebounce | |
*/ | |
const play = async (soundId = defaultSoundId) => { | |
if (!isEnabled) { | |
return; | |
} | |
const soundUrl = avaiableSounds[soundId]; | |
if (!soundUrl) { | |
logger('warn', 'sound not found', soundId); | |
return; | |
} | |
if (playlist.isPlaying) { | |
return addToPlaylist(soundId); | |
} | |
playlist.isPlaying = true; | |
try { | |
const audio = new Audio(soundUrl); | |
await audio.play(); | |
} catch (error) { | |
logger('error', 'error playing sound', soundId, error); | |
} | |
playlist.isPlaying = false; | |
playNext(); | |
}; | |
const enable = () => { | |
isEnabled = true; | |
}; | |
const disable = () => { | |
isEnabled = false; | |
}; | |
/** | |
* @returns {HTMLElement} element | |
*/ | |
const createToggleButton = () => { | |
const element = document.createElement('button'); | |
element.innerText = isEnabled ? 'mute sounds' : 'play sounds'; | |
element.addEventListener('click', () => { | |
if (isEnabled) { | |
disable(); | |
element.innerText = 'play sounds'; | |
} else { | |
enable(); | |
element.innerText = 'mute sounds'; | |
play('direct'); | |
} | |
}); | |
return element; | |
}; | |
return { | |
play, | |
enable, | |
disable, | |
createToggleButton, | |
}; | |
} | |
/** | |
* Integration with the browser's Notification API | |
* | |
* @param {NotificationOptions} options | |
*/ | |
function useNotification(options) { | |
/** | |
* @type {NotificationOptions} defaultOptions | |
*/ | |
const defaultOptions = { | |
// vibrate: [200, 100, 200], | |
...options, | |
}; | |
let hasPermission = false; | |
/** | |
* @param {boolean | undefined} forceRequest | |
*/ | |
const checkPermission = async (forceRequest) => { | |
if (hasPermission) { | |
return; | |
} | |
if (!('Notification' in window)) { | |
logger('error', 'This browser does not support desktop notification'); | |
return; | |
} | |
if (!forceRequest && Notification.permission === 'denied') { | |
return; | |
} | |
if (Notification.permission === 'granted') { | |
hasPermission = true; | |
return; | |
} | |
const permission = await Notification.requestPermission(); | |
if (permission !== 'granted') { | |
hasPermission = true; | |
} | |
logger('debug', 'permission granted?', permission, hasPermission); | |
}; | |
/** | |
* | |
* @param {string} title | |
* @param {string} body | |
*/ | |
const push = async (title, body) => { | |
logger('debug', 'pushing notification', title, body); | |
const create = () => { | |
const notification = new Notification(title, { | |
...defaultOptions, | |
body, | |
}); | |
return notification; | |
}; | |
await checkPermission(); | |
if (!hasPermission) { | |
logger('warn', 'no permission to send notifications'); | |
return; | |
} | |
logger('debug', 'notification:', title, body); | |
create(title, body); | |
}; | |
return { | |
checkPermission, | |
push, | |
}; | |
} | |
const { | |
push: pushNotification, | |
checkPermission: checkNotificationPermission, | |
} = useNotification(); | |
const { play: playSoundAlert, createToggleButton: createSoundAlertButton } = | |
useSoundAlert(); | |
class Frame { | |
/** | |
* @type {number} key | |
*/ | |
key = 0; | |
/** | |
* @type {HTMLElement | null} containerElement | |
*/ | |
#containerElement = null; | |
/** | |
* @type {HTMLElement | null} statusElement | |
*/ | |
#statusElement = null; | |
/** | |
* @type {HTMLElement | null} iframeElement | |
*/ | |
#iframeElement = null; | |
/** | |
* @type {FrameStatus} status | |
*/ | |
#status = 'loading'; | |
/** | |
* @type {string} url | |
*/ | |
#url = ''; | |
/** | |
* @type {FrameOptions} options | |
*/ | |
#options = { | |
autoReloadDelay: 60 * 1000, | |
timeoutDelay: 20 * 1000, | |
reloadTimeAfterTimeout: 5 * 1000, | |
}; | |
/** | |
* @type {boolean} isMounted | |
*/ | |
#isMounted = false; | |
/** | |
* @type {any} interval | |
*/ | |
#autoReloadTimer = null; | |
/** | |
* @type {any} interval | |
*/ | |
#timeoutTimer = null; | |
/** | |
* @param {string} url | |
* @param {number} key | |
* @param {FrameOptions} options | |
*/ | |
constructor(url, key, options) { | |
this.#url = url; | |
this.key = key; | |
if (options) { | |
this.#options = { | |
...this.#options, | |
...options, | |
}; | |
} | |
this.init(); | |
} | |
/** | |
* @return {HTMLElement | null} container | |
*/ | |
get element() { | |
return this.#containerElement; | |
} | |
/** | |
* @return {HTMLElement} container | |
*/ | |
#createContainer() { | |
const element = document.createElement('article'); | |
element.style.position = 'relative'; | |
element.style.minWidth = '50vw'; | |
element.style.display = 'flex'; | |
element.style.flexDirection = 'column'; | |
element.style.justifyContent = 'center'; | |
element.style.alignItems = 'center'; | |
element.style.boxSizing = 'border-box'; | |
return element; | |
} | |
/** | |
* @return {HTMLElement} statusText | |
*/ | |
#createStatusElement() { | |
const element = document.createElement('div'); | |
element.style.position = 'absolute'; | |
element.style.top = '0'; | |
element.style.left = '0'; | |
return element; | |
} | |
/** | |
* @return {HTMLElement} iframe | |
*/ | |
#createIframe() { | |
const element = document.createElement('iframe'); | |
element.style.width = '100%'; | |
element.style.height = '100%'; | |
element.style.border = 'none'; | |
element.style.boxSizing = 'border-box'; | |
return element; | |
} | |
/** | |
* @returns {void} | |
*/ | |
#addEventListeners() { | |
this.#iframeElement.addEventListener('load', () => { | |
this.#setStatus('success'); | |
}); | |
this.#iframeElement.addEventListener('error', () => { | |
this.#setStatus('error', 'Iframe load error!'); | |
pushNotification('[framerize.js] Error!', 'Error loading iframe'); | |
playSoundAlert('gentle-alarm'); | |
}); | |
} | |
/** | |
* @returns {void} | |
*/ | |
#mount() { | |
logger('debug', `mounting iframe #${this.key}`); | |
if (!this.#containerElement) { | |
this.#containerElement = this.#createContainer(); | |
} | |
if (!this.#statusElement) { | |
this.#statusElement = this.#createStatusElement(); | |
this.#containerElement.appendChild(this.#statusElement); | |
} | |
this.#iframeElement = this.#createIframe(); | |
this.#containerElement.appendChild(this.#iframeElement); | |
this.#isMounted = true; | |
} | |
/** | |
* @returns {void} | |
*/ | |
#initIframe() { | |
logger('debug', `initializing iframe #${this.key}`); | |
this.#iframeElement.src = this.#url; | |
} | |
/** | |
* @param {number} customDelay | |
*/ | |
#runAutoReload(customDelay) { | |
this.reloadTimeout = setTimeout(() => { | |
this.reload(); | |
}, customDelay || this.#options.autoReloadDelay); | |
} | |
/** | |
* @returns {void} | |
*/ | |
#onTimeout() { | |
if (this.#status === 'success') { | |
return; | |
} | |
this.#setStatus('error', 'timeout error!'); | |
logger('error', `timeout error #${this.key}`, this.toLogObject()); | |
playSoundAlert('little-bell'); | |
pushNotification('[framerize.js] Timeout!', 'Timeout loading iframe'); | |
this.destroy(true); | |
this.#runAutoReload(this.#options.reloadTimeAfterTimeout); | |
} | |
/** | |
* @returns {void} | |
*/ | |
#runTimeout() { | |
this.#timeoutTimer = setTimeout(() => { | |
this.#onTimeout(); | |
}, this.#options.timeoutDelay); | |
} | |
/** | |
* @param {FrameStatus} status | |
* @param {string | undefined} customMessage | |
* @return {void} | |
*/ | |
#setStatus(status, customMessage) { | |
this.#status = status; | |
const updateStatusElement = (backgroundColor, message) => { | |
this.#statusElement.style.backgroundColor = backgroundColor; | |
this.#statusElement.innerText = customMessage || message; | |
}; | |
switch (this.#status) { | |
case 'loading': { | |
updateStatusElement('rgba(0, 0, 255, 0.5)', 'loading...'); | |
break; | |
} | |
case 'success': { | |
updateStatusElement('rgba(0, 255, 0, 0.5)', 'success!'); | |
break; | |
} | |
case 'error': { | |
updateStatusElement('rgba(255, 0, 0, 0.5)', 'error!'); | |
break; | |
} | |
default: { | |
updateStatusElement('rgba(0, 0, 0, 0.5)', '???'); | |
} | |
} | |
} | |
/** | |
* @param {boolean} keepContainer | |
* @returns {void} | |
*/ | |
destroy(keepContainer = false) { | |
if (!this.#autoReloadTimer) { | |
clearTimeout(this.#autoReloadTimer); | |
} | |
if (this.#timeoutTimer) { | |
clearTimeout(this.#timeoutTimer); | |
} | |
if (this.#iframeElement) { | |
this.#containerElement.removeChild(this.#iframeElement); | |
this.#iframeElement = null; | |
} | |
if (!keepContainer) { | |
this.#statusElement.remove(); | |
this.#statusElement = null; | |
this.#containerElement.remove(); | |
this.#containerElement = null; | |
} | |
this.#isMounted = false; | |
} | |
/** | |
* @returns {void} | |
*/ | |
reload() { | |
logger('info', `reloading iframe #${this.key}`, this.toLogObject()); | |
try { | |
this.destroy(true); | |
} catch (error) { | |
this.#setStatus('error'); | |
playSoundAlert('whistling'); | |
logger( | |
'error', | |
`error reloading iframe #${this.key}`, | |
this.toLogObject(), | |
error, | |
); | |
} | |
this.init(); | |
} | |
/** | |
* @returns {void} | |
*/ | |
init() { | |
logger('info', `initializing iframe #${this.key}`); | |
if (!this.#isMounted) { | |
this.#mount(); | |
} | |
this.#setStatus('loading'); | |
this.#addEventListeners(); | |
this.#initIframe(); | |
this.#runAutoReload(); | |
this.#runTimeout(); | |
} | |
/** | |
* @returns {FrameLogObject} logObject | |
*/ | |
toLogObject() { | |
const logObject = { | |
key: this.key, | |
url: this.#url, | |
status: this.#status, | |
isMounted: this.#isMounted, | |
}; | |
return JSON.parse(JSON.stringify(logObject)); | |
} | |
} | |
class Framerize { | |
/** | |
* @param {Array<String>} urls | |
*/ | |
urls = []; | |
/** | |
* @type {HTMLElement} dom | |
*/ | |
dom = null; | |
/** | |
* @type {HTMLElement} layout | |
*/ | |
layout = null; | |
/** | |
* @type {Array<Frame>} frames | |
*/ | |
frames = []; | |
/** | |
* @type {FrameOptions | undefined} frameOptions | |
*/ | |
frameOptions; | |
/** | |
* @param {Array<string>} urls | |
*/ | |
constructor(urls, frameOptions) { | |
this.urls = urls; | |
this.frameOptions = frameOptions; | |
} | |
/** | |
* @return {HTMLElement} layout | |
*/ | |
createLayout() { | |
const layout = document.createElement('main'); | |
layout.style.margin = '0'; | |
layout.style.padding = '0'; | |
layout.style.position = 'relative'; | |
layout.style.width = '100%'; | |
layout.style.height = '100%'; | |
layout.style.display = 'flex'; | |
layout.style.flexDirection = 'row'; | |
layout.style.flexWrap = 'wrap'; | |
layout.style.justifyContent = 'flex-start'; | |
layout.style.alignItems = 'stretch'; | |
layout.style.boxSizing = 'border-box'; | |
const toggleButton = createSoundAlertButton(); | |
toggleButton.style.position = 'fixed'; | |
toggleButton.style.top = '0'; | |
toggleButton.style.right = '0'; | |
toggleButton.style.zIndex = '9999'; | |
layout.appendChild(toggleButton); | |
return layout; | |
} | |
/** | |
* @return {HTMLElement} dom | |
*/ | |
createDom() { | |
const createHead = () => { | |
const head = window.document.createElement('head'); | |
const title = window.document.createElement('title'); | |
title.innerText = 'Framerize'; | |
const meta = window.document.createElement('meta'); | |
meta.setAttribute('charset', 'utf-8'); | |
return head; | |
}; | |
const createBody = () => { | |
const body = window.document.createElement('body'); | |
body.style.margin = '0'; | |
body.style.padding = '0'; | |
body.style.width = '100vw'; | |
body.style.height = '100vh'; | |
body.style.overflow = 'hidden'; | |
body.style.display = 'flex'; | |
body.style.flexDirection = 'column'; | |
body.style.justifyContent = 'center'; | |
body.style.fontFamily = 'Ubuntu Mono, monospace'; | |
body.style.alignItems = 'center'; | |
body.style.boxSizing = 'border-box'; | |
return body; | |
}; | |
const createHtml = () => { | |
const html = window.document.createElement('html'); | |
return html; | |
}; | |
const html = createHtml(); | |
const mount = () => { | |
html.appendChild(createHead()); | |
html.appendChild(createBody()); | |
}; | |
mount(); | |
return html; | |
} | |
/** | |
* @param {String} url | |
* @param {number} key | |
* @return {Frame} frame | |
*/ | |
createFrame(url, key) { | |
const frame = new Frame(url, key); | |
return frame; | |
} | |
/** | |
* @return {void} | |
*/ | |
createFrames() { | |
return this.urls.map(this.createFrame); | |
} | |
/** | |
* @returns {void} | |
*/ | |
destroyDom() { | |
window.document.removeChild(window.document.documentElement); | |
} | |
/** | |
* @returns {void} | |
*/ | |
#mount() { | |
this.destroyDom(); | |
this.dom = this.createDom(); | |
window.document.appendChild(this.dom); | |
this.layout = this.createLayout(); | |
window.document.body.appendChild(this.layout); | |
this.frames = this.createFrames(); | |
this.frames.forEach((frame) => { | |
this.layout.appendChild(frame.element); | |
}); | |
} | |
/** | |
* @returns {void} | |
*/ | |
async init() { | |
this.#mount(); | |
this.frames.forEach((frame) => { | |
frame.init(); | |
}); | |
checkNotificationPermission(true); | |
} | |
} | |
})(); |
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
const framerize = new Framerize( | |
[ | |
'https://google.com', | |
'https://google.com', | |
'https://google.com', | |
'https://google.com', | |
'https://google.com', | |
'https://google.com', | |
'https://google.com', | |
], | |
{ | |
autoReloadDelay: 120 * 1000, | |
timeoutDelay: 30 * 1000, | |
reloadTimeAfterTimeout: 10 * 1000, | |
}, | |
); | |
framerize.init(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment