diff --git a/.gitignore b/.gitignore index c2658d7..b947077 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ node_modules/ +dist/ diff --git a/README.md b/README.md index 82accba..516f658 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # Cern Paella Plugins [![Build and push to NPM](https://github.com/cern-vc/cern-paella-plugins/actions/workflows/build.yml/badge.svg)](https://github.com/cern-vc/cern-paella-plugins/actions/workflows/build.yml) +[![npm version](https://badge.fury.io/js/cern-paella-plugins.svg)](https://badge.fury.io/js/cern-paella-plugins) This repository contains the plugins for the Paella Player used at CERN. diff --git a/package.json b/package.json index 1029128..8aa77cf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cern-paella-plugins", - "version": "0.1.0", + "version": "0.2.0", "description": "Paella plugins for cern use", "main": "src/index.js", "module": "dist/cern-paella-plugins.js", diff --git a/src/plugins/ch.cern.paella.liveStreamIndicatorPlugin.js b/src/plugins/ch.cern.paella.liveStreamIndicatorPlugin.js new file mode 100644 index 0000000..a517d5e --- /dev/null +++ b/src/plugins/ch.cern.paella.liveStreamIndicatorPlugin.js @@ -0,0 +1,82 @@ +/* eslint-disable no-param-reassign */ +/* eslint-disable max-classes-per-file */ +import { Canvas, CanvasPlugin, createElementWithHtmlText } from "paella-core"; +import * as img_url from "./icons/live-icon.png"; + +// Canvas implementation +export class LiveStreamIndicatorCanvas extends Canvas { + /** + * This class displays an image indicating whether the stream is live or not. + * + * @param {*} player + * @param {*} videoContainer + * @param {*} stream + */ + constructor(player, videoContainer, stream) { + super("div", player, videoContainer); + this.stream = stream; + this.parentContainer = videoContainer; + } + + /** + * When the plugin is loaded, this code will be executed. + * It will display the image of the live stream indicator if the stream is live. + * + * @param {*} player + */ + async loadCanvas() { + let isLiveStream = false; + + const streamSources = this.stream.sources; + + const setStreamAsLive = (key, value) => { + const tempIsLiveStream = value[0].isLiveStream; + if (tempIsLiveStream) { + isLiveStream = tempIsLiveStream; + } + }; + + Object.keys(streamSources).forEach((key) => { + setStreamAsLive(key, streamSources[key]); + }); + + if (isLiveStream) { + const indicator = document.getElementById("live-stream-indicator"); + if (indicator) { + return; + } + console.log("Stream is live. Displaying the live stream indicator"); + createElementWithHtmlText( + `
`, + this.parentContainer + ); + } + } +} + +// Canvas plugin definition +export default class LiveStreamIndicatorPlugin extends CanvasPlugin { + // eslint-disable-next-line class-methods-use-this + get parentContainer() { + return "videoContainer"; // or videoContainer + } + + isCompatible(stream) { + if (!Array.isArray(stream.canvas) || stream.canvas.length === 0) { + console.log("No canvas defined in the stream"); + // By default, the default canvas is HTML video canvas + this.stream = stream; + return true; + } + + return super.isCompatible(stream); + } + + getCanvasInstance(videoContainer) { + return new LiveStreamIndicatorCanvas( + this.player, + videoContainer, + this.stream + ); + } +} diff --git a/src/plugins/ch.cern.paella.liveStreamingProgressIndicator.js b/src/plugins/ch.cern.paella.liveStreamingProgressIndicator.js new file mode 100644 index 0000000..a643f35 --- /dev/null +++ b/src/plugins/ch.cern.paella.liveStreamingProgressIndicator.js @@ -0,0 +1,110 @@ +import { ProgressIndicatorPlugin } from "paella-core"; + +function draw(context, width, height) { + console.log("draw params", { + context, + width, + height, + }); + let posX = 0; + let textMargin = 0; + const circleSize = 8; + + if (this.side === "left") { + posX = this.margin; + textMargin = circleSize + 4; + } else if (this.side === "center") { + posX = width / 2; + textMargin = 0; + } else if (this.side === "right") { + posX = width - this.margin; + textMargin = -(circleSize + 4); + } + + const circleMargin = this.side === "center" ? -40 : 0; + context.fillStyle = this.textColor; + context.font = `bold 14px Arial`; + context.textAlign = this.side; + const textHeight = height / 2 + 3; + + context.fillText("Live", posX + textMargin, textHeight); + + context.beginPath(); + context.fillStyle = this.circleColor; + context.arc( + posX + circleMargin, + height / 2, + circleSize / 2, + 0, + 2 * Math.PI, + false + ); + context.fill(); +} + +function minHeight() { + return 25; +} + +function minHeightHover() { + return 25; +} + +export default class LiveStreamingProgressIndicatorPlugin extends ProgressIndicatorPlugin { + async isEnabled() { + const e = await super.isEnabled(); + console.log("isEnabled1", e); + console.log("player", this.player); + console.log("isEnabled2", this.player.videoContainer.isLiveStream); + return true; + return e && this.player.videoContainer.isLiveStream; + } + + async load() { + this.layer = this.config.layer ?? "foreground"; + this.side = this.config.side ?? "right"; + this.margin = this.config.margin ?? 50; + this.textColor = this.config.textColor ?? "white"; + this.circleColor = this.config.circleColor ?? "red"; + + if (["foreground", "background"].indexOf(this.layer) === -1) { + throw new Error( + "Invalid layer set in plugin 'es.upv.paella.liveStreamingPlugin'. Valid values are 'foreground' or 'background'" + ); + } + + if (["left", "center", "right"].indexOf(this.side) === -1) { + throw new Error( + "Invalid side set in plugin 'es.upv.paella.liveStreamingPlugin'. Valid values are 'left', 'center' or 'right'" + ); + } + + console.log("load params", { + layer: this.layer, + side: this.side, + margin: this.margin, + textColor: this.textColor, + circleColor: this.circleColor, + }); + } + + drawForeground(context, width, height, isHover) { + if (this.layer === "foreground") { + draw.apply(this, [context, width, height, isHover]); + } + } + + drawBackground(context, width, height, isHover) { + if (this.layer === "background") { + draw.apply(this, [context, width, height, isHover]); + } + } + + get minHeight() { + return minHeight.apply(this); + } + + get minHeightHover() { + return minHeightHover.apply(this); + } +} diff --git a/src/plugins/ch.cern.paella.matomoAnalyticsPlugin.js b/src/plugins/ch.cern.paella.matomoAnalyticsPlugin.js new file mode 100644 index 0000000..aedd87a --- /dev/null +++ b/src/plugins/ch.cern.paella.matomoAnalyticsPlugin.js @@ -0,0 +1,61 @@ +/* eslint-disable no-underscore-dangle */ +/* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable no-multi-assign */ +import { DataPlugin } from "paella-core"; + +export default class MatomoAnalyticsUserTrackingDataPlugin extends DataPlugin { + async load() { + console.log("Loading matomo analytics plugin"); + const { trackingId } = this.config; + // const domain = this.config.domain || "auto"; + if (trackingId) { + console.log("Matomo Analytics Enabled"); + const _paq = (window._paq = window._paq || []); + /* tracker methods like "setCustomDimension" should be called before "trackPageView" */ + _paq.push(["trackPageView"]); + _paq.push(["enableLinkTracking"]); + // eslint-disable-next-line func-names + (function () { + const u = "https://webanalytics.web.cern.ch/"; + _paq.push(["setTrackerUrl", `${u}matomo.php`]); + _paq.push(["setSiteId", trackingId]); + const d = document; + const g = d.createElement("script"); + const s = d.getElementsByTagName("script")[0]; + g.async = true; + g.src = `${u}matomo.js`; + s.parentNode.insertBefore(g, s); + })(); + } else { + console.log( + "No Matomo Tracking ID found in config file. Disabling Matomo Analytics", + ); + } + } + + async write(context, { id }, data) { + if (this.config.category === undefined || this.config.category === true) { + const category = "PaellaPlayer"; + const action = data.event; + const labelData = { + videoId: id, + plugin: data.plugin, + }; + + // try { + // // Test if data parameters can be serialized + // JSON.stringify(data.params); + // labelData.params = data.params; + // } catch (error) { + // console.log(error); + // } + + const label = JSON.stringify(labelData); + if (category.length > 0 && action.length > 0) { + // _paq.push(['trackEvent', 'Contact', 'Email Link Click', 'name@example.com']); + // eslint-disable-next-line no-undef + _paq.push(["trackEvent", category, action, label]); + } + } + } +} diff --git a/src/plugins/ch.cern.paella.matomoAnalyticsUserTrackingPlugin.js b/src/plugins/ch.cern.paella.matomoAnalyticsUserTrackingPlugin.js new file mode 100644 index 0000000..7b86b47 --- /dev/null +++ b/src/plugins/ch.cern.paella.matomoAnalyticsUserTrackingPlugin.js @@ -0,0 +1,50 @@ +import { Events, EventLogPlugin } from "paella-core"; + +// const eventKeys = Object.keys(Events); + +const getPaellaEvents = (events) => + events.map((eventName) => Events[eventName]); + +export default class MatomoAnalyticsUserEventTrackerPlugin extends EventLogPlugin { + get events() { + if (this.config.events) { + return getPaellaEvents(this.config.events); + } + + return [ + Events.PLAY, + Events.PAUSE, + Events.SEEK, + Events.STOP, + Events.ENDED, + Events.FULLSCREEN_CHANGED, + Events.VOLUME_CHANGED, + Events.BUTTON_PRESS, + Events.RESIZE_END, + ]; + } + + async onEvent(event, params) { + const id = this.player.videoId; + // Remove plugin reference to avoid circular references + if (params.plugin) { + const { name, config } = params.plugin; + // eslint-disable-next-line no-param-reassign + params.plugin = { name, config }; + } + const trackingData = { event, params }; + + switch (event) { + case Events.SHOW_POPUP: + case Events.HIDE_POPUP: + case Events.BUTTON_PRESS: + trackingData.plugin = params.plugin?.name || null; + break; + default: + break; + } + + const context = this.config.context || "matomoUserTracking"; + await this.player.data.write(context, { id }, trackingData); + } +} diff --git a/src/plugins/ch.cern.paella.nextTimeButtonPlugin.js b/src/plugins/ch.cern.paella.nextTimeButtonPlugin.js new file mode 100644 index 0000000..35b6c1b --- /dev/null +++ b/src/plugins/ch.cern.paella.nextTimeButtonPlugin.js @@ -0,0 +1,86 @@ +import { ButtonPlugin } from "paella-core"; +import defaultForwardIcon from "./icons/next-icon.svg"; + +const params = new Proxy(new URLSearchParams(window.location.search), { + get: (searchParams, prop) => searchParams.get(prop), +}); + +export function toSeconds(timeExpr) { + const units = { h: 3600, m: 60, s: 1 }; + const regex = /(\d+)([hms])/g; + + let seconds = 0; + let match = regex.exec(timeExpr); + + while (match) { + seconds += parseInt(match[1], 10) * units[match[2]]; + match = regex.exec(timeExpr); + } + + return seconds; +} + +export default class NextTimeButtonPlugin extends ButtonPlugin { + getAriaLabel() { + return this.player.translate("Go to next time"); + } + + getDescription() { + return this.getAriaLabel(); + } + + async isEnabled() { + const enabled = (await super.isEnabled()) && params.time; + if (!enabled) { + return false; + } + this.time = this.config.time || 30; + this.timeParams = params.time.split(","); + this.slots = []; + if (this.timeParams) { + this.timeParams.forEach((element) => { + if (/[hms]/.test(element)) { + this.slots.push(toSeconds(element)); + } else { + this.slots.push(parseFloat(element)); + } + }); + } + this.player.currentPosition = 0; + this.goToPosition(this.player.currentPosition); + return enabled; + } + + async load() { + const addSuffix = + this.config.suffix !== undefined ? this.config.suffix : true; + this.suffix = addSuffix ? "s" : ""; + this.icon = ``; + setTimeout(() => { + Array.from(this.iconElement.getElementsByClassName("time-text")).forEach( + (textIcon) => { + // eslint-disable-next-line no-param-reassign + textIcon.innerHTML = this.time + this.suffix; + }, + ); + }, 100); + } + + goToPosition(position) { + if (position > this.slots.length - 1) { + this.player.currentPosition = this.slots.length - 1; + } else { + this.player.currentPosition = position; + } + this.time = this.slots[this.player.currentPosition]; + console.log( + `Jump to next time: ${this.time}. Slot ${this.player.currentPosition}`, + ); + // const currentTime = await this.player.videoContainer.currentTime(); + this.player.videoContainer.setCurrentTime(this.time); + } + + async action() { + this.goToPosition(this.player.currentPosition + 1); + } +} diff --git a/src/plugins/ch.cern.paella.prevTimeButtonPlugin.js b/src/plugins/ch.cern.paella.prevTimeButtonPlugin.js new file mode 100644 index 0000000..7616fc2 --- /dev/null +++ b/src/plugins/ch.cern.paella.prevTimeButtonPlugin.js @@ -0,0 +1,86 @@ +import { ButtonPlugin } from "paella-core"; +import defaultIcon from "./icons/previous-icon.svg"; + +const params = new Proxy(new URLSearchParams(window.location.search), { + get: (searchParams, prop) => searchParams.get(prop), +}); + +export function toSeconds(timeExpr) { + const units = { h: 3600, m: 60, s: 1 }; + const regex = /(\d+)([hms])/g; + + let seconds = 0; + let match = regex.exec(timeExpr); + + while (match) { + seconds += parseInt(match[1], 10) * units[match[2]]; + match = regex.exec(timeExpr); + } + + return seconds; +} + +export default class PrevTimeButtonPlugin extends ButtonPlugin { + getAriaLabel() { + return this.player.translate("Go to previous time"); + } + + getDescription() { + return this.getAriaLabel(); + } + + async isEnabled() { + const enabled = (await super.isEnabled()) && params.time; + if (!enabled) { + return false; + } + this.time = 0; + this.timeParams = params.time.split(","); + this.slots = []; + if (this.timeParams) { + this.timeParams.forEach((element) => { + if (/[hms]/.test(element)) { + this.slots.push(toSeconds(element)); + } else { + this.slots.push(parseFloat(element)); + } + }); + } + this.player.currentPosition = 0; + // this.goToPosition(this.player.currentPosition); + + return enabled; + } + + async load() { + const addSuffix = + this.config.suffix !== undefined ? this.config.suffix : true; + this.suffix = addSuffix ? "s" : ""; + this.icon = ``; + setTimeout(() => { + Array.from(this.iconElement.getElementsByClassName("time-text")).forEach( + (textIcon) => { + // eslint-disable-next-line no-param-reassign + textIcon.innerHTML = this.time + this.suffix; + }, + ); + }, 100); + } + + goToPosition(position) { + if (position < 0) { + this.player.currentPosition = 0; + } else { + this.player.currentPosition = position; + } + this.time = this.slots[this.player.currentPosition]; + console.log( + `Jump to previous time: ${this.time}. Slot ${this.player.currentPosition}`, + ); + this.player.videoContainer.setCurrentTime(this.time); + } + + async action() { + this.goToPosition(this.player.currentPosition - 1); + } +} diff --git a/src/plugins/ch.cern.paella.vttManifestCaptionsPlugin.js b/src/plugins/ch.cern.paella.vttManifestCaptionsPlugin.js new file mode 100644 index 0000000..09f7d83 --- /dev/null +++ b/src/plugins/ch.cern.paella.vttManifestCaptionsPlugin.js @@ -0,0 +1,47 @@ +import { CaptionsPlugin, utils, WebVTTParser } from "paella-core"; + +export default class VttManifestCaptionsPlugin extends CaptionsPlugin { + async isEnabled() { + const enabled = await super.isEnabled(); + return ( + enabled && + this.player.videoManifest.captions && + this.player.videoManifest.captions.length > 0 + ); + } + + async getCaptions() { + const result = []; + const p = []; + this.player.videoManifest.captions.forEach((captions) => { + p.push( + new Promise((resolve) => { + if (/vtt/i.test(captions.format)) { + const fileUrl = utils.resolveResourcePath( + this.player, + captions.url, + ); + fetch(fileUrl, { credentials: "include" }) + // eslint-disable-next-line consistent-return + .then((fetchResult) => { + if (fetchResult.ok) { + return fetchResult.text(); + } + // reject(); + console.error(fetchResult); + }) + .then((text) => { + const parser = new WebVTTParser(text); + parser.captions.label = captions.text; + parser.captions.language = captions.lang; + result.push(parser.captions); + resolve(); + }); + } + }), + ); + }); + await Promise.all(p); + return result; + } +} diff --git a/src/plugins/icons/live-icon.png b/src/plugins/icons/live-icon.png new file mode 100644 index 0000000..cb49134 Binary files /dev/null and b/src/plugins/icons/live-icon.png differ diff --git a/src/plugins/icons/next-icon.svg b/src/plugins/icons/next-icon.svg new file mode 100644 index 0000000..1c588fe --- /dev/null +++ b/src/plugins/icons/next-icon.svg @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/src/plugins/icons/previous-icon.svg b/src/plugins/icons/previous-icon.svg new file mode 100644 index 0000000..d6f3299 --- /dev/null +++ b/src/plugins/icons/previous-icon.svg @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file