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:
- I started using TypeScript more in the codebase so full gallery example is in TypeScript (not the latest revision tho)
- I’m definitely not an expert when it comes to js and front-end in general, so if you find an issue somewhere - feel free to let me know!
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.
Revisit gallery markup
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.