I was surprised that previous blog post about how to implement PhotoSwipe gallery using StimulusJS got so much traction (give the fact I spent 0 time promoting the blog and traffic is purely organic). So I decided to give it a follow-up and throw some ideas on how you can implement video support in PhotoSwipe using awesome Plyr library.

Some things to mention since the last post:

Inside the official PhotoSwipe repository you can find huge discussion regarding video support - it contains a bunch of ideas, code snippets, useful information and even full-blown photoswipe forks with video-support build-in. So I definitely recommend checking it out first.

Unfortunately I wasn’t fully happy with suggested approaches so I decided to roll my own using that is backed by already mentioned Plyr library.

I assume you have your gallery in place, we need to glue some pieces to make it all happen.

Do you remember this snippet mentioned in the previous post?

<%= link_to image.big_file_destination, title: image.title, data: { action: 'gallery#onImageClick', target: 'gallery.picture' } do %>
  <%= image_tag image.small_thumb %>
<% end %>

We will modify this a little bit, first I’m gonna pass video data attribute and id (or some sort of uuid - basically something unique per gallery item that I will re-use later in stimulus gallery controller). link_to will point to video (if gallery item is a video file - otherwise we will simply point to our big resolution image file). image_tag will point to thumbnail (video thumbnail in case it’s a video or regular thumbnail of a static image in case it’s not a video). Also let’s rename image variable to record as we’re dealing with more generic types now.

<%= link_to record.file, title: record.title, data: { action: 'gallery#onImageClick', target: 'gallery.picture', video: record.video?, id: record.id } do %>
  <%= image_tag record.thumbnail %>
<% end %>

That’s actually it when it comes to html markup, most work will happen in the gallery controller. So simply let’s take a dive in.

// First let's define custom interface based on PhotoSwipe.Item as we wanna know if we're
// dealing with video or image; note I'm also gonna define that `id` attribute I mentioned before
interface ImageVideoItem extends PhotoSwipe.Item {
  id: number;
  source: string;
  video?: boolean;
}

export default class extends Controller {
  static targets = ['picture']

  private pictureTargets;

  // we're gonna store player instance and clean it up after gallery is closed
  private player;

  onImageClick(event): void {
    event.preventDefault()

    // I'm sill rendering gallery html markup somewhere outside the controller itself, but I'm fine with it
    const galleryWrapper = document.querySelector('.pswp') as HTMLElement

    // you might want to adjust those according to your needs!
    var options = {
      history: false,
      shareEl: false,
      closeOnScroll: false,
      closeOnVerticalDrag: false,
      focus: true,
      // here we're gonna use custom source attribute instead of src to lookup the current item as videos
      // are slightly different - we will simply render custom html there so we can initialize Plyr on those
      index: this.items.findIndex(item => item.source === event.currentTarget.getAttribute('href'))
    }

    // using dynamic import here so we load everything only when needed thanks to webpack
    // note: you might need to set "esModuleInterop": true in your tsconfig to get this to work properly
    import(/* webpackChunkName: "photoswipe" */ 'photoswipe').then(PhotoSwipe => {
      import(/* webpackChunkName: "photoswipe-ui-default" */ 'photoswipe/dist/photoswipe-ui-default').then(PhotoSwipeUI_Default => {
        const gallery = new PhotoSwipe.default(galleryWrapper, PhotoSwipeUI_Default.default, this.items, options)

        gallery.listen('destroy', () => {
          this.cleanupPlayer()
        })

        gallery.listen('preventDragEvent', (event, isDown, preventObj) => {
          // do not prevent touch events when player is up - this fixes seeking problems
          preventObj.prevent = !this.player
        })

        gallery.listen('beforeChange', () => {
          const currItem = gallery.currItem as ImageVideoItem
          this.cleanupPlayer()

          // that's why we passed that video data attribute as I think
          // it's simply easier to do detection on the backend
          if (currItem.video) {
            import(/* webpackChunkName: "plyr" */ 'plyr').then(plyr => {
              // we're gonna initialize plyr based on our html created for videos
              // refer to Plyr docs for more info and tweak options as needed
              this.player = new plyr.default(`#video-${currItem.id}`, {
                hideControls: false,
                tooltips: { controls: true, seek: true },
                volume: 0.8
              })

              this.player.on('ready', () => {
                gallery.updateSize(false)
              })
            })
          } else {
            const image = new Image()
            image.src = currItem.src

            image.onload = () => {
              gallery.currItem.w = image.width
              gallery.currItem.h = image.height

              gallery.updateSize(false)
            }
          }
        })

        gallery.init()
      })
    })
  }

  get items(): ImageVideoItem[] {
    return this.pictureTargets.map(item => {
      const src = item.getAttribute('href')
      const id = parseInt(item.dataset.id)

      // in case we're dealing with video we're gonna
      // create html markup needed by Plyr
      // maybe it's not the most elegant way, but it should do the job nicely
      if (item.dataset.video === 'true') {
        return {
          id: id,
          video: true,
          source: src,
          // on the backend my videos are compressed to mp4, you might need to adjust this further
          html: `<video id="video-${id}" playsinline controls>` +
                `<source src="${src}" type="video/mp4" />` +
                '</video>'
        }
      } else {
        return {
          id: id,
          src: src,
          source: src,
          title: item.getAttribute('title'),
          w: 0,
          h: 0
        }
      }
    })
  }

  cleanupPlayer(): void {
    if (this.player) {
      this.player.destroy()
      this.player = undefined
    }
  }
}

And that’s pretty much it - it should give you general overview how you could potentially add video support to PhotoSwipe using Plyr and it should give your users pretty decent browsing experience.