SwVideoPreview

SwVideoPreview is a lightweight player for card hover / mini-window previews — muted, no controls, and capped at 60 seconds of playback by default. Built on HLS.js + native <video>, SSR-safe.

Source Tri-state via fetchPlayUrl

Two Input Modes

  • id mode (primary): pass an ID; the component asynchronously fetches the URL via fetchPlayUrl(id)
  • url mode (secondary): pass a known URL directly to skip fetch

fetchPlayUrl Tri-state

fetchPlayUrl is zero-config by default — same pattern as onHistory:

  • undefined (default): built-in POST /movie/play (legacy backend convention, returns { play_url })
  • null: explicitly disable fetch (must pass url)
  • function: override built-in implementation

Basic Usage

Simplest form: direct url (skips fetching); muted + autoplay, no looping, stops after 60 seconds by default.

Basic preview (direct url)

加载中...
<!-- Default behavior: pass id and play (uses built-in /movie/play) -->
<SwVideoPreview :id="movie.id" />
<!-- Override fetch implementation -->
<SwVideoPreview :id="m.id" :fetch-play-url="(id) => api.movie.preview({ id })" />
<!-- Pass URL directly (skip fetch) -->
<SwVideoPreview url="https://cdn.example.com/preview.mp4" type="mp4" />
<!-- Disable fetch, must pass url -->
<SwVideoPreview :url="myUrl" :fetch-play-url="null" />

Card Hover Preview

Hover a card → enlarged overlay → async buffering → video fades in on @playing (a simplified take on the yc001 homepage pattern).

Card hover zoom + async load & play

加载中...
<script setup lang="ts">
const movies = [
  { id: 1, title: 'Movie 1', cover: '...' },
  { id: 2, title: 'Movie 2', cover: '...' },
]
const hoveringId = ref<number | null>(null)
</script>

<template>
  <div class="grid">
    <div
      v-for="m in movies"
      :key="m.id"
      @mouseenter="hoveringId = m.id"
      @mouseleave="hoveringId = null"
    >
      <SwVideoPreview v-if="hoveringId === m.id" :id="m.id" />
      <img v-else :src="m.cover" />
    </div>
  </div>
</template>

fetchPlayUrl + events

Pass :id plus an async resolver; @loading / @ready / @playing are all observable.

:id + fetchPlayUrl (event log)

加载中...

Replay when ended (loop=false)

loop=false + @ended replay button

加载中...

Refetch on URL Expiry

Preview URLs typically expire after a few minutes; call reload() to refetch:

<script setup lang="ts">
const previewRef = ref()
let expiryTimer: number | undefined

onMounted(() => {
  // Auto-refresh URL every 5 minutes
  expiryTimer = window.setInterval(() => previewRef.value?.reload(), 5 * 60 * 1000)
})

onBeforeUnmount(() => {
  if (expiryTimer) clearInterval(expiryTimer)
})
</script>

<template>
  <SwVideoPreview ref="previewRef" :id="movie.id" />
</template>

Error Fallback

Fetch failure → @error cover fallback

加载中...
<script setup lang="ts">
const fetchFailed = ref(false)
</script>

<template>
  <div class="card-cover">
    <SwVideoPreview
      v-if="!fetchFailed"
      :id="movie.id"
      @error="fetchFailed = true"
    />
    <img v-else :src="movie.cover" alt="Preview failed; falling back to cover" />
  </div>
</template>

Playback duration cap (maxDuration)

A preview stops after 60 seconds of playback by default (hls.stopLoad() + pause(), keeping the last frame) and emits capped.

Why cap by default: preview URLs are commonly implemented as "fall back to the full feature's playback URL when ops hasn't configured a dedicated preview asset". With no cap, a user can watch for as long as they keep hovering — paid content consumed for free through the preview overlay. The cap closes that hole at the framework layer instead of leaving every consumer to remember it.

Why 60: measured industry preview reels already run 55s~2min (Tencent Video 112s, Youku 55s), so a 60-second cap almost never fires for them (they finish naturally via @ended); meanwhile 60 seconds out of a 90-minute feature is 1.1% — nobody watches a movie by accumulating hovers. In other words the default sits on the side that is right for most cases.

