Ability to programmatically control embeds (#776)
authorWilliam Lahti <wilahti@gmail.com>
Tue, 10 Jul 2018 15:47:56 +0000 (08:47 -0700)
committerChocobozzz <me@florianbigard.com>
Tue, 10 Jul 2018 15:47:56 +0000 (17:47 +0200)
* 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

12 files changed:
client/package.json
client/src/assets/player/peertube-player.ts
client/src/standalone/player/definitions.ts [new file with mode: 0644]
client/src/standalone/player/events.ts [new file with mode: 0644]
client/src/standalone/player/player.ts [new file with mode: 0644]
client/src/standalone/videos/embed.ts
client/src/standalone/videos/test-embed.html [new file with mode: 0644]
client/src/standalone/videos/test-embed.scss [new file with mode: 0644]
client/src/standalone/videos/test-embed.ts [new file with mode: 0644]
client/webpack/webpack.video-embed.js
server/controllers/client.ts
support/doc/api/embeds.md [new file with mode: 0644]

index 1264891ec2c048371c619f64b357879c33f17c48..617c7cb49fdc978ba2c9d084d181bb2365184573 100644 (file)
@@ -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",
     "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",
index 7e339990ccec66675843e225319dda5fd87a1a3b..baae740fe64846e6b8f2a03d842eee0155473fd5 100644 (file)
@@ -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 (file)
index 0000000..6920672
--- /dev/null
@@ -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
+}
\ 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 (file)
index 0000000..c013283
--- /dev/null
@@ -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
+    }
+}
diff --git a/client/src/standalone/player/player.ts b/client/src/standalone/player/player.ts
new file mode 100644 (file)
index 0000000..9fc648d
--- /dev/null
@@ -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
\ No newline at end of file
index d8db2a1197b1544d06cd8199549b70fe85b226e5..e9baf64d0270576dab01c31cc321b4bffe0a4a0c 100644 (file)
@@ -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
+  ) {
+  }
 
-function loadVideoInfo (videoId: string): Promise<Response> {
-  return fetch(getVideoUrl(videoId))
-}
+  private channel : Channel.MessagingChannel
+  private isReady = false
+  private resolutions : PeerTubeResolution[] = null
 
-function removeElement (element: HTMLElement) {
-  element.parentElement.removeChild(element)
-}
+  initialize() {
+    this.constructChannel()
+    this.setupStateTracking()
 
-function displayError (videoElement: HTMLVideoElement, text: string) {
-  // Remove video element
-  removeElement(videoElement)
+    // We're ready!
 
-  document.title = 'Sorry - ' + text
+    this.notifyReady()
+  }
+  
+  private get element() {
+    return this.embed.videoElement
+  }
 
-  const errorBlock = document.getElementById('error-block')
-  errorBlock.style.display = 'flex'
+  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)
 
-  const errorText = document.getElementById('error-content')
-  errorText.innerHTML = text
-}
+    this.channel = channel
+  }
 
-function videoNotFound (videoElement: HTMLVideoElement) {
-  const text = 'This video does not exist.'
-  displayError(videoElement, text)
-}
+  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
+      }
 
-function videoFetchError (videoElement: HTMLVideoElement) {
-  const text = 'We cannot fetch the video. Please try again later.'
-  displayError(videoElement, text)
+      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
+    })
+  }
 }
 
-const urlParts = window.location.href.split('/')
-const lastPart = urlParts[urlParts.length - 1]
-const videoId = lastPart.indexOf('?') === -1 ? lastPart : lastPart.split('?')[0]
+class PeerTubeEmbed {
+  constructor(
+    private videoContainerId : string
+  ) {
+    this.videoElement = document.getElementById(videoContainerId) as HTMLVideoElement
+  }
 
-loadLocale(window.location.origin, videojs, navigator.language)
-  .then(() => loadVideoInfo(videoId))
-  .then(async response => {
+  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'
+
+  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
+  }
 
-    if (!response.ok) {
-      if (response.status === 404) return videoNotFound(videoElement)
+  loadVideoInfo (videoId: string): Promise<Response> {
+    return fetch(this.getVideoUrl(videoId))
+  }
 
-      return videoFetchError(videoElement)
-    }
+  removeElement (element: HTMLElement) {
+    element.parentElement.removeChild(element)
+  }
 
-    const videoInfo: VideoDetails = await response.json()
+  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
+  }
 
-    let autoplay = false
-    let startTime = 0
+  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
-      autoplay = params.has('autoplay') && (params.get('autoplay') === '1' || params.get('autoplay') === 'true')
+
+      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) startTime = startTimeParamNumber
+      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 this.videoNotFound(this.videoElement)
+
+      return this.videoFetchError(this.videoElement)
+    }
+
+    const videoInfo: VideoDetails = await response.json()
+
+    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()
diff --git a/client/src/standalone/videos/test-embed.html b/client/src/standalone/videos/test-embed.html
new file mode 100644 (file)
index 0000000..a60ba28
--- /dev/null
@@ -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>
diff --git a/client/src/standalone/videos/test-embed.scss b/client/src/standalone/videos/test-embed.scss
new file mode 100644 (file)
index 0000000..df3d69f
--- /dev/null
@@ -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 (file)
index 0000000..7215144
--- /dev/null
@@ -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))
+})
\ No newline at end of file
index 403a65930b56a13daed5fe964eab5c65a8316fcd..979da0dffee0c3f4a90b417d86b6862dc0249947 100644 (file)
@@ -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']
       }),
 
       /**
index dfffe5487d024b9781f3f6e115ff835a7af8545d..5413f61e8ba36acb33a23d2ea226024a283fde63 100644 (file)
@@ -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 (file)
index 0000000..3a35a53
--- /dev/null
@@ -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.
\ No newline at end of file