This article was originally published on Rails Designer's blog
Building on the video recording feature from earlier, let's add a nice touch to the presentation index page: video previews that play on hover. You know the experience—hover over a video thumbnail and get a quick preview of what's inside. It's the same interaction you see on YouTube, Netflix and every modern video platform.
The presentations index
After recording presentations, you need a way to browse them. A simple index page lists all presentations with video thumbnails. When you hover over a thumbnail, the video plays a preview. Move your cursor away and it returns to the poster image.
Here's the basic setup:
<% @presentations.each do |presentation| %>
<%= video_tag presentation.video,
width: "160",
poster: (presentation.video.representable? ? url_for(presentation.video.representation(resize: "160x120")) : nil),
data: {
controller: "preview",
action: "mouseenter->preview#play mouseleave->preview#pause"
}
%>
<% end %>
Active Storage's representable? method checks if a preview can be generated and representation() creates the thumbnail automatically.
The preview controller
All the logic happens in one Stimulus controller:
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = {
segments: { type: Number, default: 3 },
interval: { type: Number, default: 1000 },
minDuration: { type: Number, default: 5 }
}
connect() {
this.originalTime = 0
this.wasPlaying = false
this.previewTimer = null
this.currentIndex = 0
this.isReady = false
this.timestamps = []
this.element.addEventListener("loadedmetadata", () => {
this.#calculateTimestamps()
this.isReady = true
})
}
}
Understanding loadedmetadata
The loadedmetadata event is the key here. It fires when the browser has loaded enough of the video to know its duration, dimensions and other metadata. Without this information, you can't calculate meaningful preview timestamps.
this.element.addEventListener("loadedmetadata", () => {
this.#calculateTimestamps()
this.isReady = true
})
Only after loadedmetadata fires can you access this.element.duration reliably. Try to use it before this event and you'll get NaN or 0.
Smart timestamping
Instead of just playing from the beginning, the controller shows different parts of the video:
#calculateTimestamps() {
const duration = this.element.duration
if (duration < this.minDurationValue) {
this.timestamps = [0]
return
}
this.timestamps = []
for (let i = 1; i <= this.segmentsValue; i++) {
this.timestamps.push((duration / (this.segmentsValue + 1)) * i)
}
}
For short videos (under 5 seconds), it just plays from the start. For longer videos, it divides the duration into segments and cycles through them. A 60-second video with 3 segments shows clips at 15, 30 and 45 seconds.
The element methods
Stimulus gives you direct access to the video element through this.element. Here are the key methods and properties used:
-
this.element.durationfor the total video length in seconds -
this.element.currentTimefor current playback position -
this.element.pausedis a boolean indicating if video is paused -
this.element.play()to start playback -
this.element.pause()to pause playback -
this.element.mutedis a boolean to control audio during preview
The preview controller leverages these to create smooth hover interactions:
play() {
if (!this.isReady || this.timestamps.length === 0) return
this.originalTime = this.element.currentTime
this.wasPlaying = !this.element.paused
this.currentIndex = 0
this.element.muted = true
this.#showNextTimestamp()
if (this.timestamps.length > 1) {
this.previewTimer = setInterval(() => {
this.#showNextTimestamp()
}, this.intervalValue)
}
}
When you hover, it saves the current state, mutes the audio and starts cycling through preview segments. When you leave, it restores everything exactly as it was.
Auto-looping previews
The preview automatically loops through different parts of the video:
#showNextTimestamp() {
this.element.currentTime = this.timestamps[this.currentIndex]
this.element.play()
this.currentIndex = (this.currentIndex + 1) % this.timestamps.length
}
The modulo operator (%) creates the loop: when it reaches the last segment, it goes back to the first. Combined with setInterval, this creates a cycling preview that gives your users a real sense of the video content.
The key is waiting for the right moment (when metadata loads), calculating smart preview points and cleaning up properly when the interaction ends. This preview controller shows how a small Stimulus controller can create a nice UX. By understanding browser events like loadedmetadata and leveraging the video element's built-in methods, you can get a lot done without writing a lot of JavaScript. 🥳
Top comments (0)