Timing is measured as cumulative playback time (accumulated from timeupdate; pauses and buffering don't count), not wall-clock time since mount — otherwise a slow network would cut playback short. With loop enabled, laps accumulate until the cap is reached.

<!-- Different caps per source kind: don't cut curated reels, do cap full-feature fallbacks -->
<SwVideoPreview :id="m.id" :max-duration="source === 'curated' ? 0 : 30000" @capped="showCover = true" />
<!-- Explicitly uncapped (restores the old unbounded behaviour) -->
<SwVideoPreview :id="m.id" :max-duration="0" />

"Stops network requests" only holds for m3u8: hls.js has stopLoad(), while mp4 / webm go through the native video.src and have no equivalent — after pause() it is up to the browser whether it keeps filling the buffer. Deterministically stopping the stream would require clearing src, which throws away the last frame and defeats the freeze-frame behaviour, so we don't.

Custom Playback Strategy

The legacy component hardcoded autoplay/muted/loop=true and controls=false; the new component makes these configurable (controls is still hardcoded to false):

<!-- No loop, unmuted (after user interaction) -->
<SwVideoPreview :id="movie.id" :loop="false" :muted="false" />

Event contract: at least one terminal state after loading

After loading, at least one terminal state is guaranteed to arrive: playing, empty, or error.

There used to be a fourth outcome — the source loads fine but never plays (readyState is high enough yet it stays paused, and playing never fires) — with no event at all, leaving consumers stuck in their own loading state forever. Each applier therefore wrote its own timeout fallback, each picking a different number (6s, 2.5s, …). The framework now covers it: with autoplay, if playback hasn't started 8 seconds after the source is attached, it emits error({ reason: 'start-timeout' }).

"At least one" — not "exactly one": playing legitimately fires again when playback resumes after a pause, so logic that re-hides the cover on @playing keeps working.

Collapsing the loading animation sooner is the consumer's call. The framework's 8 seconds only guarantees a terminal state; it does not dictate perceived pacing. If you want faster visual feedback, run your own 2–3 second timer that only clears the visuals (scanline / dimming) while letting the framework's 8 seconds keep giving the stream a chance — better than shortening the framework timeout, since a false positive kills a stream that was merely slow.

Props

NameTypeRequiredDefaultDescription
idstring | number''Media ID, passed to fetchPlayUrl
urlstring—Direct preview URL, skips fetch
typestring'm3u8'URL media type (m3u8 uses hls.js; others use native video.src)
fetchPlayUrl((id) => Promise<PreviewSource | null>) | nullundefinedTri-state: undefined → built-in /movie/play; null → disable; function → override
autoplaybooleantrueAutoplay
mutedbooleantrueMuted (required for autoplay)
loopbooleanfalseLoop (real preview reels run 55s~2min; looping is a patch for footage that is too short)
preload'none' | 'metadata' | 'auto''metadata'Preload strategy
maxDurationnumber60000Cumulative playback cap in milliseconds; stops and emits capped on reaching it. 0 = uncapped

Events

NamePayloadDescription
loading—Source resolution started (fires on both the id and url paths)
ready—URL attached to video, ready to play
playing—Actually started playing
waiting'waiting' | 'stalled'Buffering / waiting (incl. url direct mode; also fires when re-buffering mid-playback)
canplay—Buffering finished, can resume
ended—Playback ended (with the default loop=false, fires when the clip finishes)
capped—Cumulative playback hit maxDuration; playback stopped (last frame kept)
empty—No playable source (fetchPlayUrl=null without url / empty id / fetch returned no usable address). Distinct from error: "there simply is no preview source" is a valid business state, not a failure
settledreason: 'ended' | 'capped' | 'empty' | 'error'Aggregate of the four terminal states, fired right after the corresponding specific event. If you only need "unmount / stop the stream once it settles", subscribe to this one instead of remembering all four — a missed empty is invisible under mocks
errorerr: unknownFetch failure / fatal HLS error / native error / start timeout (payload { reason: 'start-timeout', source: 'sw-video-preview' }). Non-fatal HLS errors are retried by hls.js and no longer forwarded

defineExpose

MethodDescription
play()Start playback. Calling it after maxDuration has been reached resets the timer and grants a fresh window (deliberately not a silent no-op; looping requires repeated explicit calls, which is an explicit business decision)
pause()Pause (picture only — hls keeps filling its buffer)
stop()Stop playback and streaming while keeping the current frame (m3u8 uses stopLoad(); mp4 does not guarantee the network stops — see above)
destroy()Destroy hls instance (the picture goes away too)
reload()Re-execute fetch + initPlayer (for URL expiry)

PreviewSource Type

type PreviewSource =
  | string
  | { play_url: string; type?: string }
  | { url: string; type?: string }

Notes

  • Zero-config: pass id and play; the framework defaults to /movie/play
  • Non-HLS support: based on type / URL extension, mp4 / webm etc. use native video.src directly
  • Auto-reload on id / url change: destroys old instance and reloads automatically
  • Controls always disabled: preview UI never shows controls
  • autoplay requires muted: browser policy

See Also

The waiting event payload distinguishes playback waiting for data (waiting) from a stalled download (stalled). A stalled download can still have enough buffered data to continue playback; it is not a playback timeout.