Carousel (Video)

Same carousel mechanics plus video orchestration: auto-advance on clip end, deferred cleanup to avoid compositor black frames, lazy preload.

Live Demo

The currently-playing clip auto-advances to the next on ended. Off-screen videos have no src, so nothing is downloaded until it's needed.

Source

HTML + JavaScript
<div data-component="video-carousel" data-cloak>
    <button data-action="prev" data-bind-attr="{ disabled: !canPrev }">‹</button>
    <div class="viewport" data-bind-style="{ width: viewportWidth }">
        <div class="track" data-bind-style="{ transform: trackTransform }" data-list="enrichedItems">
            <template>
                <div class="slide">
                    <video muted playsinline
                           data-bind-attr="{ src: loaded ? src : '', preload: loaded ? 'auto' : 'none' }"></video>
                    <div data-bind="label"></div>
                </div>
            </template>
        </div>
    </div>
    <button data-action="next" data-bind-attr="{ disabled: !canNext }">›</button>
</div>

<script>
wildflower.component('video-carousel', {
    state: {
        offset: 0,
        current: 0,
        visibleCount: 2,
        slideWidth: 200,
        gap: 12,
        items: [
            { src: '/videos/a.mp4', label: 'A' },
            { src: '/videos/b.mp4', label: 'B' },
            { src: '/videos/c.mp4', label: 'C' },
            { src: '/videos/d.mp4', label: 'D' }
        ]
    },
    computed: {
        maxOffset() { return Math.max(0, this.items.length - this.visibleCount); },
        canPrev()   { return this.offset > 0; },
        canNext()   { return this.offset < this.maxOffset; },
        viewportWidth() {
            const n = this.visibleCount;
            return (this.slideWidth * n + this.gap * (n - 1)) + 'px';
        },
        trackTransform() {
            return 'translateX(-' + (this.offset * (this.slideWidth + this.gap)) + 'px)';
        },
        enrichedItems() {
            const start = this.offset - 1;
            const end = this.offset + this.visibleCount;
            return this.items.map((item, i) => ({
                ...item,
                loaded: i >= start && i <= end
            }));
        }
    },
    init() {
        // Bind the 'ended' event on any video rendered by the list.
        // Delegated handler keeps it simple as the list re-renders.
        this.$el.addEventListener('ended', (e) => {
            if (e.target.tagName === 'VIDEO') this._advance();
        }, true);
        this._playAt(0);
    },
    prev() {
        if (!this.canPrev) return;
        this.offset--;
        this._syncPlaying();
    },
    next() {
        if (!this.canNext) return;
        this.offset++;
        this._syncPlaying();
    },
    _advance() {
        const nextIdx = (this.current + 1) % this.items.length;
        this.current = nextIdx;
        // Scroll viewport so the playing clip stays visible
        if (nextIdx < this.offset) this.offset = nextIdx;
        else if (nextIdx >= this.offset + this.visibleCount) {
            this.offset = nextIdx - this.visibleCount + 1;
        }
        // Defer play until after the CSS slide animation completes, so the
        // browser doesn't drop decoded frames mid-transform (causes black flash)
        setTimeout(() => this._playAt(nextIdx), 400);
    },
    _syncPlaying() {
        // When user arrows a playing clip off-screen, jump to first visible
        if (this.current < this.offset || this.current >= this.offset + this.visibleCount) {
            setTimeout(() => { this.current = this.offset; this._playAt(this.offset); }, 400);
        }
    },
    _playAt(idx) {
        const videos = this.$el.querySelectorAll('video');
        videos.forEach(v => { try { v.pause(); } catch (e) {} });
        const target = videos[idx];
        if (target && target.src) {
            try { target.currentTime = 0; target.play(); } catch (e) {}
        }
    }
});
</script>

Key Points

  • The same enrichedItems lazy-load pattern as the image carousel: off-screen videos get no src, so zero bytes download until they're needed
  • preload is bound reactively too: 'auto' when the slide is in the loaded window, 'none' when it isn't. The browser aggressively discards data for videos set to 'none'
  • A delegated ended listener on the component root handles rotation; it survives list re-renders without manual per-video wiring
  • setTimeout(..., 400) after a scroll defers the next play() until the CSS slide transition finishes. Without this, the browser's compositor can drop decoded frames mid-transform and the video flashes black
  • If the user arrows the playing clip off-screen, rotation jumps to the first visible slide after the animation
  • Touching currentTime = 0 before play() ensures each clip starts from frame 0 on its turn, even on repeat