From 999417328bde0e60cd59318fc1c18672356254ce Mon Sep 17 00:00:00 2001 From: William Lahti Date: Tue, 10 Jul 2018 08:47:56 -0700 Subject: [PATCH] Ability to programmatically control embeds (#776) * first stab at jschannel based player api * semicolon purge * more method-level docs; consolidate definitions * missing definitions * better match peertube's class conventions * styling for embed tester * basic docs * add `getVolume` * document the test-embed feature --- client/package.json | 3 + client/src/assets/player/peertube-player.ts | 9 +- client/src/standalone/player/definitions.ts | 18 + client/src/standalone/player/events.ts | 48 +++ client/src/standalone/player/player.ts | 190 +++++++++++ client/src/standalone/videos/embed.ts | 332 +++++++++++++++---- client/src/standalone/videos/test-embed.html | 51 +++ client/src/standalone/videos/test-embed.scss | 149 +++++++++ client/src/standalone/videos/test-embed.ts | 98 ++++++ client/webpack/webpack.video-embed.js | 24 +- server/controllers/client.ts | 5 + support/doc/api/embeds.md | 122 +++++++ 12 files changed, 975 insertions(+), 74 deletions(-) create mode 100644 client/src/standalone/player/definitions.ts create mode 100644 client/src/standalone/player/events.ts create mode 100644 client/src/standalone/player/player.ts create mode 100644 client/src/standalone/videos/test-embed.html create mode 100644 client/src/standalone/videos/test-embed.scss create mode 100644 client/src/standalone/videos/test-embed.ts create mode 100644 support/doc/api/embeds.md diff --git a/client/package.json b/client/package.json index 1264891ec..617c7cb49 100644 --- a/client/package.json +++ b/client/package.json @@ -52,6 +52,7 @@ "@types/core-js": "^0.9.28", "@types/jasmine": "^2.8.7", "@types/jasminewd2": "^2.0.3", + "@types/jschannel": "^1.0.0", "@types/lodash-es": "^4.17.0", "@types/markdown-it": "^0.0.4", "@types/node": "^9.3.0", @@ -70,9 +71,11 @@ "extract-text-webpack-plugin": "4.0.0-beta.0", "file-loader": "^1.1.5", "html-webpack-plugin": "^3.2.0", + "html-loader": "^0.5.5", "https-browserify": "^1.0.0", "jasmine-core": "^3.1.0", "jasmine-spec-reporter": "^4.2.1", + "jschannel": "^1.0.2", "karma": "^2.0.2", "karma-chrome-launcher": "^2.2.0", "karma-coverage-istanbul-reporter": "^2.0.1", diff --git a/client/src/assets/player/peertube-player.ts b/client/src/assets/player/peertube-player.ts index 7e339990c..baae740fe 100644 --- a/client/src/assets/player/peertube-player.ts +++ b/client/src/assets/player/peertube-player.ts @@ -29,10 +29,15 @@ function getVideojsOptions (options: { peertubeLink: boolean, poster: string, startTime: number - theaterMode: boolean + theaterMode: boolean, + controls?: boolean, + muted?: boolean, + loop?: boolean }) { const videojsOptions = { - controls: true, + controls: options.controls !== undefined ? options.controls : true, + muted: options.controls !== undefined ? options.muted : false, + loop: options.loop !== undefined ? options.loop : false, poster: options.poster, autoplay: false, inactivityTimeout: options.inactivityTimeout, diff --git a/client/src/standalone/player/definitions.ts b/client/src/standalone/player/definitions.ts new file mode 100644 index 000000000..6920672a7 --- /dev/null +++ b/client/src/standalone/player/definitions.ts @@ -0,0 +1,18 @@ + +export interface EventHandler { + (ev : T) : void +} + +export type PlayerEventType = + 'pause' | 'play' | + 'playbackStatusUpdate' | + 'playbackStatusChange' | + 'resolutionUpdate' +; + +export interface PeerTubeResolution { + id : any + label : string + src : string + active : boolean +} \ No newline at end of file diff --git a/client/src/standalone/player/events.ts b/client/src/standalone/player/events.ts new file mode 100644 index 000000000..c01328352 --- /dev/null +++ b/client/src/standalone/player/events.ts @@ -0,0 +1,48 @@ +import { EventHandler } from "./definitions" + +interface PlayerEventRegistrar { + registrations : Function[] +} + +interface PlayerEventRegistrationMap { + [name : string] : PlayerEventRegistrar +} + +export class EventRegistrar { + + private eventRegistrations : PlayerEventRegistrationMap = {} + + public bindToChannel(channel : Channel.MessagingChannel) { + for (let name of Object.keys(this.eventRegistrations)) + channel.bind(name, (txn, params) => this.fire(name, params)) + } + + public registerTypes(names : string[]) { + for (let name of names) + this.eventRegistrations[name] = { registrations: [] } + } + + public fire(name : string, event : T) { + this.eventRegistrations[name].registrations.forEach(x => x(event)) + } + + public addListener(name : string, handler : EventHandler) { + if (!this.eventRegistrations[name]) { + console.warn(`PeerTube: addEventListener(): The event '${name}' is not supported`) + return false + } + + this.eventRegistrations[name].registrations.push(handler) + return true + } + + public removeListener(name : string, handler : EventHandler) { + if (!this.eventRegistrations[name]) + return false + + this.eventRegistrations[name].registrations = + this.eventRegistrations[name].registrations.filter(x => x === handler) + + return true + } +} diff --git a/client/src/standalone/player/player.ts b/client/src/standalone/player/player.ts new file mode 100644 index 000000000..9fc648d25 --- /dev/null +++ b/client/src/standalone/player/player.ts @@ -0,0 +1,190 @@ +import * as Channel from 'jschannel' +import { EventRegistrar } from './events' +import { EventHandler, PlayerEventType, PeerTubeResolution } from './definitions' + +const PASSTHROUGH_EVENTS = [ + 'pause', 'play', + 'playbackStatusUpdate', + 'playbackStatusChange', + 'resolutionUpdate' +] + +/** + * Allows for programmatic control of a PeerTube embed running in an + + + diff --git a/client/src/standalone/videos/test-embed.scss b/client/src/standalone/videos/test-embed.scss new file mode 100644 index 000000000..df3d69f21 --- /dev/null +++ b/client/src/standalone/videos/test-embed.scss @@ -0,0 +1,149 @@ + +* { + font-family: sans-serif; +} + +html { + width: 100%; + overflow-x: hidden; + overflow-y: auto; +} + +body { + margin: 0; + padding: 0; +} + +iframe { + border: none; + border-radius: 8px; + min-width: 200px; + width: 100%; + height: 100%; + pointer-events: none; +} + +aside { + width: 33vw; + margin: 0 .5em .5em 0; + height: calc(33vw * 0.5625); +} + +.logo { + font-size: 150%; + height: 100%; + font-weight: bold; + display: flex; + flex-direction: row; + align-items: center; + margin-right: 0.5em; + + .icon { + height: 100%; + padding: 0 18px 0 32px; + background: white; + display: flex; + align-items: center; + margin-right: 0.5em; + } +} + +main { + padding: 0 1em; + display: flex; + align-items: flex-start; +} + +.spacer { + flex: 1; +} + +header { + width: 100%; + height: 3.2em; + background-color: #F1680D; + color: white; + //background-image: url(../../assets/images/backdrop/network-o.png); + display: flex; + flex-direction: row; + align-items: center; + margin-bottom: 1em; + box-shadow: 1px 0px 10px rgba(0,0,0,0.6); + background-size: 50%; + background-position: top left; + padding-right: 1em; + + h1 { + margin: 0; + padding: 0 1em 0 0; + font-size: inherit; + font-weight: 100; + position: relative; + top: 2px; + } +} + +#options { + display: flex; + flex-wrap: wrap; + + & > * { + flex-grow: 0; + } +} + +fieldset { + border: none; + min-width: 8em; + legend { + border-bottom: 1px solid #ccc; + width: 100%; + } +} + +button { + background: #F1680D; + color: white; + font-weight: bold; + border-radius: 5px; + margin: 0; + padding: 1em 1.25em; + border: none; +} + +a { + text-decoration: none; + + &:hover { + text-decoration: underline; + } + + &, &:hover, &:focus, &:visited, &:active { + color: #F44336; + } +} + +@media (max-width: 900px) { + aside { + width: 50vw; + height: calc(50vw * 0.5625); + } +} + +@media (max-width: 600px) { + main { + flex-direction: column; + } + + aside { + width: calc(100vw - 2em); + height: calc(56.25vw - 2em * 0.5625); + } +} + +@media (min-width: 1800px) { + aside { + width: 50vw; + height: calc(50vw * 0.5625); + } +} \ No newline at end of file diff --git a/client/src/standalone/videos/test-embed.ts b/client/src/standalone/videos/test-embed.ts new file mode 100644 index 000000000..721514488 --- /dev/null +++ b/client/src/standalone/videos/test-embed.ts @@ -0,0 +1,98 @@ +import './test-embed.scss' +import { PeerTubePlayer } from '../player/player'; +import { PlayerEventType } from '../player/definitions'; + +window.addEventListener('load', async () => { + + const urlParts = window.location.href.split('/') + const lastPart = urlParts[urlParts.length - 1] + const videoId = lastPart.indexOf('?') === -1 ? lastPart : lastPart.split('?')[0] + + let iframe = document.createElement('iframe') + iframe.src = `/videos/embed/${videoId}?autoplay=1&controls=0&api=1` + let mainElement = document.querySelector('#host') + mainElement.appendChild(iframe); + + console.log(`Document finished loading.`) + let player = new PeerTubePlayer(document.querySelector('iframe')) + + window['player'] = player + + console.log(`Awaiting player ready...`) + await player.ready + console.log(`Player is ready.`) + + let monitoredEvents = [ + 'pause', 'play', + 'playbackStatusUpdate', + 'playbackStatusChange' + ] + + monitoredEvents.forEach(e => { + player.addEventListener(e, () => console.log(`PLAYER: event '${e}' received`)) + console.log(`PLAYER: now listening for event '${e}'`) + }) + + let playbackRates = [] + let activeRate = 1 + let currentRate = await player.getPlaybackRate() + + let updateRates = async () => { + + let rateListEl = document.querySelector('#rate-list') + rateListEl.innerHTML = '' + + playbackRates.forEach(rate => { + if (currentRate == rate) { + let itemEl = document.createElement('strong') + itemEl.innerText = `${rate} (active)` + itemEl.style.display = 'block' + rateListEl.appendChild(itemEl) + } else { + let itemEl = document.createElement('a') + itemEl.href = 'javascript:;' + itemEl.innerText = rate + itemEl.addEventListener('click', () => { + player.setPlaybackRate(rate) + currentRate = rate + updateRates() + }) + itemEl.style.display = 'block' + rateListEl.appendChild(itemEl) + } + }) + } + + player.getPlaybackRates().then(rates => { + playbackRates = rates + updateRates() + }) + + let updateResolutions = resolutions => { + let resolutionListEl = document.querySelector('#resolution-list') + resolutionListEl.innerHTML = '' + + resolutions.forEach(resolution => { + if (resolution.active) { + let itemEl = document.createElement('strong') + itemEl.innerText = `${resolution.label} (active)` + itemEl.style.display = 'block' + resolutionListEl.appendChild(itemEl) + } else { + let itemEl = document.createElement('a') + itemEl.href = 'javascript:;' + itemEl.innerText = resolution.label + itemEl.addEventListener('click', () => { + player.setResolution(resolution.id) + }) + itemEl.style.display = 'block' + resolutionListEl.appendChild(itemEl) + } + }) + } + + player.getResolutions().then( + resolutions => updateResolutions(resolutions)) + player.addEventListener('resolutionUpdate', + resolutions => updateResolutions(resolutions)) +}) \ No newline at end of file diff --git a/client/webpack/webpack.video-embed.js b/client/webpack/webpack.video-embed.js index 403a65930..979da0dff 100644 --- a/client/webpack/webpack.video-embed.js +++ b/client/webpack/webpack.video-embed.js @@ -14,7 +14,9 @@ module.exports = function () { const configuration = { entry: { - 'video-embed': './src/standalone/videos/embed.ts' + 'video-embed': './src/standalone/videos/embed.ts', + 'player': './src/standalone/player/player.ts', + 'test-embed': './src/standalone/videos/test-embed.ts' }, resolve: { @@ -89,7 +91,8 @@ module.exports = function () { use: 'raw-loader', exclude: [ helpers.root('src/index.html'), - helpers.root('src/standalone/videos/embed.html') + helpers.root('src/standalone/videos/embed.html'), + helpers.root('src/standalone/videos/test-embed.html') ] }, @@ -110,7 +113,10 @@ module.exports = function () { }), new PurifyCSSPlugin({ - paths: [ helpers.root('src/standalone/videos/embed.ts') ], + paths: [ + helpers.root('src/standalone/videos/embed.ts'), + helpers.root('src/standalone/videos/test-embed.html') + ], purifyOptions: { minify: true, whitelist: [ '*vjs*', '*video-js*' ] @@ -124,7 +130,17 @@ module.exports = function () { filename: 'embed.html', title: 'PeerTube', chunksSortMode: 'dependency', - inject: 'body' + inject: 'body', + chunks: ['video-embed'] + }), + + new HtmlWebpackPlugin({ + template: '!!html-loader!src/standalone/videos/test-embed.html', + filename: 'test-embed.html', + title: 'PeerTube', + chunksSortMode: 'dependency', + inject: 'body', + chunks: ['test-embed'] }), /** diff --git a/server/controllers/client.ts b/server/controllers/client.ts index dfffe5487..5413f61e8 100644 --- a/server/controllers/client.ts +++ b/server/controllers/client.ts @@ -21,6 +21,7 @@ const clientsRouter = express.Router() const distPath = join(root(), 'client', 'dist') const assetsImagesPath = join(root(), 'client', 'dist', 'assets', 'images') const embedPath = join(distPath, 'standalone', 'videos', 'embed.html') +const testEmbedPath = join(distPath, 'standalone', 'videos', 'test-embed.html') // Special route that add OpenGraph and oEmbed tags // Do not use a template engine for a so little thing @@ -32,6 +33,10 @@ clientsRouter.use('' + '/videos/embed', (req: express.Request, res: express.Response, next: express.NextFunction) => { res.sendFile(embedPath) }) +clientsRouter.use('' + + '/videos/test-embed', (req: express.Request, res: express.Response, next: express.NextFunction) => { + res.sendFile(testEmbedPath) +}) // Static HTML/CSS/JS client files diff --git a/support/doc/api/embeds.md b/support/doc/api/embeds.md new file mode 100644 index 000000000..3a35a539c --- /dev/null +++ b/support/doc/api/embeds.md @@ -0,0 +1,122 @@ +# PeerTube Embed API + +PeerTube lets you embed videos and programmatically control their playback. This documentation covers how to interact with the PeerTube Embed API. + +## Playground + +Any PeerTube embed URL (ie `https://my-instance.example.com/videos/embed/52a10666-3a18-4e73-93da-e8d3c12c305a`) can be viewed as an embedding playground which +allows you to test various aspects of PeerTube embeds. Simply replace `/embed` with `/test-embed` and visit the URL in a browser. +For instance, the playground URL for the above embed URL is `https://my-instance.example.com/videos/test-embed/52a10666-3a18-4e73-93da-e8d3c12c305a`. + +## Quick Start + +Given an existing PeerTube embed `