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
idmode (primary): pass an ID; the component asynchronously fetches the URL viafetchPlayUrl(id)urlmode (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-inPOST /movie/play(legacy backend convention, returns{ play_url })null: explicitly disable fetch (must passurl)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
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
id | string | number | '' | Media ID, passed to fetchPlayUrl | |
url | string | — | Direct preview URL, skips fetch | |
type | string | 'm3u8' | URL media type (m3u8 uses hls.js; others use native video.src) | |
fetchPlayUrl | ((id) => Promise<PreviewSource | null>) | null | undefined | Tri-state: undefined → built-in /movie/play; null → disable; function → override | |
autoplay | boolean | true | Autoplay | |
muted | boolean | true | Muted (required for autoplay) | |
loop | boolean | false | Loop (real preview reels run 55s~2min; looping is a patch for footage that is too short) | |
preload | 'none' | 'metadata' | 'auto' | 'metadata' | Preload strategy | |
maxDuration | number | 60000 | Cumulative playback cap in milliseconds; stops and emits capped on reaching it. 0 = uncapped |
Events
| Name | Payload | Description |
|---|---|---|
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 |
settled | reason: '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 |
error | err: unknown | Fetch 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
| Method | Description |
|---|---|
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
idand play; the framework defaults to/movie/play - Non-HLS support: based on
type/ URL extension, mp4 / webm etc. use nativevideo.srcdirectly - 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
- SwVideoPlayer — full video player (with controls and reporting)
- SwAudioPlayer — audio player
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.