Comparing version 1.0.3 to 1.1.0
208
litfass.js
@@ -0,1 +1,3 @@ | ||
"use strict"; | ||
const puppeteer = require('puppeteer'); | ||
@@ -5,9 +7,24 @@ | ||
const Display = nbind.init(__dirname).lib.Display; | ||
const sleep = time => new Promise(resolve => setTimeout(resolve, time)); | ||
process.env["NODE_CONFIG_DIR"] = __dirname + "/config/"; | ||
const config = require('config').get('litfass'); | ||
const { scheduleIn } = require('./schedule'); | ||
const { sleep, scheduleIn } = require('./schedule'); | ||
const LAUNCH_TIMEOUT = 10e3, DEFAULT_ROTATION_SPEED = 5e3, DEFAULT_TRANSITION_ANIMATION = { | ||
const merge = require('deepmerge') | ||
const silentImmediate = (asyncCallback, errorCallback) => { | ||
setImmediate(async () => { | ||
try { await asyncCallback(); } | ||
catch(error) { | ||
// catch any errors (silently) | ||
if (errorCallback) { | ||
errorCallback(error); | ||
} | ||
} | ||
}) | ||
}; | ||
const DEFAULT_LAUNCH_TIME = 10, DEFAULT_PAGE_PRELOAD = { | ||
numberOfPages: 2, | ||
preloadTime: 5, | ||
}, DEFAULT_AIR_TIME = 5, DEFAULT_TRANSITION_ANIMATION = { | ||
name: 'fade', | ||
@@ -34,44 +51,51 @@ duration: 400 | ||
const animate = (page, ...animations) => { | ||
return new Promise((resolve, reject) => { | ||
silentImmediate(async () => { | ||
for(const animation of animations) { | ||
if (typeof animation === "string") { | ||
await page.addStyleTag({ content: animation }); | ||
} else if (animation) { | ||
await animation(page); | ||
} | ||
} resolve(); | ||
}, reject); | ||
}).catch(error => { /* do nothing */ }); | ||
}; | ||
exports.start = async (app) => { | ||
const displays = Display.getDisplays(), pages = []; | ||
// read the configuration and create a deep copy | ||
const config = merge.all([require('config').get('litfass') || {}]); | ||
// launch one browser per display | ||
for (const display of displays) { | ||
const browser = await puppeteer.launch({ | ||
headless: false, | ||
defaultViewport: null, /* do not set any viewport => full screen */ | ||
args: [ | ||
'--kiosk' /* launch in full-screen */, | ||
`--app=${app}`, /* needed to get rid of "automation" banner */ | ||
`--window-position=${display.left},${display.top}` | ||
] | ||
}); | ||
// remember the page objects, we'll need them later on | ||
let page; pages.push(page = (await browser.pages())[0]); | ||
// normalize the configuration | ||
config.launch = config.launch || {}; | ||
config.launch.time = ((config.launch.time | 0) || DEFAULT_LAUNCH_TIME) * 1e3; // normalize to milliseconds | ||
config.launch.page = { url: app, airTime: config.launch.time }; // build an artificial launch page configuration | ||
config.pagePreload = config.pagePreload || {}; | ||
config.pagePreload.numberOfPages = Math.max(( config.pagePreload.numberOfPages | 0 ) || DEFAULT_PAGE_PRELOAD.numberOfPages, 1); | ||
config.pagePreload.preloadTime = Math.max(( config.pagePreload.preloadTime | 0 ) || DEFAULT_PAGE_PRELOAD.preloadTime, 0) * 1e3; // normalize to milliseconds | ||
// if all pages / browser have been closed, exit litfaß | ||
function closeDisplay(display) { | ||
display.closed = true; | ||
if(!displays.some(display => !display.closed)) { | ||
// all displays have been closed, exit the process | ||
process.exit(0); | ||
} | ||
} | ||
browser.on('disconnected', () => closeDisplay(display)); | ||
page.on('close', () => closeDisplay(display)); | ||
// page preloading can be turned off, by setting the number of preload pages to one. this is helpful for | ||
// low-resource machines, but we'll need to treat the preloading logic differently for this corner case | ||
const shouldPreload = config.pagePreload.numberOfPages > 1; | ||
// check if there is at least one display defined | ||
if (!Array.isArray(config.displays)) { | ||
throw new Error(`Litfaß configuration needs a 'displays' array`); | ||
} else if(!config.displays.length) { | ||
throw new Error(`Litfaß configuration needs at least one display`); | ||
} | ||
// wait on the launch screen until the launch timeout has passed | ||
await sleep(LAUNCH_TIMEOUT); | ||
// for each display start the rotation | ||
await Promise.all(displays.map(async (nothing, index) => { | ||
const page = pages[index], display = Object.assign({}, config[index] || config[0], { currentPage: -1 }); | ||
// create browsers for each display | ||
const displays = Display.getDisplays(); | ||
await Promise.all(displays.map(async (display, index) => { | ||
// normalize the configuration for this display | ||
Object.assign(display, config.displays[index] || config.displays[0], { | ||
launch: true, currentPage: -1, currentTab: -1 }); | ||
// check if the display has a pages array | ||
if (!Array.isArray(display.pages)) { | ||
throw new Error(`Display ${index} needs a 'pages' array`); | ||
throw new Error(`Display configuration ${index} needs a 'pages' array`); | ||
} else if(!display.pages.length) { | ||
throw new Error(`Display ${index} needs at least one page to display`); | ||
throw new Error(`Display configuration ${index} needs at least one page to display`); | ||
} else if(display.ignore) { | ||
@@ -82,10 +106,8 @@ // ignore this display for the litfaß display | ||
// normalize the rotationSpeed to an array of integer numbers | ||
if (!display.rotationSpeed || !Array.isArray(display.rotationSpeed)) { | ||
display.rotationSpeed = Array(display.pages.length).fill( | ||
(display.rotationSpeed | 0) || DEFAULT_ROTATION_SPEED); | ||
} else { | ||
display.rotationSpeed = Array.from(Array(display.pages.length), (nothing, page) => | ||
(display.rotationSpeed[page] | 0) || DEFAULT_ROTATION_SPEED); | ||
} | ||
// normalize the rotationSpeed and URL into an array of page objects | ||
display.pages = display.pages.map((url, index) => ({ | ||
url, airTime: ((Array.isArray(display.rotationSpeed) ? | ||
(display.rotationSpeed[index] | 0) : (display.rotationSpeed | 0)) | ||
|| DEFAULT_AIR_TIME) * 1e3 // normalize to milliseconds | ||
})); | ||
@@ -110,37 +132,79 @@ // normalize the transitionAnimation to an object | ||
} | ||
} else { | ||
// no animation takes no time to animate | ||
transitionAnimation.halfDuration = 0; | ||
} | ||
// function to advance to the next page | ||
async function advancePage() { | ||
display.currentPage = ++display.currentPage % display.pages.length; | ||
// launch one browser per display | ||
const browser = display.browser = await puppeteer.launch(merge(config.launch.options, { | ||
headless: false, | ||
defaultViewport: null, /* do not set any viewport => full screen */ | ||
ignoreDefaultArgs: ['--enable-automation'], | ||
args: [ | ||
//'--kiosk' /* launch in full-screen */, | ||
`--window-position=${display.left},${display.top}` | ||
] | ||
})); | ||
if (!page.isClosed() && display.pages.length > 1) { | ||
// we have a very flaky schedule in place, thus this call will always try to merge | ||
// multiple events into one. this way all the displays will stay nicely in sync! | ||
scheduleIn(display.rotationSpeed[display.currentPage], advancePage); | ||
// if all displays have been closed, exit litfaß | ||
async function closeDisplay() { | ||
// if the browser is still connected, close it (as only one page was closed) | ||
if (browser.isConnected()) { | ||
await browser.close(); | ||
} | ||
try { | ||
if (transitionAnimation.name !== 'none') { | ||
// in case a transition should be made fade out first | ||
await page.addStyleTag({ content: transitionAnimation.out }); | ||
await sleep(transitionAnimation.halfDuration); | ||
display.closed = true; | ||
if(!displays.some(display => !display.closed)) { | ||
// all displays have been closed, exit the process | ||
process.exit(0); | ||
} | ||
} browser.on('disconnected', closeDisplay); | ||
// the browser will open with one page pre-loaded | ||
const firstTab = (await browser.pages())[0]; | ||
await firstTab.goto(config.launch.page.url); | ||
// do NOT await the page to be advanced (maybe it takes longer than the next timeout) | ||
await page.goto(display.pages[display.currentPage], { waitUntil: 'domcontentloaded' }); | ||
await page.addStyleTag({ content: transitionAnimation.after }); | ||
await page.addStyleTag({ content: transitionAnimation.in }); | ||
} else { | ||
// with no transition, navigate immediately | ||
await page.goto(display.pages[display.currentPage]); | ||
} | ||
} catch(e) { | ||
// nothing to do here, likely the page has been closed! | ||
console.warn(`Display ${index} navigation failed`); | ||
} | ||
// open more pages (tabs) for preloading the page rotation | ||
const additionalTabs = await Promise.all(Array.from({ | ||
length: config.pagePreload.numberOfPages - 1 | ||
}, async () => { | ||
// open the page and jump back to the launch screen immediately | ||
let page = await browser.newPage(); | ||
await firstTab.bringToFront(); | ||
return page; | ||
})); | ||
// if any page was closed, close the browser for this display | ||
(display.tabs = [firstTab, ...additionalTabs]) | ||
.forEach(page => page.on('close', closeDisplay)); | ||
})); | ||
// start the rotation for each display | ||
await Promise.all(displays.map(async (display, index) => { for(const transitionAnimation = display.transitionAnimation;;) { | ||
// check if the browser for this display is still connected | ||
if (display.ignore || !display.browser.isConnected()) { | ||
break; // if not exit the rotation for this display | ||
} | ||
// advance to the first page and schedule the rotation | ||
advancePage(); | ||
})); | ||
// increment the current page count and get the current / next page object to display | ||
const page = display.launch ? config.launch.page : display.pages[display.currentPage = ++display.currentPage % display.pages.length], | ||
nextPage = display.pages[(display.currentPage + 1) % display.pages.length]; | ||
delete display.launch; // on the first iteration displaying the launch page will NOT increment the page count | ||
// also get the currently active and next in line browser tab to use | ||
const tab = display.tabs[display.currentTab = ++display.currentTab % display.tabs.length], | ||
nextTab = display.tabs[(display.currentTab + 1) % display.tabs.length]; | ||
const preloadNextTab = wait => animate(nextTab, wait ? () => sleep(wait) : null, () => | ||
nextTab.goto(nextPage.url, { waitUntil: 'domcontentloaded' }), transitionAnimation.after); | ||
// if pages should be preloaded, prepare the next tab shortly before we switch to it | ||
shouldPreload && preloadNextTab(page.airTime - config.pagePreload.preloadTime); | ||
// all displays that are scheduled to show a page at the same time, should be doing so as synchronized as possible, so use a flaky scheduler here | ||
await scheduleIn(page.airTime, async () => { | ||
animate(tab, transitionAnimation.out); | ||
await sleep(transitionAnimation.halfDuration); // wait here, as the offset was subtracted from the schedule time before | ||
animate(nextTab, () => shouldPreload ? nextTab.bringToFront() : preloadNextTab(), transitionAnimation.in, shouldPreload ? () => tab.goto('about:blank') : null); | ||
}, -transitionAnimation.halfDuration /* offset, so we stay as close to the air time as possible */); | ||
}})); | ||
}; |
{ | ||
"name": "litfass", | ||
"version": "1.0.3", | ||
"version": "1.1.0", | ||
"description": "A library for advertising displays", | ||
@@ -46,2 +46,3 @@ "keywords": [ | ||
"config": "^3.3.0", | ||
"deepmerge": "^4.2.2", | ||
"express": "~4.16.1", | ||
@@ -48,0 +49,0 @@ "http-errors": "^1.7.3", |
@@ -36,7 +36,13 @@ [<img width="300" alt="Litfaß Logo" src="https://raw.githubusercontent.com/kristian/litfass/master/public/img/litfass.svg?sanitize=true">](https://github.com/kristian/litfass) | ||
- **pages**: An array of URLs to rotate the display between (e.g. https://example.com and https://kra.lc/ will rotate between those two pages in the set `rotationSpeed`) | ||
- **rotationSpeed**: The number of seconds each page should be displayed. In case some pages should be displayed longer than others, you can also specify an array. By default a page is displayed for 10 seconds before being rotated. | ||
- **transitionAnimation**: Litfaß supports transitioning between multiple pages when rotating. The following animations are supported `none`, `fade`, `slideUp`, `slideLeft`, defaults to `fade`. You can also use an object `{ name: ..., duration: ... }` to specify the speed of the transition in milliseconds. | ||
- **launch**: An object to specify different launch options for Litfaß: | ||
- *time*: The number of seconds Litfaß will display its slash / launch screen | ||
- *options*: Options merged into the launch options for Puppeteer / Chrome, e.g. handy in case you would like to specify an own *executablePath* for Chrome. | ||
- **displays**: An array of displays, to specify different rotations on different displays connected. In case more displays are connected than specified here, the first display configuration will be used. | ||
- *pages*: An array of URLs to rotate the display between (e.g. https://example.com and https://kra.lc/ will rotate between those two pages in the set `rotationSpeed`) | ||
- *rotationSpeed*: The number of seconds each page should be displayed. In case some pages should be displayed longer than others, you can also specify an array. By default a page is displayed for 10 seconds before being rotated. | ||
- *transitionAnimation*: Litfaß supports transitioning between multiple pages when rotating. The following animations are supported `none`, `fade`, `slideUp`, `slideLeft`, defaults to `fade`. You can also use an object `{ name: ..., duration: ... }` to specify the speed of the transition in milliseconds. | ||
- *ignore*: If set Litfaß will ignore this display and will not start a browser on the specified display. | ||
- **pagePreload**: Litfaß will automatically attempt to preload any page, before it is displayed. This object can be used to specify the number of buffered pages to use (*numberOfPages*) and the time the page should be started loading in advance (*preloadTime*). | ||
By default the `litfass` configuration section contains only one element. This causes the same rotation of pages to be displayed on all connected displays. Define multiple entries in the `litfass` section, where each entry corresponds to one display connected. To ignore a display use `ignore: true`. | ||
By default the `displays` configuration section contains only one element. This causes the same rotation of pages to be displayed on all connected displays. Define multiple entries in the `displays` section, where each entry corresponds to one display connected. To ignore a display use `ignore: true`. | ||
@@ -43,0 +49,0 @@ ## Author |
// this is a VERY "flaky" schedule, by design! this means close events (by 2 seconds) will be merged | ||
// together into one event, this way the litfaß will not "stutter" between multiple displays even | ||
// if it runs for a very long time, due to un-synchronized setTimestamp calls! | ||
const flakySchedule = new Map(); | ||
const queue = new Map(); | ||
exports.scheduleIn = (seconds, task) => { | ||
const sleep = exports.sleep = (time, callback) => new Promise(resolve => time > 0 ? setTimeout(resolve, time) : setImmediate(resolve)).then(callback); | ||
exports.scheduleIn = (ms, task, offset) => { | ||
// calculate the future time slot, when the event should be triggered | ||
const time = (Date.now() / 1000) + seconds, timeSlot = Math.round(time); | ||
const time = Math.ceil((Date.now() + ms) / 1e3), timeSlot = time + ((offset | 0) / 1e3); | ||
// check if there is a schedule for this / or the upcoming time slot already | ||
let promise = flakySchedule.get(timeSlot) || flakySchedule.get(Math.ceil(time)); | ||
// check if there is a schedule for this time slot already | ||
let schedule = queue.get(timeSlot); | ||
// if there is no schedule yet, create one as close to the full second calculated as possible | ||
if (!promise) { | ||
flakySchedule.set(timeSlot, promise = new Promise((resolve) => { | ||
setTimeout(resolve, (timeSlot * 1000) - Date.now()); | ||
})); | ||
if (!schedule) { | ||
const tasks = []; | ||
queue.set(timeSlot, schedule = { | ||
tasks, promise: sleep((timeSlot * 1e3) - Date.now()).then(async () => { | ||
await Promise.all(tasks.map(task => task())); // execute all tasks in parallel | ||
}) | ||
}); | ||
} | ||
// in case a task function as specified, chain it to the promise | ||
task && promise.then(task); | ||
// append the task to the schedules tasks list | ||
schedule.tasks.push(task); | ||
// return the promise, which allows more chaining | ||
return promise; | ||
// return the promise this schedule is waiting for | ||
return schedule.promise; | ||
}; |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
28638
256
58
10
+ Addeddeepmerge@^4.2.2
+ Addeddeepmerge@4.3.1(transitive)