SwVideoFeed
SwVideoFeed is a TikTok / Douyin-style vertical short-video feed: one clip per screen, swipe up/down to move between them. On mobile it uses the browser's native vertical scroll-snap (finger swipe + momentum snapping come free, silky on real devices); on desktop it uses a custom wheel/trackpad to flip clips (snappy paging animation) plus keyboard ↑ / ↓ to flip, Space to toggle play/pause of the current clip, and optional desktop up/down arrows.
Internally it is a virtualized + bare hls player pool: every item renders its own full-screen slide to hold the scroll height, but only items inside the window (current ±windowSize) mount a real player core; items outside only show the cover — so even long lists never blow up memory. Combine @load-more (load on demand) with finished (boundary control) to build an infinite feed.
Business UI is not built in: likes / author / side icon column / follow button etc. go entirely in the
#overlayslot — the framework only provides the "scroll + playback + minimal controls" skeleton.
Muted autoplay is the default: browser policy requires a user gesture for "autoplay with sound".
muteddefaults totrue(muted playback + a mute button to unmute); passingmuted="false"is a "try to enter with sound" attempt — see Enter with sound below.
HLS signature recovery
On the hls.js path, the first fragment/key 403 or missing response reloads the same playback URL and resumes at the current position, with at most one rebuild per mount; playback progress and elapsed time do not renew this allowance, and subsequent fatal errors reach the application for fallback. Use a stable endpoint that issues fresh signatures; reloading an expired CDN URL cannot renew its signature. Feed resumes playback only if the item was playing and is still active; paused and preloaded items stay paused. Fragment/key timeouts (including keyLoadTimeOut), 404 and 5xx retain hls.js handling. Fatal errors still reach the error event. An item whose hls.js instance hit a fatal error (while preloading, or earlier while active) is rebuilt once from its current URL when it becomes active, so a transient line failure can self-heal; if the rebuild fails as well, error fires again and no further automatic retries happen (swiping away and back tries once more).
In development, HLS recovery console records contain the error type, HTTP status, positions before/after rebuilding and outcome. progress requires advancement beyond the resume position, not merely a loaded manifest. These records contain no signed URLs and are not sent to a production backend. Native HLS is outside this recovery path.
Cancellable episode resume
Validate the account, work and episode ownership before passing resume: { key, time } on a video item. key identifies the viewing session. Only the active item offers a five-second countdown measured in actual playback, with resume-now and cancel actions. Pause, manual seek, changing items or changing sources cancels the offer. Background time, buffering and ads do not consume the countdown. Preloaded items never resume; existing session positions on return and explicit seek_time (including 0) take priority.
Use resume-pending(index, item, pending) to block periodic, pause and leave history writes by item ID. The application must also block writes while private history is unknown or its request has failed; release that protection only after a successful read. The framework does not fetch history, navigate between episodes or bypass autoplay policies. Use seek_time for an explicit continue action and resume for an ordinary entry.
Cancellable resume
Basic usage
Pass an array of clips to items; each item needs at least a cover img + play routes play_links. Put business overlays in the #overlay slot (it receives { item, index, active }). The component fills the full screen by default (100dvh); inside a fixed-height container set the CSS variable --sw-video-feed-height to 100%.
Vertical short-video feed (in a phone-sized box)
<SwVideoFeed :items="list">
<template #overlay="{ item, index, active }">
<div class="feed-overlay">
<p class="author">@{{ item.meta?.author }}</p>
<button class="like">♥ {{ item.meta?.likes }}</button>
</div>
</template>
</SwVideoFeed>
Business overlay (#overlay)
Business UI (like / comment / share icon column + author / description) is rendered entirely in the #overlay slot — the framework doesn't build it. The slot container is pointer-events: none (the video stays clickable / swipeable); only direct children get a zero-specificity pointer-events: auto fallback and deeper elements inherit — so pointer-events: none written at any level inside the slot takes effect and cascades down (a hidden action column no longer swallows clicks), no need for a visibility: hidden workaround.
Douyin-style right icon column + bottom-left author/description
Touch devices: tapping vs. swiping
On mobile the feed uses native scrolling, so the browser has to decide at touchmove
time whether a gesture is a scroll or a tap — and once it commits to scrolling it cancels
that click, with no way to take it back. Inside a scrolling container, therefore, a single
region cannot be both reliably tappable and a valid place to start a swipe. It is a
per-element trade-off.
What happens without that trade-off (observed on a real device): tapping the bookmark button on the right edge with your thumb naturally traces a small arc; once it exceeds the browser's touch slop, one mis-tap costs you two things at once — the bookmark never registers (the click was cancelled) and momentum snaps the feed to the next item.
The framework already makes that trade-off for you — no applier changes required:
button / a / [role="button"] inside .slide automatically get touch-action: none,
so those controls belong to tapping. The video surface, cover, text paragraphs and the
positioning wrappers in your slots are untouched, so most of the screen still starts a swipe.
The one rule you must follow: never make a full-screen <a> / <button> inside a slot
(including a full-bleed SwLink / SwButton) — that slide becomes impossible to swipe from
anywhere. Use <div @click> for full-screen tap layers, exactly like the component's own
tap-to-pause layer does.
To harden a grouped control area, fold the labels into the buttons — don't put touch-action on the
container.
touch-action: none applies to an element and its descendants only. Inside a grouped area such as the
right-hand action rail, the gaps between buttons, the text labels under them and the empty space around
the group are not buttons, so in theory a touch landing there can still become a swipe.
The fix is to move the label inside the <button> (icon + label in one button): the label becomes a
descendant and is covered by the rule above automatically — and you get a larger tap target for free,
which is the better markup anyway.
Do not opt the whole container out with touch-action: none. If the labels are siblings of the
buttons, opting out the entire rail turns the label area into dead space that neither responds to taps nor
starts a swipe — worse than leaving it alone. (One consumer measured this and dropped the idea.)
When a button "does nothing" or "swipes instead of tapping", first ask the browser what is actually at
that coordinate — don't reach for touch-action straight away. The most common culprit in this spot is
stacking-order occlusion (framework 0.111.1 fixed one: invisible pagination arrows sitting on top of
the business action rail — tapping them paged the feed and the business handler never ran).
const r = document.querySelector('.share-btn').getBoundingClientRect()
console.log(document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2))
// Hits itself → it really is a pan-vs-click problem. Hits something else → occlusion, a different fix entirely.
Also, to tell whether the click fired at all, test the button that always produces visible feedback (a toast on both success and failure, or a login dialog when signed out) — "nothing happened" can mean the click was swallowed or that the handler failed silently.
The side effect is that you can no longer start a swipe directly on top of a button or
link. That is the unavoidable cost of the constraint above: those spots exist to be tapped.
If one link covers a large area (say a whole "continue watching" card) and you really want it
swipeable, turn it into a <div @click> — at the cost of link semantics and SEO.
⚠️ Nested horizontal scrolling is not supported inside the feed
.feed-viewport declares touch-action: pan-y (framework 0.122.3+, fw#354) to seal off the horizontal
gesture surface: overflow-y: auto forces the other axis' visible to compute as auto per spec, so a
vertical stream silently gains horizontal scrollability — on a real iPhone you can drag the whole feed
sideways (elastic rubber-band; not reproducible on desktop).
The cost: touch-action is an ancestor-level restriction that descendants cannot undo — per spec the
allowed gestures are intersected along the chain from the touch target to the scrolling container, so a
descendant cannot re-enable what an ancestor disallowed (writing touch-action: pan-x on something inside
#overlay has no effect). A horizontally scrolling rail or tab strip placed in #overlay will not scroll.
This is a deliberate trade-off: the feed is a full-screen vertical stream by definition, and horizontally swipeable content already conflicts with the tap-vs-swipe trade-off above. If you genuinely need it, open a framework issue so we can evaluate a prop — do not try to work around it in the applier (you cannot).
Dragging the progress bar to seek is unaffected (two-state since fw#372) — the expanded bar declares
touch-action: none(press-and-drag in any direction); the collapsed bar declarespan-y: a vertical start hands the gesture to native feed scrolling, while a horizontal start (~10px, horizontally dominant) engages seeking — so you can drag the bar horizontally while playing, no prior tap on the video needed.
Data format (VideoFeedItem)
items is a discriminated union: video items (kind omitted or 'video', reusing the player's PlayLink routes) + ad items (kind: 'ad', full-screen in-feed ads — see In-Feed Ad below).
// Video item (kind omitted = video item; existing usage unchanged)
interface VideoFeedVideoItem {
kind?: 'video'
img: string // cover (fallback shown before canplay + for off-window slides)
play_links: PlayLink[] // m3u8 multi-route
link_code?: string // which route to use (matches PlayLink.code, takes priority over component-level linkCode)
seek_time?: number // start position (seconds), auto-seeks after loadedmetadata. Dual use: history resume / paywall unlock resume (resume_offset)
resume?: { key: string | number; time: number } | null // Ownership-validated, cancellable history candidate
locked?: boolean // paywall lock state (path A): true = paid content the user can't access; shows #paywall after the trial clip ends (see "Paywall")
id?: string | number // business id (analytics / v-for key)
meta?: Record<string, unknown> // business data passed through to #overlay
}
// Ad item (full-screen in-feed ad; creative goes in the #in-feed-ad slot)
interface VideoFeedAdItem {
kind: 'ad'
id?: string | number // business id (analytics / key)
skipAfter?: number // forced-view duration (millisecnds); falls back to props.adSkipAfter (default 0 = not forced)
meta?: Record<string, unknown> // ad data passed through to the #in-feed-ad slot (image / link etc.)
}
type VideoFeedItem = VideoFeedVideoItem | VideoFeedAdItem
interface PlayLink {
code: string // route id
id: number | string // business id
lid: number | string // business lid
m3u8_url: string // actual playback URL (.m3u8 → hls.js, otherwise native <video>)
}
const list: VideoFeedItem[] = [
{
id: 1,
img: 'https://cdn.example.com/cover1.jpg',
play_links: [{ code: 'line1', id: 1, lid: 1, m3u8_url: 'https://cdn.example.com/1.m3u8' }],
meta: { author: 'alice', likes: 1234 },
},
// Ad item inserted by the backend (one every N clips): full-screen, swipeable, opens a link on tap
{ kind: 'ad', id: 'ad-1', skipAfter: 4000, meta: { image: 'https://cdn.example.com/ad.jpg', link: '/promo' } },
]
Load more (infinite feed)
When the current clip reaches "fewer than loadMoreThreshold clips remaining", @load-more fires; the parent fetches the next page and appends it to items (do not replace the whole array — that unmounts already-mounted player cores). When there is no more data, set finished to true and the component stops firing @load-more; if the user scrolls to the bottom and pulls up again, @reach-end fires once.
Load more (appends up to a cap of 12)
Event escape hatch (change/play/pause/timeupdate)
<SwVideoFeed
:items="list"
:finished="finished"
:load-more-threshold="5"
@load-more="loadNext"
@reach-end="onReachEnd"
>
<template #overlay="{ item }">…</template>
</SwVideoFeed>
<script setup>
const list = ref([])
const finished = ref(false)
let loading = false
async function loadNext() {
if (loading || finished.value) return
loading = true
const page = await api.fetchFeed(cursor)
if (page.length === 0) finished.value = true
else list.value.push(...page) // append, don't replace
loading = false
}
</script>
Initial position & virtualization
initialIndexsets which clip to land on at entry (e.g. the index clicked from a cover list); default0(instant, no animation).windowSizeis the player-pool radius: default1(current ±1 = 3 clips mount a real player core), the rest only show the cover.- Preload policy: by default the next 2 video items preload their first 5 seconds each (playback stock; the buffer cap is raised back to 30s once an item becomes active), keeping two consecutive swipes instant. Pass a number for a custom count (e.g.
:preload="3"), or:preload="false"to disable entirely — the cover shows until loading starts after the swipe settles, trading instant-play feel for CDN traffic. Ad entries (kind:'ad', zero video traffic) are skipped without consuming preload quota, the list tail clamps naturally, and the mount window extends forward to cover the preload set. The previous item is not preloaded (the item you just swiped away from is already loaded, so swiping back is still instant). Note the seconds cap is only precise on the hls.js path; iPhone always uses native HLS (iOS only ships ManagedMediaSource, and some Chinese browsers implement it in a way that advances the timeline without rendering any picture — so the framework routes iPhone to native, fw#365), where buffering is browser-managed and best-effort. ioThresholdis the visibility ratio at whichIntersectionObserverdecides the "current clip"; default0.6.
<!-- Enter at clip 3, pre-mount 2 clips on each side -->
<SwVideoFeed :items="list" :initial-index="3" :window-size="2" />
Enter with sound (muted = false)
muted defaults to true (muted autoplay — compliant with browser policy, never blocked). Passing muted="false" is an attempt to "enter with sound": it borrows the activation from the user's "tap into the feed" gesture — under SPA routing the first clip usually plays with sound (works on both PC and mobile web); if the browser blocks it (e.g. a full page refresh with no gesture), the component automatically falls back to muted and shows a mute button so the user can unmute, never going black-screen. Once the user toggles the mute state, it persists across the whole feed (the current clip and all subsequent ones).
<!-- Default muted playback (most robust; never blocked on mobile) -->
<SwVideoFeed :items="list" />
<!-- Try to enter with sound (auto-falls back to muted if blocked) -->
<SwVideoFeed :items="list" :muted="false" />
Desktop interaction & sensitivity
- Wheel / trackpad: accumulated
deltaYflips clips (one gesture = one clip, with a cooldown to prevent momentum over-flipping).sensitivity.wheelis the multiplier applied to the accumulated amount (default1; larger = more sensitive). - Keyboard (when the container is focused):
↑/↓flip clips,Spacetoggles play/pause of the current clip. arrows(defaulttrue): desktop up/down arrows — shown only on hover devices, disabled at the endpoints, hidden on mobile.
<SwVideoFeed :items="list" :sensitivity="{ wheel: 1.5 }" :arrows="false" />
End-of-clip behaviour (endMode)
What happens when a clip finishes. Defaults to 'loop' (replay — the personality of a short-video feed).
<!-- Series album / short-drama collection: play the next episode automatically -->
<SwVideoFeed :items="episodes" end-mode="next" />
Why this needs a prop instead of just listening for ended: in 'loop' mode the component sets
<video loop>, and a looping video wraps around at the end without firing ended (per the HTML
spec) — the event never reaches your code at all. endMode="next" turns loop off and takes over
the advance.
Three boundaries (all covered by e2e):
- Paywalled clips (
item.locked) win — the trailer stops, the paywall appears and the feed does not advance, so the user is never carried away from the wall - Forced-watch windows are respected — no advance while an in-feed ad or entry preroll countdown is still running
- Last item — emits
reach-endwhenfinishedis true (handle it yourself, e.g. toast + go back); whenfinishedis false it simply stops. Continuous play is deliberately decoupled from pagination — rely onloadMoreThresholdto fetch the next page ahead of time
Minimalist playback controls (Douyin-style)
Each player core has built-in Douyin-style minimal controls (no config needed): tap the video to pause/play, tap / hover to reveal a draggable progress bar (current / total time shown while dragging), a horizontal start on the bar drags directly while playing (no prior tap needed; a vertical start still switches items, fw#372), and a mute button at the bottom-right. Position / size are all tunable via CSS variables (see CSS variables).
Aspect-ratio fill (fillThreshold)
When the similarity between the video and viewport aspect ratios is ≥ fillThreshold, the video is cropped to fill (object-fit: cover, top-aligned + horizontally centered); default false (no crop, leaves black bars / contain).
Similarity = min(videoRatio, boxRatio) / max(videoRatio, boxRatio). 0.8 is the recommended value:
| Case | Similarity | 0.9 | 0.8 |
|---|---|---|---|
| Standard 9:16 clip on a 16:9 phone | 1.000 | ✂ crops | ✂ crops |
| Standard 9:16 clip on 19.5:9 (mainstream) | 0.822 | ❌ still letterboxed | ✂ crops |
| Standard 9:16 clip on 20:9 | 0.800 | ❌ still letterboxed | ✂ crops |
| Legacy 3:4 footage | 0.616 | letterboxed | letterboxed |
| 16:9 landscape in a portrait box | 0.260 | letterboxed | letterboxed |
0.9 only removes hairline bars and misses mainstream tall phones; 0.8 makes standard vertical footage fill the screen on every mainstream device (cropping at most 20% of the width — the same order of magnitude as the industry safe zones) while genuinely mismatched footage is still safely letterboxed.
Because the video is top-aligned, anything cropped vertically is taken off the bottom — if your sources routinely burn subtitles into the lower band, raise the threshold or leave the feature off.
Side-by-side below: the test streams are 16:9 landscape, so inside a portrait box they leave top/bottom black bars by default (
contain); withfillThresholdset, near-ratio videos crop to fill.
Default letterbox vs fillThreshold crop-to-fill
<!-- Mostly portrait clips: crop near-portrait ones to fill -->
<SwVideoFeed :items="list" :fill-threshold="0.8" />
In-Feed Ad
The backend can insert ad data into the items array (the industry calls this an In-Feed Ad — the full-screen ad in a TikTok / Douyin feed). The component branches on kind: an item with kind: 'ad' mounts no player core; instead its creative goes in the #in-feed-ad scoped slot, where the business renders the image creative + click-through link + impression tracking. It is still a normal slide — swipeable, and @change fires for it as usual.
The #in-feed-ad scope is { item, index, active, countdown, canSkip }:
<SwVideoFeed :items="feed" :ad-skip-after="4000">
<template #in-feed-ad="{ item, active, countdown, canSkip }">
<!-- Ad creative: image + link + impression tracking are all rendered by the business -->
<SwAdNative :items="[toAdItem(item.meta)]" slot-key="feed_inline" />
<!-- Forced-view countdown hint -->
<span v-if="active && !canSkip" class="skip-hint">{{ countdown }}s until you can swipe away</span>
</template>
</SwVideoFeed>
Forced-view for N seconds: once an ad item becomes the active clip you can lock "swipe away" for N seconds before releasing it (the industry "skip after a countdown" convention from skippable in-stream / interstitial ads). The effective duration is item.skipAfter ?? props.adSkipAfter, and defaults to 0 = not forced, freely swipeable:
- When
>0: ad item active → lock swipe-away (desktop blocksnext/prev/wheel, mobile freezes native scrolling) + countdown; on zero it unlocks andcanSkipflips totrue. - Override per item with
item.skipAfter, fall back at the feed level withadSkipAfter.
Tracking belongs to the business: the feed does not track ad items — the business uses
SwAd*(built-in impression / click) oruseAdTrackinside#in-feed-ad. The feed only manages "timing + position + the forced-view lock".
Entry preroll ad
A preroll ad shown once before the whole playlist starts (same API as the single-player SwVideoPlayer preroll). Toggle it with prerollAd; the preroll content goes in the #preroll-ad slot and is decoupled from items (the business provides it, not the list).
- It covers the whole feed on entry and holds the first clip (still pre-buffered, but not playing and silent);
skip()or a countdown reaching zero enters the main content. - One-shot: never re-appears after
skip. Scroll is not locked by default: swiping up during the preroll auto-skip()s into the feed (no forced ad wait). - Forced view (opt-in): with
prerollAdForceWatchon, flipping is locked during the countdown (wheel / keyboard ↑↓ / touch / navigation all blocked), then auto-unlocks on zero (same model as the in-feed adadSkipAfter); the slot renders ✕ / countdown based oncanSkip. - With
prerollAdAutoClose=trueit auto-enters the main content when the countdown hits zero; withfalseit shows the default "Enter" button on zero (or the businessskip()s).
The #preroll-ad scope is { countdown, skip, canSkip } (canSkip = whether it can be skipped now, false during a forced-view lock, same as #in-feed-ad):
<!-- preroll-ad-force-watch: locks swiping during the countdown (don't render ✕ while canSkip=false);
preroll-ad-auto-close: auto-enters the feed at zero (false shows an "Enter" button instead) -->
<SwVideoFeed
:items="videos"
preroll-ad
:preroll-ad-duration="5000"
preroll-ad-force-watch
:preroll-ad-auto-close="true"
>
<template #preroll-ad="{ countdown, skip, canSkip }">
<SwAdPreroll :items="playAdsToSwAdItems(feedPreroll)" slot-key="feed_preroll" @empty="skip()" />
</template>
</SwVideoFeed>
Periodic popover ad (@change)
"Pop a body-level ad every N clips" is not built in — the component only emits @change, and the business counts and pops SwAdPopup itself (building it in would hard-wire the frequency policy, violating "the feed doesn't bind business UI"). @change passes the item, so the business can exclude ad items from the count via item.kind:
<SwVideoFeed :items="feed" @change="onChange" />
<SwAdPopup ref="popRef" :items="popupAds" slot-key="feed_interval" />
<script setup>
const popRef = ref()
let played = 0
function onChange(_index, item) {
if (item.kind === 'ad') return // don't count ad items
if (++played % 5 === 0) popRef.value?.open() // pop once every 5 videos
}
</script>
Paywall
Paid content shows a purchase gate when the trial segment finishes (monetization, not advertising — not part of the SwAd* family). The mechanism follows path A: the backend only ships the trial segment (the full feature is never sent to the client, blocking rips at the root), so the segment ends naturally with ended → the #paywall slot is shown. Pass locked: true on a video item (compute it from your backend's paid fields, e.g. is_vip && !has_access; the framework doesn't care about field names):
- Wall on trial end: when a
lockedclip's trial segment finishes (the play core does not loop locked clips, freezing on the last frame) → the#paywallslot shows +@paywall(index, item)fires (hook your "wall impression" conversion tracking here). - No scroll lock, no close X: the paywall is a content lock state, not a dismissible overlay — you can swipe up /
skipToNext()(skip this video) away; the only exits are "unlock / skip". - Resume after unlock: on purchase your code mutates the item — swap
play_linksto the full source + setseek_timeto the backend'sresume_offset+ clearlocked; the component re-attaches the source and seeks to the resume point automatically (reusing theseek_timestart mechanism). The framework provides nounlock()(fetching the full source is your job).
#paywall scope { item, index, active, skipToNext }:
<SwVideoFeed :items="list" @paywall="onPaywallExposure">
<template #paywall="{ item, skipToNext }">
<div class="paywall">
<p>Trial ended</p>
<button @click="unlock(item)">Unlock with membership</button>
<button @click="skipToNext()">Skip this video</button>
</div>
</template>
</SwVideoFeed>
<script setup>
async function unlock(item) {
await purchase() // your payment flow
const full = await fetchFullSource(item.id) // fetch full m3u8 + resume_offset
item.play_links = full.play_links
item.seek_time = full.resume_offset ?? 0 // resume start (default 0 = from the beginning)
item.locked = false // clear lock → wall hides + auto source swap & resume
}
</script>
resume_offsetcomes from the backend (trial = first N seconds →N/ mid-clip highlight →0/ missing →0from the start); the frontend does not derive it from the trial length (the trial isn't necessarily the start of the feature). Paid / playback routes must bessr: false(caught by the ESLintno-isr-on-paid-pathsrule).
In-session resume (rememberProgress)
Virtualization unmounts the play core for clips that leave the window, losing playback progress. rememberProgress (default true) makes the component record an in-session position keyed by play URL: swipe away from a clip → swipe back and it resumes where you left off (not from the start).
- In-session (swipe away → swipe back, same browse): the component records + resumes automatically, zero config. This progress loss is caused by virtualization unmounting, so the framework owns the fix.
- Cross-session history (come back tomorrow): the component does not touch the backend — listen to
@timeupdateand report to your backend on a throttle, pass aresumecandidate for ordinary entries andseek_timeonly for explicit continue actions, protecting writes withresume-pending. - Zero conflict with paywall resume: session progress is keyed by URL, so after a paywall source swap the new URL isn't in the table → it falls back to
item.seek_time(=resume_offset).
<!-- Disable in-session resume (every clip starts from 0 / seek_time) -->
<SwVideoFeed :items="list" :remember-progress="false" />
<!-- Cross-session history: report to backend yourself -->
<SwVideoFeed :items="list" @timeupdate="throttledReportProgress" />
Pull-to-refresh (pullToRefresh)
The standard short-video feed interaction: on the first clip at the top of the viewport, pulling down past the threshold and releasing emits @refresh (pullToRefresh is opt-in, default off). Your handler fetches fresh data (usually resetting items back to page 1 — symmetric with @load-more tail appends) and closes the loop through the controlled refreshing prop: set it true to keep the indicator, false when done.
- Touch only: desktop wheel / keyboard never trigger (desktop has F5 / buttons).
- No fights with internals: the gesture is only claimed on "first clip + top + unlocked" (no preroll, no forced-view ad lock); everything else passes through to native scrolling. The indicator floats over the top and never shifts content.
- Render your own indicator via the
#refresh-indicatorslot ({ pulling, progress, refreshing }, animate your brand splash byprogress); a minimal default (arrow flips when armed → spinner while refreshing, honoringprefers-reduced-motion) ships when omitted.
Pull-to-refresh (try on touch / device emulation)
Hijacking-browser block (#hijacking-browser slot)
<SwVideoFeed :items="list" pull-to-refresh :refreshing="refreshing" @refresh="onRefresh">
<template #refresh-indicator="{ progress, refreshing }">
<BrandWave :progress="progress" :active="refreshing" />
</template>
</SwVideoFeed>
const refreshing = ref(false)
async function onRefresh() {
refreshing.value = true
try {
list.value = await api.feed({ page: 1 })
page.value = 1
} finally {
refreshing.value = false
}
}
Loading and buffering feedback
A buffering indicator shows both while the first frame is loading and when playback stalls mid-video — built into the component, on by default, no applier changes required.
Why it defaults to on: with no feedback at all, users cannot tell "still loading" from "this one is broken". Making every consumer rewrite it is a bad default, so it lives in the component.
Anti-flicker: a waiting event often lasts only tens of milliseconds (seeking, network jitter), so the indicator waits 300 ms before appearing — brief stalls never flash. This affects the UI only; the @waiting event fires immediately, so your analytics timestamps are unaffected.
The indicator never touches the cover or the picture: when playback stalls mid-video the current frame stays exactly where it was (a spinner is simply layered on top) — it does not jump back to the cover image.
Customising the indicator
Three levels, pick what you need:
| Goal | How |
|---|---|
| Recolour / resize | Set the --sw-video-feed-loading-* CSS variables |
| Render your own | The #loading slot |
| Switch it off | Pass an empty slot |
<SwVideoFeed :items="list">
<!-- Your own: the wrapper already handles visibility, this only has to draw -->
<template #loading>
<MyBrandSpinner />
</template>
</SwVideoFeed>
<SwVideoFeed :items="list">
<!-- Off: the container stays but is empty — invisible, and it never blocks gestures -->
<template #loading />
</SwVideoFeed>
CSS variables
The container height and the position / size / colors of the minimal controls are all tunable via CSS variables (set them on the container or a parent):
| Variable | Default | Description |
|---|---|---|
--sw-video-feed-height | 100dvh | Feed height (set to 100% etc. inside a fixed-height parent) |
--sw-video-feed-mute-right | 1rem | Mute button distance from the right |
--sw-video-feed-mute-bottom | 2.5rem | Mute button distance from the bottom (bottom-right, thumb-reachable; put the business icon column above it) |
--sw-video-feed-progress-inset | 0.5rem | Progress bar inset left/right (prevents edge-swipe gesture conflicts) |
--sw-video-feed-progress-bottom | 0 | Progress bar distance from the bottom |
--sw-video-feed-refresh-z | 6 | Pull-to-refresh indicator z-index (above business overlay, below paywall / preroll) |
--sw-video-feed-refresh-top | 1rem | Indicator distance from the top |
--sw-video-feed-refresh-bg / -color | black 40% / white 90% | Default indicator circle / icon color |
--sw-video-feed-loading-z | 7 | Loading / buffering indicator z-index (above the business overlay and arrows, below the paywall / preroll) |
--sw-video-feed-loading-size | 2.5rem | Indicator size |
--sw-video-feed-loading-bg / -color | black 40% / white 90% | Default indicator disc / icon colour |
--sw-video-feed-arrow-right | 1rem | Desktop arrow distance from the right |
--sw-video-feed-arrow-gap | 1.25rem | Gap between the two desktop arrows |
--sw-video-feed-arrow-size | 2.5rem | Desktop arrow size |
--sw-video-feed-arrow-bg | rgb(0 0 0 / 20%) | Arrow background (rest) |
--sw-video-feed-arrow-bg-hover | rgb(0 0 0 / 30%) | Arrow background (hover) |
--sw-video-feed-arrow-color | rgb(255 255 255 / 80%) | Arrow icon color |
--sw-video-feed-overlay-z | 4 | Business overlay (#overlay) z-index |
--sw-video-feed-arrows-z | 5 | Desktop arrow z-index (chrome, above the business overlay by default) |
--sw-video-feed-paywall-z | 8 | Paywall layer (#paywall) z-index |
--sw-video-feed-preroll-z | 10 | Preroll layer (#preroll-ad) z-index (full-screen takeover, topmost) |
--sw-video-feed-loading-color | rgb(255 255 255 / 90%) | Loading indicator color |
--sw-video-feed-refresh-color | rgb(255 255 255 / 90%) | Pull-to-refresh indicator color |
--sw-video-feed-preroll-enter-bg | rgb(255 255 255 / 20%) | Background of the entry-preroll "enter" button |
--sw-video-feed-mute-bg | rgb(0 0 0 / 40%) | Mute button background (inside FeedVideo) |
--sw-video-feed-mute-color | rgb(255 255 255) | Mute button icon color (inside FeedVideo) |
--sw-video-feed-progress-track | rgb(255 255 255 / 30%) | Progress track color (inside FeedVideo) |
--sw-video-feed-progress-fill | rgb(255 255 255 / 85%) | Progress filled color |
--sw-video-feed-progress-fill-active | rgb(255 255 255) | Filled color while dragging (the expanded state of the two-stage gesture) |
--sw-video-feed-progress-handle | rgb(255 255 255) | Progress drag handle color |
--sw-video-feed-seek-bg | rgb(0 0 0 / 55%) | Background of the time bubble shown while seeking |
--sw-video-feed-seek-color | rgb(255 255 255) | Current-time color inside the seek bubble |
--sw-video-feed-seek-total-color | rgb(255 255 255 / 65%) | Total-duration color inside the seek bubble |
<!-- Embed in a fixed-height container + raise the mute button -->
<div style="height: 600px">
<SwVideoFeed :items="list" style="--sw-video-feed-height: 100%; --sw-video-feed-mute-bottom: 4rem" />
</div>
Imperative control
Call the exposed methods via a template ref:
<script setup>
const feedRef = ref()
</script>
<template>
<SwVideoFeed ref="feedRef" :items="list" />
<button @click="feedRef.next()">Next</button>
<button @click="feedRef.scrollToIndex(0)">Back to first</button>
<button @click="feedRef.setMuted(false)">Unmute</button>
</template>
Playback rate & line switching (settings-menu scenario)
The feed doesn't build a settings menu UI (a TikTok-style feed intentionally has none), but it provides imperative APIs + #overlay scope so the business can render its own settings panel (e.g. as a rate / line replacement when migrating a PC video site off artplayer):
setPlaybackRate(rate): takes effect for the current and all subsequent clips (survives flipping / remount);ratemust be positive, invalid values are ignored.switchLine(code): switch the play route (acodefromplay_links), re-attaching the current clip while preserving playback progress (the progress migrates to the new route's session table and resumes after remount).- The
#overlayscope exposes{ playbackRate, lineCode }(reactive) for the settings panel to display the current state.
<SwVideoFeed ref="feedRef" :items="list" link-code="line1">
<template #overlay="{ playbackRate, lineCode }">
<div class="settings">
<button @click="feedRef.setPlaybackRate(playbackRate === 1 ? 2 : 1)">Rate {{ playbackRate }}x</button>
<button @click="feedRef.switchLine(lineCode === 'line1' ? 'line2' : 'line1')">Line {{ lineCode }}</button>
</div>
</template>
</SwVideoFeed>
Props
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
items | VideoFeedItem[] | ✅ | — | Array of clips (see Data format) |
initialIndex | number | 0 | Which clip to land on at entry (instant) | |
windowSize | number | 1 | Player-pool radius (current ±N mount a real core, rest show cover only) | |
preload | boolean | number | true | Preload the first 5s of the next N video items: default/true = 2, a number = custom count, false/0 = no preloading (cover shows until loading starts after the swipe settles — cheapest on CDN traffic). Ad entries are skipped without consuming quota; the tail clamps naturally. Second-level precision only on the hls.js path; iOS Safari native HLS is best-effort | |
blockHijackingBrowsers | boolean | true | Block hijacking browsers (UC / Quark / QQ / Baidu). These replace the page's <video> with their own player — our element is never used, so no playback strategy can help; and they do it to offer download / save-to-cloud, which is a net loss for the content owner, so we do not want playback there even if it worked. Renders a switch-browser notice instead of the player (Safari on iOS, Chrome on Android); fully overridable via the #hijacking-browser slot. WeChat is deliberately not blocked (standard WKWebView + largest traffic source) | |
loadMoreThreshold | number | 5 | Fire @load-more when fewer than this many remain | |
finished | boolean | false | Parent says no more data (stops @load-more once true) | |
muted | boolean | true | Initial mute; false = try to enter with sound, auto-fallback to muted if blocked | |
linkCode | string | undefined | Default route (item.link_code takes priority) | |
sensitivity | { wheel?: number } | undefined | Wheel / trackpad sensitivity multiplier (default 1) | |
ioThreshold | number | 0.6 | Visibility ratio at which IntersectionObserver picks the current clip | |
fillThreshold | number | false | false | Crop to fill when ratio similarity ≥ this; false = no crop, leaves black bars | |
arrows | boolean | true | Desktop up/down arrows (hover devices only, disabled at endpoints, hidden on mobile) | |
prerollAd | boolean | false | Enable the entry preroll (one-shot, before the first clip; content via #preroll-ad, decoupled from items) | |
prerollAdDuration | number | 5000 | Entry preroll countdown duration (milliseconds, must be > 0) | |
prerollAdAutoClose | boolean | false | Auto-enter the main content when the preroll countdown hits zero; false shows "Enter" on zero | |
prerollAdForceWatch | boolean | false | Preroll forced view (opt-in): lock flipping during the countdown, auto-unlock on zero; the slot renders ✕ / countdown based on canSkip | |
adSkipAfter | number | 0 | Feed-level fallback for the In-Feed Ad forced-view duration (millisecnds), 0 = not forced; override per item with item.skipAfter | |
track | VideoFeedTrackProp | undefined | WebSDK playback-behavior tracking (opt-in), see "Playback tracking" below | |
rememberProgress | boolean | true | In-session resume: records progress when you swipe away from a clip, resumes on remount (keyed by play URL). Cross-session history goes through your own @timeupdate | |
pullToRefresh | boolean | false | Pull-to-refresh (opt-in, touch only): pull past the threshold on the first clip at the top to emit @refresh | |
refreshing | boolean | false | Controlled refreshing state: set true on @refresh to keep the indicator, false when data is in | |
endMode | 'loop' | 'next' | 'loop' | What happens when a clip ends: loop replays it (short-video default); next advances to the next item and turns loop off — see "End-of-clip behaviour" above |
Playback tracking (:track.video)
A feed is a multi-item stream, so track.video takes a mapping function (item, index) => MediaTrackVideoSpec (return undefined to exempt an item):
<SwVideoFeed
:items="videos"
:track="{ video: (item, i) => ({
video_id: String(item.id), video_title: item.title ?? '',
video_content_type: 'short_video',
recommend_trace_id: item.trace_id ?? '',
}) }"
/>
- Only the active item counts (play/pause noise from windowed neighbors is filtered automatically); switching items resets the behavior gates.
- Behavior vocabulary matches SwVideoPlayer playback tracking; feed videos loop by default with no natural
ended— completion is covered byplay_progress_100. - Escape hatch: new raw pass-through events
@play(index, item)/@pause(index, item)/@timeupdate(index, currentTime, duration)for your own tracking SDK.
Events
| Name | Payload | When |
|---|---|---|
change | (index: number, item: VideoFeedItem) | The current clip changes |
load-more | () | Fewer than loadMoreThreshold remain — parent should fetch the next page |
reach-end | () | Scrolled to the bottom after finished |
canplay | (index: number, item: VideoFeedItem) | The current clip is playable |
error | (index: number, item: VideoFeedItem, err: unknown) | The current clip errored. Fatal errors only — non-fatal ones (segment 404, transient network) are retried by hls.js and no longer bother consumers |
paywall | (index: number, item: VideoFeedItem) | A locked clip's trial segment finished and the paywall is shown (hook your wall-impression conversion tracking here) |
play | (index: number, item: VideoFeedItem) | The current clip starts playing (native passthrough, escape hatch for your own tracking) |
pause | (index: number, item: VideoFeedItem) | The current clip pauses (native passthrough) |
timeupdate | (index: number, currentTime: number, duration: number) | Playback progress updates (native passthrough) |
resume-pending | (index: number, item: VideoFeedItem, pending: boolean) | Block history writes for this item while its resume decision is pending. |
waiting | (index: number, item: VideoFeedItem) | That item started waiting for data (native waiting, forwarded). The UI side is already covered by the built-in indicator; this event is for your own analytics (stall rate). Fired immediately — the indicator's anti-flicker delay does not apply to it |
autoplay-muted | (index: number, item: VideoFeedItem) | Unmuted autoplay was blocked by the browser and playback fell back to muted — muted=false never guarantees sound; show an "enable sound" hint on this event |
refresh | () | Pull-to-refresh released past the threshold: reset items and close the loop via controlled refreshing |
ended | (index: number, item: VideoFeedItem) | The item finished playing. ⚠️ Under endMode='loop' (default) ordinary items have loop=true, and per the HTML spec no ended is dispatched; use endMode='next' or derive a milestone from timeupdate |
seeked | (index: number, currentTime: number) | Seek completed — either the user dragged the progress bar or seek() was called. timeupdate cannot tell scrubbing apart from normal playback, so this is the only signal for tracking jumps |
controls-show / controls-hide | (index: number) | A user-activity window opened or closed. Use it to fade your own #overlay UI in and out together with the control bar. Only the current item is forwarded; the event names match SwVideoPlayer. Consumable on both touch and desktop — you no longer need your own timer (fw#350): on touch it is "tap to reveal, hide after 3s while playing, stay while paused" (since fw#372 a horizontal drag started on the scrubber while playing also opens the activity window — the drag fires a controls-show / controls-hide pair, so business chrome briefly appearing during a drag is expected, not a bug); on desktop it is "show on mouse move, hide after 3s idle". Both are driven by the same state machine, so your overlay and the control bar stay inherently in sync (something a separate timer cannot achieve). Mouse activity is a pure activity signal and never touches playback (only a click toggles play/pause); pointermove from touch is deliberately ignored so the existing tap semantics stay intact. The listener sits on the feed root, so moving the mouse over your own #overlay UI counts as activity too. 🔴 Do not read the name literally as "controls show/hide": this component runs bare hls + <video> and has no artplayer-style chrome. The activity window only drives the scrubber's expanded state (thicker + drag handle + draggable). The scrubber itself is always visible and never hides, and the mute button is governed by muted, not by this event (corrected in fw#351). If your floating layer lives outside the feed (page DOM rather than the #overlay slot), mouse movement over it never reaches this component — call markActivity() to keep the window open, see below |
Slots
| Name | Scope | Description |
|---|---|---|
overlay | { item, index, active, playbackRate, lineCode } | Per-clip business overlay (likes / author / side bar / follow / settings menu etc.) — the framework does not build this UI; playbackRate / lineCode pair with rate & line switching |
paywall | { item, index, active, skipToNext } | Paywall content (shown when an item.locked clip's trial ends): you render the purchase UI (unlock / skip this video, no close X); skipToNext() = go to next clip |
cover | { item } | Custom cover (defaults to item.img as a cover-cropped background image; video items only) |
in-feed-ad | { item, index, active, countdown, canSkip } | In-Feed Ad (kind:'ad') creative: the business renders image + link + impression tracking; countdown = forced-view seconds left, canSkip = whether swiping away is allowed |
preroll-ad | { countdown, skip, canSkip } | Entry preroll content: countdown = seconds left, skip() = enter main content early, canSkip = whether skippable now (false during a forced-view lock, same as #in-feed-ad); defaults to a countdown + "Enter" when not provided |
loading | { item, index } | Loading / buffering indicator: the wrapper already decides visibility from the buffering state, so the slot only has to draw. Falls back to a minimal spinner; pass an empty slot (<template #loading />) to switch it off |
refresh-indicator | { pulling, progress, refreshing } | Pull-to-refresh indicator (floats over the top, never shifts content): animate your brand splash by progress; minimal arrow / spinner default |
hijacking-browser | { browser, suggested } | Full notice shown when a hijacking browser is detected — it replaces the player rather than covering it. browser is the matched browser id (uc / qq / baidu); suggested is the recommended browser name (Safari on iOS, Chrome elsewhere) |
Exposed methods (defineExpose)
| Member | Signature | Description |
|---|---|---|
next | () => void | Flip to the next clip (fires reach-end at the bottom when finished) |
prev | () => void | Flip to the previous clip |
scrollToIndex | (i: number, opts?: { immediate?: boolean }) => void | Scroll to a given clip (immediate = no animation) |
setMuted | (b: boolean) => void | Set the feed-level mute state |
skip | () => void | End the entry preroll and enter the main content (same as the slot's skip()) |
setPlaybackRate | (rate: number) => void | Set the playback rate (persists across current and subsequent clips; non-positive values ignored) |
switchLine | (code: string) => void | Switch the play route (re-attaches the current clip, preserving progress) |
play | () => void | Play the current item (silent no-op when it has no playback core) |
pause | () => void | Pause the current item |
togglePlay | () => void | Toggle play/pause on the current item |
seek | (time: number) => void | Seek the current item (clamped to [0, duration]) |
getVideo | () => HTMLVideoElement | null | Escape hatch: the current item's <video> element (mirrors SwVideoPlayer.getPlayer()). ⚠️ Invalidated when virtualization unmounts the item — fetch it on each use, never cache |
markActivity | () => void | Extends the user-activity window (fw#351): keeps the scrubber expanded and pushes controls-hide back by 3s. Use it when your floating layer lives outside the feed (mouse movement there never reaches this component) or for an entry grace period — call it periodically while hovering. 🔴 It can only extend, never veto hiding: the hide decision belongs solely to this component. Do not intercept controls-hide — having both sides decide when to hide is exactly the self-built-timer problem again |
activeIndex | Ref<number> | The current clip index |
Control Color Tokens
Playback-control colors (play icon / seek time / mute button / progress bar) are themeable via --sw-video-feed-* tokens (fallbacks are the default media-context black/white values):
--sw-video-feed-play-icon-color (white 60%) · -seek-bg (black 55%) · -seek-color (white) · -seek-total-color (white 65%) · -mute-bg (black 40%) · -mute-color (white) · -progress-track (white 30%) · -progress-fill (white 85%) · -progress-fill-active (white) · -progress-handle (white)
Notes
- Render business UI in
#overlay: likes / author / follow / side icon column etc. all go inside#overlay; the framework doesn't build them. The slot container ispointer-events: none(the video stays clickable / swipeable); only direct children get a zero-specificitypointer-events: autofallback and deeper elements inherit — sopointer-events: nonewritten at any level inside the slot takes effect and cascades down (a hidden action column no longer swallows clicks), no need for avisibility: hiddenworkaround. - Append in
@load-more, don't replace: doitems.value.push(...page)with the new page; replacing the whole array unmounts mounted player cores and loses progress. - Set
--sw-video-feed-heightin a fixed-height container: the component defaults to100dvhfull-screen; when embedding into a card / doc box, give the container a fixed height + set--sw-video-feed-height: 100%. - Muted autoplay is the default: "autoplay with sound" is always blocked on mobile;
muted="false"may play with sound only when there is an entry gesture (SPA navigation), and auto-falls back to muted when blocked. - Three platforms: native
scroll-snapon mobile/tablet (zero config); custom wheel + keyboard + optional arrows on desktop.