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
This commit is contained in:
parent
0b755f3b27
commit
999417328b
12 changed files with 975 additions and 74 deletions
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
|
|
18
client/src/standalone/player/definitions.ts
Normal file
18
client/src/standalone/player/definitions.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
|
||||
export interface EventHandler<T> {
|
||||
(ev : T) : void
|
||||
}
|
||||
|
||||
export type PlayerEventType =
|
||||
'pause' | 'play' |
|
||||
'playbackStatusUpdate' |
|
||||
'playbackStatusChange' |
|
||||
'resolutionUpdate'
|
||||
;
|
||||
|
||||
export interface PeerTubeResolution {
|
||||
id : any
|
||||
label : string
|
||||
src : string
|
||||
active : boolean
|
||||
}
|
48
client/src/standalone/player/events.ts
Normal file
48
client/src/standalone/player/events.ts
Normal file
|
@ -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<T>(name : string, event : T) {
|
||||
this.eventRegistrations[name].registrations.forEach(x => x(event))
|
||||
}
|
||||
|
||||
public addListener<T>(name : string, handler : EventHandler<T>) {
|
||||
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<T>(name : string, handler : EventHandler<T>) {
|
||||
if (!this.eventRegistrations[name])
|
||||
return false
|
||||
|
||||
this.eventRegistrations[name].registrations =
|
||||
this.eventRegistrations[name].registrations.filter(x => x === handler)
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
190
client/src/standalone/player/player.ts
Normal file
190
client/src/standalone/player/player.ts
Normal file
|
@ -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 <iframe>
|
||||
* within a web page.
|
||||
*/
|
||||
export class PeerTubePlayer {
|
||||
/**
|
||||
* Construct a new PeerTubePlayer for the given PeerTube embed iframe.
|
||||
* Optionally provide a `scope` to ensure that messages are not crossed
|
||||
* between multiple PeerTube embeds. The string passed here must match the
|
||||
* `scope=` query parameter on the embed URL.
|
||||
*
|
||||
* @param embedElement
|
||||
* @param scope
|
||||
*/
|
||||
constructor(
|
||||
private embedElement : HTMLIFrameElement,
|
||||
private scope? : string
|
||||
) {
|
||||
this.eventRegistrar.registerTypes(PASSTHROUGH_EVENTS)
|
||||
|
||||
this.constructChannel()
|
||||
this.prepareToBeReady()
|
||||
}
|
||||
|
||||
private eventRegistrar : EventRegistrar = new EventRegistrar()
|
||||
private channel : Channel.MessagingChannel
|
||||
private readyPromise : Promise<void>
|
||||
|
||||
/**
|
||||
* Destroy the player object and remove the associated player from the DOM.
|
||||
*/
|
||||
destroy() {
|
||||
this.embedElement.remove()
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen to an event emitted by this player.
|
||||
*
|
||||
* @param event One of the supported event types
|
||||
* @param handler A handler which will be passed an event object (or undefined if no event object is included)
|
||||
*/
|
||||
addEventListener(event : PlayerEventType, handler : EventHandler<any>): boolean {
|
||||
return this.eventRegistrar.addListener(event, handler)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an event listener previously added with addEventListener().
|
||||
*
|
||||
* @param event The name of the event previously listened to
|
||||
* @param handler
|
||||
*/
|
||||
removeEventListener(event : PlayerEventType, handler : EventHandler<any>): boolean {
|
||||
return this.eventRegistrar.removeListener(event, handler)
|
||||
}
|
||||
|
||||
/**
|
||||
* Promise resolves when the player is ready.
|
||||
*/
|
||||
get ready(): Promise<void> {
|
||||
return this.readyPromise
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell the embed to start/resume playback
|
||||
*/
|
||||
async play() {
|
||||
await this.sendMessage('play')
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell the embed to pause playback.
|
||||
*/
|
||||
async pause() {
|
||||
await this.sendMessage('pause')
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell the embed to change the audio volume
|
||||
* @param value A number from 0 to 1
|
||||
*/
|
||||
async setVolume(value : number) {
|
||||
await this.sendMessage('setVolume', value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current volume level in the embed.
|
||||
* @param value A number from 0 to 1
|
||||
*/
|
||||
async getVolume(): Promise<number> {
|
||||
return await this.sendMessage<void, number>('setVolume')
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell the embed to seek to a specific position (in seconds)
|
||||
* @param seconds
|
||||
*/
|
||||
async seek(seconds : number) {
|
||||
await this.sendMessage('seek', seconds)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell the embed to switch resolutions to the resolution identified
|
||||
* by the given ID.
|
||||
*
|
||||
* @param resolutionId The ID of the resolution as found with getResolutions()
|
||||
*/
|
||||
async setResolution(resolutionId : any) {
|
||||
await this.sendMessage('setResolution', resolutionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a list of the available resolutions. This may change later, listen to the
|
||||
* `resolutionUpdate` event with `addEventListener` in order to be updated as the available
|
||||
* resolutions change.
|
||||
*/
|
||||
async getResolutions(): Promise<PeerTubeResolution[]> {
|
||||
return await this.sendMessage<void, PeerTubeResolution[]>('getResolutions')
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a list of available playback rates.
|
||||
*/
|
||||
async getPlaybackRates() : Promise<number[]> {
|
||||
return await this.sendMessage<void, number[]>('getPlaybackRates')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current playback rate. Defaults to 1 (1x playback rate).
|
||||
*/
|
||||
async getPlaybackRate() : Promise<number> {
|
||||
return await this.sendMessage<void, number>('getPlaybackRate')
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the playback rate. Should be one of the options returned by getPlaybackRates().
|
||||
* Passing 0.5 means half speed, 1 means normal, 2 means 2x speed, etc.
|
||||
*
|
||||
* @param rate
|
||||
*/
|
||||
async setPlaybackRate(rate : number) {
|
||||
await this.sendMessage('setPlaybackRate', rate)
|
||||
}
|
||||
|
||||
private constructChannel() {
|
||||
this.channel = Channel.build({
|
||||
window: this.embedElement.contentWindow,
|
||||
origin: '*',
|
||||
scope: this.scope || 'peertube'
|
||||
})
|
||||
this.eventRegistrar.bindToChannel(this.channel)
|
||||
}
|
||||
|
||||
private prepareToBeReady() {
|
||||
let readyResolve, readyReject
|
||||
this.readyPromise = new Promise<void>((res, rej) => {
|
||||
readyResolve = res
|
||||
readyReject = rej
|
||||
})
|
||||
|
||||
this.channel.bind('ready', success => success ? readyResolve() : readyReject())
|
||||
this.channel.call({
|
||||
method: 'isReady',
|
||||
success: isReady => isReady ? readyResolve() : null
|
||||
})
|
||||
}
|
||||
|
||||
private sendMessage<TIn, TOut>(method : string, params? : TIn): Promise<TOut> {
|
||||
return new Promise<TOut>((resolve, reject) => {
|
||||
this.channel.call({
|
||||
method, params,
|
||||
success: result => resolve(result),
|
||||
error: error => reject(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// put it on the window as well as the export
|
||||
window['PeerTubePlayer'] = PeerTubePlayer
|
|
@ -17,100 +17,296 @@ import 'core-js/es6/set'
|
|||
// For google bot that uses Chrome 41 and does not understand fetch
|
||||
import 'whatwg-fetch'
|
||||
|
||||
import * as videojs from 'video.js'
|
||||
import * as vjs from 'video.js'
|
||||
import * as Channel from 'jschannel'
|
||||
|
||||
import { VideoDetails } from '../../../../shared'
|
||||
import { addContextMenu, getVideojsOptions, loadLocale } from '../../assets/player/peertube-player'
|
||||
import { PeerTubeResolution } from '../player/definitions';
|
||||
|
||||
function getVideoUrl (id: string) {
|
||||
return window.location.origin + '/api/v1/videos/' + id
|
||||
/**
|
||||
* Embed API exposes control of the embed player to the outside world via
|
||||
* JSChannels and window.postMessage
|
||||
*/
|
||||
class PeerTubeEmbedApi {
|
||||
constructor(
|
||||
private embed : PeerTubeEmbed
|
||||
) {
|
||||
}
|
||||
|
||||
private channel : Channel.MessagingChannel
|
||||
private isReady = false
|
||||
private resolutions : PeerTubeResolution[] = null
|
||||
|
||||
initialize() {
|
||||
this.constructChannel()
|
||||
this.setupStateTracking()
|
||||
|
||||
// We're ready!
|
||||
|
||||
this.notifyReady()
|
||||
}
|
||||
|
||||
private get element() {
|
||||
return this.embed.videoElement
|
||||
}
|
||||
|
||||
private constructChannel() {
|
||||
let channel = Channel.build({ window: window.parent, origin: '*', scope: this.embed.scope })
|
||||
|
||||
channel.bind('play', (txn, params) => this.embed.player.play())
|
||||
channel.bind('pause', (txn, params) => this.embed.player.pause())
|
||||
channel.bind('seek', (txn, time) => this.embed.player.currentTime(time))
|
||||
channel.bind('setVolume', (txn, value) => this.embed.player.volume(value))
|
||||
channel.bind('getVolume', (txn, value) => this.embed.player.volume())
|
||||
channel.bind('isReady', (txn, params) => this.isReady)
|
||||
channel.bind('setResolution', (txn, resolutionId) => this.setResolution(resolutionId))
|
||||
channel.bind('getResolutions', (txn, params) => this.resolutions)
|
||||
channel.bind('setPlaybackRate', (txn, playbackRate) => this.embed.player.playbackRate(playbackRate))
|
||||
channel.bind('getPlaybackRate', (txn, params) => this.embed.player.playbackRate())
|
||||
channel.bind('getPlaybackRates', (txn, params) => this.embed.playerOptions.playbackRates)
|
||||
|
||||
this.channel = channel
|
||||
}
|
||||
|
||||
private setResolution(resolutionId : number) {
|
||||
if (resolutionId === -1 && this.embed.player.peertube().isAutoResolutionForbidden())
|
||||
return
|
||||
|
||||
// Auto resolution
|
||||
if (resolutionId === -1) {
|
||||
this.embed.player.peertube().enableAutoResolution()
|
||||
return
|
||||
}
|
||||
|
||||
this.embed.player.peertube().disableAutoResolution()
|
||||
this.embed.player.peertube().updateResolution(resolutionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Let the host know that we're ready to go!
|
||||
*/
|
||||
private notifyReady() {
|
||||
this.isReady = true
|
||||
this.channel.notify({ method: 'ready', params: true })
|
||||
}
|
||||
|
||||
private setupStateTracking() {
|
||||
|
||||
let currentState : 'playing' | 'paused' | 'unstarted' = 'unstarted'
|
||||
|
||||
setInterval(() => {
|
||||
let position = this.element.currentTime
|
||||
let volume = this.element.volume
|
||||
|
||||
this.channel.notify({
|
||||
method: 'playbackStatusUpdate',
|
||||
params: {
|
||||
position,
|
||||
volume,
|
||||
playbackState: currentState,
|
||||
}
|
||||
})
|
||||
}, 500)
|
||||
|
||||
this.element.addEventListener('play', ev => {
|
||||
currentState = 'playing'
|
||||
this.channel.notify({ method: 'playbackStatusChange', params: 'playing' })
|
||||
})
|
||||
|
||||
this.element.addEventListener('pause', ev => {
|
||||
currentState = 'paused'
|
||||
this.channel.notify({ method: 'playbackStatusChange', params: 'paused' })
|
||||
})
|
||||
|
||||
// PeerTube specific capabilities
|
||||
|
||||
this.embed.player.peertube().on('autoResolutionUpdate', () => this.loadResolutions())
|
||||
this.embed.player.peertube().on('videoFileUpdate', () => this.loadResolutions())
|
||||
}
|
||||
|
||||
private loadResolutions() {
|
||||
let resolutions = []
|
||||
let currentResolutionId = this.embed.player.peertube().getCurrentResolutionId()
|
||||
|
||||
for (const videoFile of this.embed.player.peertube().videoFiles) {
|
||||
let label = videoFile.resolution.label
|
||||
if (videoFile.fps && videoFile.fps >= 50) {
|
||||
label += videoFile.fps
|
||||
}
|
||||
|
||||
resolutions.push({
|
||||
id: videoFile.resolution.id,
|
||||
label,
|
||||
src: videoFile.magnetUri,
|
||||
active: videoFile.resolution.id === currentResolutionId
|
||||
})
|
||||
}
|
||||
|
||||
this.resolutions = resolutions
|
||||
this.channel.notify({
|
||||
method: 'resolutionUpdate',
|
||||
params: this.resolutions
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function loadVideoInfo (videoId: string): Promise<Response> {
|
||||
return fetch(getVideoUrl(videoId))
|
||||
}
|
||||
class PeerTubeEmbed {
|
||||
constructor(
|
||||
private videoContainerId : string
|
||||
) {
|
||||
this.videoElement = document.getElementById(videoContainerId) as HTMLVideoElement
|
||||
}
|
||||
|
||||
function removeElement (element: HTMLElement) {
|
||||
element.parentElement.removeChild(element)
|
||||
}
|
||||
videoElement : HTMLVideoElement
|
||||
player : any
|
||||
playerOptions : any
|
||||
api : PeerTubeEmbedApi = null
|
||||
autoplay : boolean = false
|
||||
controls : boolean = true
|
||||
muted : boolean = false
|
||||
loop : boolean = false
|
||||
enableApi : boolean = false
|
||||
startTime : number = 0
|
||||
scope : string = 'peertube'
|
||||
|
||||
function displayError (videoElement: HTMLVideoElement, text: string) {
|
||||
// Remove video element
|
||||
removeElement(videoElement)
|
||||
|
||||
document.title = 'Sorry - ' + text
|
||||
|
||||
const errorBlock = document.getElementById('error-block')
|
||||
errorBlock.style.display = 'flex'
|
||||
|
||||
const errorText = document.getElementById('error-content')
|
||||
errorText.innerHTML = text
|
||||
}
|
||||
|
||||
function videoNotFound (videoElement: HTMLVideoElement) {
|
||||
const text = 'This video does not exist.'
|
||||
displayError(videoElement, text)
|
||||
}
|
||||
|
||||
function videoFetchError (videoElement: HTMLVideoElement) {
|
||||
const text = 'We cannot fetch the video. Please try again later.'
|
||||
displayError(videoElement, text)
|
||||
}
|
||||
|
||||
const urlParts = window.location.href.split('/')
|
||||
const lastPart = urlParts[urlParts.length - 1]
|
||||
const videoId = lastPart.indexOf('?') === -1 ? lastPart : lastPart.split('?')[0]
|
||||
|
||||
loadLocale(window.location.origin, videojs, navigator.language)
|
||||
.then(() => loadVideoInfo(videoId))
|
||||
.then(async response => {
|
||||
static async main() {
|
||||
const videoContainerId = 'video-container'
|
||||
const videoElement = document.getElementById(videoContainerId) as HTMLVideoElement
|
||||
const embed = new PeerTubeEmbed(videoContainerId)
|
||||
await embed.init()
|
||||
}
|
||||
|
||||
getVideoUrl (id: string) {
|
||||
return window.location.origin + '/api/v1/videos/' + id
|
||||
}
|
||||
|
||||
loadVideoInfo (videoId: string): Promise<Response> {
|
||||
return fetch(this.getVideoUrl(videoId))
|
||||
}
|
||||
|
||||
removeElement (element: HTMLElement) {
|
||||
element.parentElement.removeChild(element)
|
||||
}
|
||||
|
||||
displayError (videoElement: HTMLVideoElement, text: string) {
|
||||
// Remove video element
|
||||
this.removeElement(videoElement)
|
||||
|
||||
document.title = 'Sorry - ' + text
|
||||
|
||||
const errorBlock = document.getElementById('error-block')
|
||||
errorBlock.style.display = 'flex'
|
||||
|
||||
const errorText = document.getElementById('error-content')
|
||||
errorText.innerHTML = text
|
||||
}
|
||||
|
||||
videoNotFound (videoElement: HTMLVideoElement) {
|
||||
const text = 'This video does not exist.'
|
||||
this.displayError(videoElement, text)
|
||||
}
|
||||
|
||||
videoFetchError (videoElement: HTMLVideoElement) {
|
||||
const text = 'We cannot fetch the video. Please try again later.'
|
||||
this.displayError(videoElement, text)
|
||||
}
|
||||
|
||||
getParamToggle (params: URLSearchParams, name: string, defaultValue: boolean) {
|
||||
return params.has(name) ? (params.get(name) === '1' || params.get(name) === 'true') : defaultValue
|
||||
}
|
||||
|
||||
getParamString (params: URLSearchParams, name: string, defaultValue: string) {
|
||||
return params.has(name) ? params.get(name) : defaultValue
|
||||
}
|
||||
|
||||
private initializeApi() {
|
||||
if (!this.enableApi)
|
||||
return
|
||||
|
||||
this.api = new PeerTubeEmbedApi(this)
|
||||
this.api.initialize()
|
||||
}
|
||||
|
||||
async init() {
|
||||
try {
|
||||
await this.initCore()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
private loadParams() {
|
||||
try {
|
||||
let params = new URL(window.location.toString()).searchParams
|
||||
|
||||
this.autoplay = this.getParamToggle(params, 'autoplay', this.autoplay)
|
||||
this.controls = this.getParamToggle(params, 'controls', this.controls)
|
||||
this.muted = this.getParamToggle(params, 'muted', this.muted)
|
||||
this.loop = this.getParamToggle(params, 'loop', this.loop)
|
||||
this.enableApi = this.getParamToggle(params, 'api', this.enableApi)
|
||||
this.scope = this.getParamString(params, 'scope', this.scope)
|
||||
|
||||
const startTimeParamString = params.get('start')
|
||||
const startTimeParamNumber = parseInt(startTimeParamString, 10)
|
||||
if (isNaN(startTimeParamNumber) === false)
|
||||
this.startTime = startTimeParamNumber
|
||||
} catch (err) {
|
||||
console.error('Cannot get params from URL.', err)
|
||||
}
|
||||
}
|
||||
|
||||
private async initCore() {
|
||||
const urlParts = window.location.href.split('/')
|
||||
const lastPart = urlParts[urlParts.length - 1]
|
||||
const videoId = lastPart.indexOf('?') === -1 ? lastPart : lastPart.split('?')[0]
|
||||
|
||||
await loadLocale(window.location.origin, vjs, navigator.language)
|
||||
let response = await this.loadVideoInfo(videoId)
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) return videoNotFound(videoElement)
|
||||
if (response.status === 404)
|
||||
return this.videoNotFound(this.videoElement)
|
||||
|
||||
return videoFetchError(videoElement)
|
||||
return this.videoFetchError(this.videoElement)
|
||||
}
|
||||
|
||||
const videoInfo: VideoDetails = await response.json()
|
||||
|
||||
let autoplay = false
|
||||
let startTime = 0
|
||||
|
||||
try {
|
||||
let params = new URL(window.location.toString()).searchParams
|
||||
autoplay = params.has('autoplay') && (params.get('autoplay') === '1' || params.get('autoplay') === 'true')
|
||||
|
||||
const startTimeParamString = params.get('start')
|
||||
const startTimeParamNumber = parseInt(startTimeParamString, 10)
|
||||
if (isNaN(startTimeParamNumber) === false) startTime = startTimeParamNumber
|
||||
} catch (err) {
|
||||
console.error('Cannot get params from URL.', err)
|
||||
}
|
||||
this.loadParams()
|
||||
|
||||
const videojsOptions = getVideojsOptions({
|
||||
autoplay,
|
||||
autoplay: this.autoplay,
|
||||
controls: this.controls,
|
||||
muted: this.muted,
|
||||
loop: this.loop,
|
||||
startTime : this.startTime,
|
||||
|
||||
inactivityTimeout: 1500,
|
||||
videoViewUrl: getVideoUrl(videoId) + '/views',
|
||||
playerElement: videoElement,
|
||||
videoViewUrl: this.getVideoUrl(videoId) + '/views',
|
||||
playerElement: this.videoElement,
|
||||
videoFiles: videoInfo.files,
|
||||
videoDuration: videoInfo.duration,
|
||||
enableHotkeys: true,
|
||||
peertubeLink: true,
|
||||
poster: window.location.origin + videoInfo.previewPath,
|
||||
startTime,
|
||||
theaterMode: false
|
||||
})
|
||||
videojs(videoContainerId, videojsOptions, function () {
|
||||
const player = this
|
||||
|
||||
player.dock({
|
||||
title: videoInfo.name,
|
||||
description: player.localize('Uses P2P, others may know your IP is downloading this video.')
|
||||
})
|
||||
this.playerOptions = videojsOptions
|
||||
this.player = vjs(this.videoContainerId, videojsOptions, () => {
|
||||
|
||||
addContextMenu(player, window.location.origin + videoInfo.embedPath)
|
||||
window['videojsPlayer'] = this.player
|
||||
|
||||
if (this.controls) {
|
||||
(this.player as any).dock({
|
||||
title: videoInfo.name,
|
||||
description: this.player.localize('Uses P2P, others may know your IP is downloading this video.')
|
||||
})
|
||||
}
|
||||
addContextMenu(this.player, window.location.origin + videoInfo.embedPath)
|
||||
this.initializeApi()
|
||||
})
|
||||
})
|
||||
.catch(err => console.error(err))
|
||||
}
|
||||
}
|
||||
|
||||
PeerTubeEmbed.main()
|
||||
|
|
51
client/src/standalone/videos/test-embed.html
Normal file
51
client/src/standalone/videos/test-embed.html
Normal file
|
@ -0,0 +1,51 @@
|
|||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="logo">
|
||||
<div class="icon">
|
||||
<img src="../../assets/images/logo.svg">
|
||||
</div>
|
||||
<div>
|
||||
PeerTube
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="spacer"></div>
|
||||
<h1>Embed Playground</h1>
|
||||
</header>
|
||||
<main>
|
||||
<aside>
|
||||
<div id="host"></div>
|
||||
</aside>
|
||||
<div id="controls">
|
||||
<div>
|
||||
<button onclick="player.play()">Play</button>
|
||||
<button onclick="player.pause()">Pause</button>
|
||||
<button onclick="player.seek(parseInt(prompt('Enter position to seek to (in seconds)')))">Seek</button>
|
||||
</div>
|
||||
<br/>
|
||||
|
||||
<div id="options">
|
||||
<fieldset>
|
||||
<legend>Resolutions:</legend>
|
||||
<div id="resolution-list"></div>
|
||||
<br/>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Rates:</legend>
|
||||
<div id="rate-list"></div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- iframes are used dynamically -->
|
||||
<iframe hidden></iframe>
|
||||
<a hidden></a>
|
||||
</body>
|
||||
</html>
|
149
client/src/standalone/videos/test-embed.scss
Normal file
149
client/src/standalone/videos/test-embed.scss
Normal file
|
@ -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);
|
||||
}
|
||||
}
|
98
client/src/standalone/videos/test-embed.ts
Normal file
98
client/src/standalone/videos/test-embed.ts
Normal file
|
@ -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(<PlayerEventType>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))
|
||||
})
|
|
@ -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']
|
||||
}),
|
||||
|
||||
/**
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
122
support/doc/api/embeds.md
Normal file
122
support/doc/api/embeds.md
Normal file
|
@ -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 `<iframe>`, one can use the PeerTube Embed API to control it by first including the library. You can include it via Yarn with:
|
||||
|
||||
```
|
||||
yarn add @peertube/embed-api
|
||||
```
|
||||
|
||||
Now just use the `PeerTubePlayer` class exported by the module:
|
||||
|
||||
```typescript
|
||||
import { PeerTubePlayer } from '@peertube/embed-api'
|
||||
|
||||
let player = new PeerTubePlayer(document.querySelector('iframe'))
|
||||
await player.ready // wait for the player to be ready
|
||||
|
||||
// now you can use it!
|
||||
player.play()
|
||||
player.seek(32)
|
||||
player.stop()
|
||||
```
|
||||
|
||||
# Methods
|
||||
|
||||
## `play() : Promise<void>`
|
||||
|
||||
Starts playback, or resumes playback if it is paused.
|
||||
|
||||
## `pause() : Promise<void>`
|
||||
|
||||
Pauses playback.
|
||||
|
||||
## `seek(positionInSeconds : number)`
|
||||
|
||||
Seek to the given position, as specified in seconds into the video.
|
||||
|
||||
## `addEventListener(eventName : string, handler : Function)`
|
||||
|
||||
Add a listener for a specific event. See below for the available events.
|
||||
|
||||
## `getResolutions() : Promise<PeerTubeResolution[]>`
|
||||
|
||||
Get the available resolutions. A `PeerTubeResolution` looks like:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 3,
|
||||
"label": "720p",
|
||||
"src": "//src-url-here",
|
||||
"active": true
|
||||
}
|
||||
```
|
||||
|
||||
`active` is true if the resolution is the currently selected resolution.
|
||||
|
||||
## `setResolution(resolutionId : number): Promise<void>`
|
||||
|
||||
Change the current resolution. Pass `-1` for automatic resolution (when available).
|
||||
Otherwise, `resolutionId` should be the ID of an object returned by `getResolutions()`
|
||||
|
||||
## `getPlaybackRates() : Promise<number[]>`
|
||||
|
||||
Get the available playback rates, where `1` represents normal speed, `0.5` is half speed, `2` is double speed, etc.
|
||||
|
||||
## `getPlaybackRates() : Promise<number>`
|
||||
|
||||
Get the current playback rate. See `getPlaybackRates()` for more information.
|
||||
|
||||
## `setPlaybackRate(rate : number) : Promise<void>`
|
||||
|
||||
Set the current playback rate. The passed rate should be a value as returned by `getPlaybackRates()`.
|
||||
|
||||
## `setVolume(factor : number) : Promise<void>`
|
||||
|
||||
Set the playback volume. Value should be between `0` and `1`.
|
||||
|
||||
## `getVolume(): Promise<number>`
|
||||
|
||||
Get the playback volume. Returns a value between `0` and `1`.
|
||||
# Events
|
||||
|
||||
You can subscribe to events by using `addEventListener()`. See above for details.
|
||||
|
||||
## Event `play`
|
||||
|
||||
Fired when playback begins or is resumed after pausing.
|
||||
|
||||
## Event `pause`
|
||||
|
||||
Fired when playback is paused.
|
||||
|
||||
## Event `playbackStatusUpdate`
|
||||
|
||||
Fired every half second to provide the current status of playback. The parameter of the callback will resemble:
|
||||
|
||||
```json
|
||||
{
|
||||
"position": 22.3,
|
||||
"volume": 0.9,
|
||||
"playbackState": "playing"
|
||||
}
|
||||
```
|
||||
|
||||
The `volume` field contains the volume from `0` (silent) to `1` (full volume). The `playbackState` can be `playing` or `paused`. More states may be added later.
|
||||
|
||||
## Event `playbackStatusChange`
|
||||
|
||||
Fired when playback transitions between states, such as `pausing` and `playing`. More states may be added later.
|
||||
|
||||
## Event `resolutionUpdate`
|
||||
|
||||
Fired when the available resolutions have changed, or when the currently selected resolution has changed. Listener should call `getResolutions()` to get the updated information.
|
Loading…
Reference in a new issue