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 #overlay slot — 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". muted defaults to true (muted playback + a mute button to unmute); passing muted="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 declares pan-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

  • initialIndex sets which clip to land on at entry (e.g. the index clicked from a cover list); default 0 (instant, no animation).
  • windowSize is the player-pool radius: default 1 (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.
  • ioThreshold is the visibility ratio at which IntersectionObserver decides the "current clip"; default 0.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 deltaY flips clips (one gesture = one clip, with a cooldown to prevent momentum over-flipping). sensitivity.wheel is the multiplier applied to the accumulated amount (default 1; larger = more sensitive).
  • Keyboard (when the container is focused): ↑ / ↓ flip clips, Space toggles play/pause of the current clip.
  • arrows (default true): 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):

  1. 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
  2. Forced-watch windows are respected — no advance while an in-feed ad or entry preroll countdown is still running
  3. Last item — emits reach-end when finished is true (handle it yourself, e.g. toast + go back); when finished is false it simply stops. Continuous play is deliberately decoupled from pagination — rely on loadMoreThreshold to 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:

CaseSimilarity0.90.8
Standard 9:16 clip on a 16:9 phone1.000✂ crops✂ crops
Standard 9:16 clip on 19.5:9 (mainstream)0.822❌ still letterboxed✂ crops
Standard 9:16 clip on 20:90.800❌ still letterboxed✂ crops
Legacy 3:4 footage0.616letterboxedletterboxed
16:9 landscape in a portrait box0.260letterboxedletterboxed

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); with fillThreshold set, 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 blocks next/prev/wheel, mobile freezes native scrolling) + countdown; on zero it unlocks and canSkip flips to true.
  • Override per item with item.skipAfter, fall back at the feed level with adSkipAfter.

Tracking belongs to the business: the feed does not track ad items — the business uses SwAd* (built-in impression / click) or useAdTrack inside #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 prerollAdForceWatch on, flipping is locked during the countdown (wheel / keyboard ↑↓ / touch / navigation all blocked), then auto-unlocks on zero (same model as the in-feed ad adSkipAfter); the slot renders ✕ / countdown based on canSkip.
  • With prerollAdAutoClose=true it auto-enters the main content when the countdown hits zero; with false it shows the default "Enter" button on zero (or the business skip()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 locked clip's trial segment finishes (the play core does not loop locked clips, freezing on the last frame) → the #paywall slot 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_links to the full source + set seek_time to the backend's resume_offset + clear locked; the component re-attaches the source and seeks to the resume point automatically (reusing the seek_time start mechanism). The framework provides no unlock() (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_offset comes from the backend (trial = first N seconds → N / mid-clip highlight → 0 / missing → 0 from 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 be ssr: false (caught by the ESLint no-isr-on-paid-paths rule).

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 @timeupdate and report to your backend on a throttle, pass a resume candidate for ordinary entries and seek_time only for explicit continue actions, protecting writes with resume-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-indicator slot ({ pulling, progress, refreshing }, animate your brand splash by progress); a minimal default (arrow flips when armed → spinner while refreshing, honoring prefers-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:

GoalHow
Recolour / resizeSet the --sw-video-feed-loading-* CSS variables
Render your ownThe #loading slot
Switch it offPass 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):

VariableDefaultDescription
--sw-video-feed-height100dvhFeed height (set to 100% etc. inside a fixed-height parent)
--sw-video-feed-mute-right1remMute button distance from the right
--sw-video-feed-mute-bottom2.5remMute button distance from the bottom (bottom-right, thumb-reachable; put the business icon column above it)
--sw-video-feed-progress-inset0.5remProgress bar inset left/right (prevents edge-swipe gesture conflicts)
--sw-video-feed-progress-bottom0Progress bar distance from the bottom
--sw-video-feed-refresh-z6Pull-to-refresh indicator z-index (above business overlay, below paywall / preroll)
--sw-video-feed-refresh-top1remIndicator distance from the top
--sw-video-feed-refresh-bg / -colorblack 40% / white 90%Default indicator circle / icon color
--sw-video-feed-loading-z7Loading / buffering indicator z-index (above the business overlay and arrows, below the paywall / preroll)
--sw-video-feed-loading-size2.5remIndicator size
--sw-video-feed-loading-bg / -colorblack 40% / white 90%Default indicator disc / icon colour
--sw-video-feed-arrow-right1remDesktop arrow distance from the right
--sw-video-feed-arrow-gap1.25remGap between the two desktop arrows
--sw-video-feed-arrow-size2.5remDesktop arrow size
--sw-video-feed-arrow-bgrgb(0 0 0 / 20%)Arrow background (rest)
--sw-video-feed-arrow-bg-hoverrgb(0 0 0 / 30%)Arrow background (hover)
--sw-video-feed-arrow-colorrgb(255 255 255 / 80%)Arrow icon color
--sw-video-feed-overlay-z4Business overlay (#overlay) z-index
--sw-video-feed-arrows-z5Desktop arrow z-index (chrome, above the business overlay by default)
--sw-video-feed-paywall-z8Paywall layer (#paywall) z-index
--sw-video-feed-preroll-z10Preroll layer (#preroll-ad) z-index (full-screen takeover, topmost)
--sw-video-feed-loading-colorrgb(255 255 255 / 90%)Loading indicator color
--sw-video-feed-refresh-colorrgb(255 255 255 / 90%)Pull-to-refresh indicator color
--sw-video-feed-preroll-enter-bgrgb(255 255 255 / 20%)Background of the entry-preroll "enter" button
--sw-video-feed-mute-bgrgb(0 0 0 / 40%)Mute button background (inside FeedVideo)
--sw-video-feed-mute-colorrgb(255 255 255)Mute button icon color (inside FeedVideo)
--sw-video-feed-progress-trackrgb(255 255 255 / 30%)Progress track color (inside FeedVideo)
--sw-video-feed-progress-fillrgb(255 255 255 / 85%)Progress filled color
--sw-video-feed-progress-fill-activergb(255 255 255)Filled color while dragging (the expanded state of the two-stage gesture)
--sw-video-feed-progress-handlergb(255 255 255)Progress drag handle color
--sw-video-feed-seek-bgrgb(0 0 0 / 55%)Background of the time bubble shown while seeking
--sw-video-feed-seek-colorrgb(255 255 255)Current-time color inside the seek bubble
--sw-video-feed-seek-total-colorrgb(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); rate must be positive, invalid values are ignored.
  • switchLine(code): switch the play route (a code from play_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 #overlay scope 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

NameTypeRequiredDefaultDescription
itemsVideoFeedItem[]✅—Array of clips (see Data format)
initialIndexnumber0Which clip to land on at entry (instant)
windowSizenumber1Player-pool radius (current ±N mount a real core, rest show cover only)
preloadboolean | numbertruePreload 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
blockHijackingBrowsersbooleantrueBlock 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)
loadMoreThresholdnumber5Fire @load-more when fewer than this many remain
finishedbooleanfalseParent says no more data (stops @load-more once true)
mutedbooleantrueInitial mute; false = try to enter with sound, auto-fallback to muted if blocked
linkCodestringundefinedDefault route (item.link_code takes priority)
sensitivity{ wheel?: number }undefinedWheel / trackpad sensitivity multiplier (default 1)
ioThresholdnumber0.6Visibility ratio at which IntersectionObserver picks the current clip
fillThresholdnumber | falsefalseCrop to fill when ratio similarity ≥ this; false = no crop, leaves black bars
arrowsbooleantrueDesktop up/down arrows (hover devices only, disabled at endpoints, hidden on mobile)
prerollAdbooleanfalseEnable the entry preroll (one-shot, before the first clip; content via #preroll-ad, decoupled from items)
prerollAdDurationnumber5000Entry preroll countdown duration (milliseconds, must be > 0)
prerollAdAutoClosebooleanfalseAuto-enter the main content when the preroll countdown hits zero; false shows "Enter" on zero
prerollAdForceWatchbooleanfalsePreroll forced view (opt-in): lock flipping during the countdown, auto-unlock on zero; the slot renders ✕ / countdown based on canSkip
adSkipAfternumber0Feed-level fallback for the In-Feed Ad forced-view duration (millisecnds), 0 = not forced; override per item with item.skipAfter
trackVideoFeedTrackPropundefinedWebSDK playback-behavior tracking (opt-in), see "Playback tracking" below
rememberProgressbooleantrueIn-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
pullToRefreshbooleanfalsePull-to-refresh (opt-in, touch only): pull past the threshold on the first clip at the top to emit @refresh
refreshingbooleanfalseControlled 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 by play_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

NamePayloadWhen
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

NameScopeDescription
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)

MemberSignatureDescription
next() => voidFlip to the next clip (fires reach-end at the bottom when finished)
prev() => voidFlip to the previous clip
scrollToIndex(i: number, opts?: { immediate?: boolean }) => voidScroll to a given clip (immediate = no animation)
setMuted(b: boolean) => voidSet the feed-level mute state
skip() => voidEnd the entry preroll and enter the main content (same as the slot's skip())
setPlaybackRate(rate: number) => voidSet the playback rate (persists across current and subsequent clips; non-positive values ignored)
switchLine(code: string) => voidSwitch the play route (re-attaches the current clip, preserving progress)
play() => voidPlay the current item (silent no-op when it has no playback core)
pause() => voidPause the current item
togglePlay() => voidToggle play/pause on the current item
seek(time: number) => voidSeek the current item (clamped to [0, duration])
getVideo() => HTMLVideoElement | nullEscape hatch: the current item's <video> element (mirrors SwVideoPlayer.getPlayer()). ⚠️ Invalidated when virtualization unmounts the item — fetch it on each use, never cache
markActivity() => voidExtends 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
activeIndexRef<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 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.
  • Append in @load-more, don't replace: do items.value.push(...page) with the new page; replacing the whole array unmounts mounted player cores and loses progress.
  • Set --sw-video-feed-height in a fixed-height container: the component defaults to 100dvh full-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-snap on mobile/tablet (zero config); custom wheel + keyboard + optional arrows on desktop.