Skip to content

Instantly share code, notes, and snippets.

@leastbad
Last active November 11, 2022 17:27
Show Gist options
  • Star 6 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save leastbad/33a97dc724d7bf21b7f6ff1992e5aab9 to your computer and use it in GitHub Desktop.
Save leastbad/33a97dc724d7bf21b7f6ff1992e5aab9 to your computer and use it in GitHub Desktop.
stimulus-youtube preview

My goal with this was to wrap the terrible YouTube Embed API in a Stimulus controller that would allow me to access the underlying API while providing some convenience methods. One key outcome is that the controller emits youtube events which contain the current position in the video. This means that other code can now respond to the position you are at in the video.

<div data-controller="youtube" data-youtube-code-value="Lo_1pyQ7xvc">
  <button data-action="youtube#play">Play</button>
  <button data-action="youtube#pause">Pause</button>
  <button data-action="youtube#stop">Stop</button>
  <br>
  <div data-youtube-target="frame"></div>
</div>

The buttons and br are not neccessary, they just help show off the functionality.

When the controller connects, data-duration, data-time and data-state attributes will appear on the controller element. This is really handy if you're using this element to initiate a Reflex, as all of the attributes will be automatically sent to the server as part of the Reflex.

If you want to send the YouTube instance commands, you'll need a DOM element reference to the container div. I'm going to assume that you have that, and that it's available to you as $0.

$0.addEventListener('youtube', e => console.log(e.detail.time))
$0.youtube.player.mute()

The controller element emits one non-bubbling youtube event every second that the video is playing.

The controller element has a youtube accessor which is a reference to the internal state of the Stimulus controller.

I did not want to attempt to exhaustively replicate the functionality of every getter and setter offered by the YouTube API. If you want to mute() or unMute() you can access the underlying YouTube API via the player accessor.

You must specify a data-youtube-code-value attribute. data-youtube-width-value and data-youtube-height-value are optional.

Methods

  • play()
  • pause()
  • stop()
  • seek(seconds)

Getters

  • player // reference to wrapped YouTube API instance
  • time // current position in the video, as an integer representing seconds
  • duration // number of seconds total in the video
  • state // see states below
  • loaded // percentage of video that has been downloaded / buffered, expressed as a float from 0 to 1

If you want to convert loaded to a percentage, multiply it by 100 and parseInt().

States

-1 (unstarted) 0 (ended) 1 (playing) 2 (paused) 3 (buffering) 5 (video cued)

import { Controller } from '@hotwired/stimulus'
import YouTubePlayer from 'youtube-player'
export default class extends Controller {
static values = {
code: String,
width: Number,
height: Number
}
static targets = ['frame']
initialize () {
this.width = this.widthValue || 640
this.height = this.heightValue || 480
this.element['youtube'] = this
}
connect () {
if (!this.hasCodeValue) return
const player = YouTubePlayer(this.frameTarget, {
width: this.width,
height: this.height,
videoId: this.codeValue
})
player.on('ready', e => {
this.element.setAttribute('data-duration', e.target.getDuration())
this.youtube = e.target
this.element.setAttribute('data-time', this.time)
this.element.setAttribute('data-state', -1)
})
player.on('stateChange', e => {
this.element.setAttribute('data-state', e.data)
this.element.setAttribute('data-time', this.time)
e.data === 1 ? this.startTimer() : clearInterval(this.timer)
})
}
teardown () {
this.player.destroy()
}
startTimer () {
this.timer = setInterval(() => {
this.element.setAttribute('data-time', this.time)
this.element.dispatchEvent(
new CustomEvent('youtube', {
bubbles: false,
cancelable: false,
detail: { time: this.time }
})
)
}, 1000)
}
play = () => this.player.playVideo()
pause = () => this.player.pauseVideo()
stop = () => this.player.stopVideo()
seek = seconds => this.player.seekTo(seconds)
get player () {
return this.youtube
}
get time () {
return parseInt(this.player.getCurrentTime())
}
get duration () {
return this.player.getDuration()
}
get state () {
return this.element.getAttribute('data-state')
}
get loaded () {
return this.player.getVideoLoadedFraction()
}
}
@wilsoncelyCUC
Copy link

Hi!

I am getting this error in the console ;(

`[stimulus.js? [sm]:1718 Error connecting controller

TypeError: Cannot read properties of undefined (reading 'destroy')
at extended.connect (youtube_controller-17dfa670e4c222bf1c314496a30f203987acc2375be746b3ee027d6f9b8bb39c.js:20:70)
at Context.connect (stimulus.js? [sm]:1127:29)
at Module.connectContextForScope (stimulus.js? [sm]:1318:17)
at stimulus.js? [sm]:1642:42
at Array.forEach ()
at Router.connectModule (stimulus.js? [sm]:1642:16)
at Router.loadDefinition (stimulus.js? [sm]:1605:14)
at stimulus.js? [sm]:1701:29
at Array.forEach ()
at Application.load (stimulus.js? [sm]:1699:21)

{identifier: 'youtube', controller: extended, element: div}](url)`

@wilsoncelyCUC
Copy link

The only difference with my setup is that I use importmap so I type

➜ CAMPANAZZO git:(homev5) ✗ ./bin/importmap pin youtube-player
Pinning "youtube-player" to https://ga.jspm.io/npm:youtube-player@5.5.2/dist/index.js
Pinning "debug" to https://ga.jspm.io/npm:debug@2.6.9/src/browser.js
Pinning "load-script" to https://ga.jspm.io/npm:load-script@1.0.0/index.js
Pinning "ms" to https://ga.jspm.io/npm:ms@2.0.0/index.js
Pinning "process" to https://ga.jspm.io/npm:@jspm/core@2.0.0-beta.27/nodelibs/browser/process-production.js
Pinning "sister" to https://ga.jspm.io/npm:sister@3.0.2/src/sister.js

@leastbad
Copy link
Author

Hey Wilson,

I wouldn't use importmap in general. IMO it's the source of far more problems than I see with other tools. I suggest you check out esbuild!

I just updated the gist with the most recent version of the controller, which is about two years old. I can say that it appears to be still working on my end. Try this new version. If it's not working, I'd at least try it without importmap. I promise you that importmap will be the source of problem after problem, in the name of "simplicity".

Good luck!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment