SwVideoPlayer

SwVideoPlayer is a video player built on top of Artplayer + hls.js, supporting HLS live / VOD streams, multi-track switching, ad pre-roll, history reporting, and other common business scenarios. All dynamic libraries are imported asynchronously in onMounted; the component is wrapped in <client-only> and is SSR-safe.

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.

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.

Video Source (one of two)

  • Direct URL mode: pass url + type for a single video address
  • Multi-track mode: pass links for a track list, paired with v-model:linkCode

Can it play H.265 (HEVC)? The player stack (Artplayer + hls.js ≥1.6) imposes no limit of its own — it depends on the device's hardware decoding (native on Safari; Chrome/Edge 105+ only with hardware support; effectively unsupported on Firefox). Your transcoder should output dual HEVC + H.264 codec variants so the player picks a supported one automatically; see Deployment · video transcoding for the spec and anti-pattern.

Basic Usage

Simplest form: mp4 direct link + poster + title (demos use a small local video; type must match the url, and :on-history="null" disables the built-in reporting since the docs site has no backend).

Basic playback (mp4 direct link)

加载中...
<!-- Direct URL -->
<SwVideoPlayer url="https://example.com/video.m3u8" />
<!-- Multi-track with v-model:linkCode (two-way sync) -->
<script setup lang="ts">
const currentCode = ref('line-1')
const links = [
  { code: 'line-1', id: 101, lid: 1, m3u8_url: 'https://a.com/1.m3u8' },
  { code: 'line-2', id: 101, lid: 2, m3u8_url: 'https://b.com/2.m3u8' },
]
</script>

<template>
  <SwVideoPlayer v-model:link-code="currentCode" :links="links" />
</template>

Common config combos

Muted autoplay (autoplay requires muted — a hard mobile constraint) + loop + picture-in-picture + theme color.

Muted autoplay + loop + theme

加载中...

Imperative Control via ref

<script setup lang="ts">
const playerRef = ref()

const onPlay = () => playerRef.value?.play()
const onSeek = () => playerRef.value?.seek(60)
</script>

<template>
  <SwVideoPlayer ref="playerRef" url="..." @ready="(art) => console.log('artplayer ready', art)" />
</template>

Resume Playback

<script setup lang="ts">
const savedTime = ref(120)
</script>

<template>
  <SwVideoPlayer
    url="https://example.com/video.m3u8"
    :start-time="savedTime"
    @timeupdate="(t) => (savedTime = t)"
  />
</template>

A resume position near the end (≥ 95%, or inside the skip credits range) counts as watched and plays from the start, so a last episode or movie doesn't open on its final frame. When the duration is only known after metadata loads (common with HLS), the player seeks to the saved position first and re-checks once the duration arrives.

Pre-Roll Ad (preroll-ad)

Pre-roll countdown + #preroll-ad slot + skip

加载中...

The player only ships the pre-roll phase state machine (blocking the content / countdown / release) — it does not ship ad content. Pre-roll visuals (carousel / static image etc.) are always rendered by the business via the #preroll-ad slot, consistent with the component family convention "engine in the framework, visuals in slots".

  • With prerollAd=true and prerollAdDuration > 0, the pre-roll layer shows first and counts down
  • At zero: prerollAdAutoClose=true auto-closes the layer and starts the content; false (default) shows an "enter" button awaiting the user
  • Slot scope { countdown, skip }: countdown is the remaining seconds; skip() ends the pre-roll early and starts the content (for business "skip ad" buttons)
  • Without the slot, the default content renders only the countdown + "enter" button
<script setup lang="ts">
// Aligned with the legacy backend play_ads protocol: item shape { id, name, content(image), link }
const { data: r } = await useFetch('/movie/detail/1')
</script>

<template>
  <SwVideoPlayer
    url="https://example.com/video.m3u8"
    :preroll-ad="!!r.play_ads?.length"
    :preroll-ad-duration="+r.play_ads_time"
    :preroll-ad-auto-close="r.play_ads_auto_jump === 'y'"
  >
    <template #preroll-ad="{ countdown, skip }">
      <SwCarousel :items="r.play_ads" :loop="r.play_ads.length > 1">
        <template #default="{ item }">
          <SwLink to="/link" :params="item.link"><SwImage :src="item.content" :alt="item.name" /></SwLink>
        </template>
      </SwCarousel>
    </template>
  </SwVideoPlayer>
</template>

prerollAdAutoClose is not YouTube-style "skippable button" semantics — the countdown always completes; it only decides whether to auto-release at zero or wait for the user to click "enter". For a "skip after N seconds" button, compose countdown + skip() inside the slot.

Play next

With show-play-next the control bar gains a "next episode" button; clicking emits play-next and your code switches the url.

With show-play-next on, the player also autoplays the next episode (framework#427): when the episode ends (or enters the credits, see the next section) it shows "Next episode in 5s · Cancel" and emits the same play-next when the countdown finishes — your code handles one callback. After Cancel it won't show again for this episode; it never shows while locked (paywall). Users can turn off "Autoplay next episode" in the settings panel (preference: useVideoPlayerPrefs).

The event carries { source: 'manual' | 'auto' }: control-bar clicks use manual, and completed countdowns use auto. Existing handlers that ignore the argument remain compatible. With episode paths, use router.push for manual navigation and router.replace for automatic navigation; the player does not navigate itself.

Turn show-play-next off on the last episode — the player doesn't know episode order and would otherwise count down there too.

Pausing, seeking, or replaying after the end cancels a pending next action without rearming it for this source load. The pause caused by natural completion preserves normal continuous playback. Background, buffering, preroll and paywall phases do not advance the timer.

Play-next button + @play-next episode switch

加载中...
<!-- show-play-next renders the "next episode" button; clicking it emits @play-next -->
<SwVideoPlayer url="..." title="Episode 1" :show-play-next="true" @play-next="loadNextEpisode" />

Fullscreen and binge playback

The player has a single fullscreen button (framework#453):

  • Entering: requests native browser fullscreen on the page root (<html>), hiding the browser UI. The player root gets .is-fullscreen and fills the viewport with position: fixed + z-index: 9999.
  • Fallback: when the browser lacks or refuses element fullscreen (iPhone Safari has no element fullscreen), the player stays in the same full-viewport layer and the address bar remains visible.
  • Triggers: the control-bar button (tips "Fullscreen" / "Exit fullscreen"; focusable with Tab, activated with Enter / Space), the F key (while the player has focus; while this player is fullscreen, F exits even without focus; disabled by hotkey=false), and double-click on desktop. On mobile, double-tap still toggles play / pause. Esc exits; in native fullscreen the system back gesture exits too.
  • Landscape videos: after native fullscreen succeeds, the player tries to lock landscape orientation (where supported) and unlocks on exit.
  • @fullscreen: emitted once on enter and once on exit. When a new player takes over the session with the same key after an episode switch, it emits true on mount, so you don't need to read the state separately. Entering / leaving the iPhone system player is reported here as well.
  • The pre-roll ad, paywall, pause ad and notices all live inside the player root, so they stay visible in fullscreen.

Keep fullscreen across episodes (fullscreenKey)

Switching episodes usually rebuilds the page and the player. Native fullscreen lives on <html>, so it survives the old player being destroyed; a new player that passes the same fullscreen-key takes over the fullscreen session:

<!-- Pass the title ID, not the episode ID: episodes of the same title keep fullscreen, another title exits -->
<SwVideoPlayer :links="playLinks" show-play-next :fullscreen-key="String(work.id)" @play-next="navigateTo(nextPath)" />
  • Same title, next episode (route navigation or a same-page :key rebuild): fullscreen is kept; no state to track in your code.
  • Another title / leaving the player page: if no player with the same key takes over once the page switch finishes, fullscreen exits and the scroll position is restored.
  • No key: fullscreen exits when the player unmounts.

Known limitations

  • iPhone Safari: only the full-viewport layer is available, so the address bar stays. After switching episodes the next one does not autoplay; the user taps once (iOS grants playback permission per media element).
  • Ancestor styles: if an ancestor of the player has transform / filter / perspective / container-type (the layer cannot fill the viewport), or creates a stacking context (a positioned element with z-index, opacity < 1, isolation: isolate, etc., which confines the layer), fullscreen may not fill the screen or may sit below the site header. In development a console warning is shown on entering fullscreen; use .is-fullscreen to lift those styles while fullscreen:
/* While fullscreen, drop the ancestor's container-type and hide floating entries that should not cover the player */
body:has(.sw-video-player.is-fullscreen) .player-wrap { container-type: normal; }
body:has(.sw-video-player.is-fullscreen) .floating-tools { display: none; }
  • Overlays that must sit above fullscreen (such as an in-fullscreen episode panel) need a z-index above 9999.

Migrating from #447

  • Remove restore-web-fullscreen and any state that records "was fullscreen before switching", and pass fullscreen-key instead (restoreWebFullscreen has been removed, framework#454).
  • Styles that depend on .art-fullscreen-web / .art-fullscreen should use .sw-video-player.is-fullscreen.
  • Site-specific fullscreen controllers (for example calling native fullscreen on the root element) can be deleted in favour of the component.
  • Do not read or write getPlayer().fullscreen / fullscreenWeb any more: the component no longer uses Artplayer's fullscreen, and writing them conflicts with the component's fullscreen state.

Skip intro and credits

skip (seconds) tells the player where the intro and credits are (framework#427):

<SwVideoPlayer
  :links="links"
  :show-play-next="hasNext"
  :skip="{ introStart: null, introEnd: 90, outroLength: 105 }"
  @play-next="playNext"
/>
  • Intro = [introStart ?? 0, introEnd). Only set introStart when a cold open plays before the intro. After each new source loads, reaching this range jumps to introEnd and shows "Intro skipped · Undo" (fades after about 4 seconds; Undo returns to where you were). At most one automatic skip per load: no skip when resuming past the intro, and no skip when the user drags back into it.
  • Credits = the last outroLength seconds of each episode (works with different episode lengths). With show-play-next on, playing into the credits starts the next-episode countdown instead of jumping — viewers can Cancel to watch a post-credits scene. Starting inside the credits, dragging or fast-forwarding into them does not start it; the countdown still runs when the episode ends.
  • Omitted / null = no skipping. Episode-order rules belong to your code, e.g. "don't skip the intro on episode 1": pass introEnd: null there; the player doesn't know episode order.
  • Users can turn off "Skip intro & credits" in the settings panel (then the intro isn't skipped and the credits don't start the countdown; the end-of-episode countdown still runs).

Event listening

Player events (play / pause / 1s-throttled timeupdate / ended) + v-model:currentTime.

Event log + v-model:currentTime

加载中...

History Reporting (onHistory tri-state)

onHistory is the core business callback for SwVideoPlayer, with zero-config defaults:

  • undefined (default): built-in reporting to POST /movie/doHistory (legacy backend convention). Reports video_play on loadedmetadata, then video_play again every 10 seconds, and video_complete on ended
  • null: completely disable reporting
  • function: override built-in implementation
<!-- Default behavior -->
<SwVideoPlayer url="..." />

<!-- Disable reporting -->
<SwVideoPlayer url="..." :on-history="null" />

<!-- Custom reporting -->
<SwVideoPlayer url="..." :on-history="(p) => analytics.track('video_play', p)" />

options Escape Hatch

<!-- Enable subtitles and screenshots (native Artplayer config, shallow-merged onto built-in options) -->
<SwVideoPlayer
  url="..."
  :options="{
    subtitle: { url: '/subtitle.vtt', type: 'vtt' },
    screenshot: true,
  }"
  @ready="(art) => art.on('subtitleUpdate', handleSubtitle)"
/>

Props

NameTypeRequiredDefaultDescription
urlstring—Direct video URL (preferred over links)
typestring'm3u8'Paired with url
linksPlayLink[][]Track list
linkCodestring—Current track code, supports v-model:linkCode
startTimenumber0Seconds to seek after init
resume{ key: string | number; time: number | null } | nullnullSame-episode private history candidate; see resume behavior below.
posterstring—Cover image
titlestring—Title
prerollAdbooleanfalseEnable the pre-roll phase (content via the #preroll-ad slot)
prerollAdDurationnumber5000Pre-roll countdown duration (milliseconds, must be > 0)
prerollAdAutoClosebooleanfalseAuto-close at zero and start the content; false shows an "enter" button
showPlayNextbooleanfalseShow "Next Episode" button; also enables the end / credits countdown and the two settings toggles (see Play next)
fullscreenKeystring—Fullscreen session owner; pass the title ID (not the episode ID). A new player with the same key takes over fullscreen, so switching episodes does not exit (see Fullscreen and binge playback)
skipSkipSegments | nullnullSkip intro / credits (seconds): { introStart?, introEnd?, outroLength? } (see Skip intro and credits)
lockedbooleanfalsePaywall lock state (path A, see Paywall): shows #paywall after the trial ends
previewReplayLimitnumberundefinedMax trial-replay count (see Paywall): undefined=unlimited / 0=disabled / N=at most N times
pauseAdbooleanfalsePause ad (see Pause Ad section): shows #pause-ad when gating passes on pause
pauseAdDelaynumber700Debounce delay (ms) from pause to ad, avoids quick pause→play / seek misfires
pauseAdMinPlayednumber5000Eligibility: won't show until played ≥ this many milliseconds
danmakubooleanfalseDanmaku capability (see Danmaku): conditionally lazy-loads artplayer-plugin-danmuku
danmakuDataDanmu[][]Danmaku data (map your backend into Danmu[]); auto-reloads on change
danmakuOptionsDanmakuOptions{}Plugin config escape hatch (shallow-merged; key defaults: emitter: false = external input, opacity: 0.8, fontSize: '4%' scales with player height, min 12px)
themestring'white'Artplayer theme color
volumenumber0.7Initial volume (0-1)
mutedbooleanfalseMuted
autoplaybooleanfalseAutoplay on mount. Without muted, mobile browsers reject it and the component retries muted, firing autoplay-muted
loopbooleanfalseLoop
playbackRatebooleantrueShow playback rate menu
pipbooleanfalsePicture-in-picture
hotkeybooleantrueEnable keyboard shortcuts
optionsPartial<ArtplayerOption>{}Artplayer native config escape hatch (shallow merge)
pluginsArtplayerPlugin[][]Custom Artplayer plugins
onHistory((p: VideoHistoryParams) => void) | nullundefinedHistory reporting: undefined → built-in /movie/doHistory; null → disable; function → override
trackVideoPlayerTrackPropundefinedWebSDK playback-behavior tracking (opt-in), see "Playback tracking" below
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)

Playback tracking (:track.video)

Pass the business content fields and playback behaviors are reported automatically — duration / progress / behavior timing are filled in by the component:

<SwVideoPlayer
  :links="links"
  :track="{ video: {
    media_id: movie.id, video_id: episode.id, video_title: movie.name, video_content_type: 'video',
    video_type_id: movie.type_id, video_type_name: movie.type_name,
    recommend_trace_id: route.query.trace_id ?? '',
  } }"
/>

Auto-reported behaviors (videoEvent, vocabulary aligned with the legacy data pipeline):

video_behavior_keyTiming
video_playPlayback starts (once per video; resume does not re-report)
video_pausePaused (only after playback started, filters init noise)
play_progress_25/50/75/100Progress milestones (once each; reset on video switch)
video_completePlayback ended (once)
video_errorPlayback error (once-gated against hls error storms)
video_heartbeatPlayback heartbeat: fires every N milliseconds while playing (N = tracker.heartbeatInterval in swiftConfig, default 30000, 0 disables); a terminal frame is flushed the moment the page is hidden (delivered via sendBeacon on the self-hosted channel, so the last segment before closing the page isn't lost); never fires while paused
video_first_frameSelf-hosted channel only (tracker.endpoint): first frame (first native playing), once per playback, carries startup_ms
  • Technical fields auto-filled: video_duration / play_duration (current position, matching legacy semantics) / play_progress / watch_time (accumulated watched seconds — wall-clock while playing, pauses excluded, not counted while the page is hidden (stops when the page goes to the background, resumes on return if still playing), each segment capped at 2 heartbeat intervals so a frozen page / sleeping device does not add its suspended time, no jumps on seek; ships with every playback event).
  • video_error covers both native video errors and hls fatal errors (since framework#425).
  • Playback-quality fields (self-hosted channel only, framework#425): play_id (per-playback id; regenerated when video_id changes, kept across line switches) · startup_ms (only on video_first_frame: ms from the last play before the first frame to the first frame; omitted if the page went to the background in between) · rebuffer_count / rebuffer_ms (accumulated rebuffers after the first frame, seek-induced waits excluded; ships with every playback event). The group WebSDK channel's behavior vocabulary and fields are unchanged.
  • Pass the content primary key as track.video.media_id explicitly: when omitted the backend falls back to video_id for attribution (dev warns once).
  • Changing track.video.video_id resets the behavior gates automatically.
  • Independent of onHistory (backend playback history) — both can be enabled together.
  • Escape hatch: with your own tracking SDK use raw events (@play / @pause / @timeupdate / @ended / @error).

Events

NamePayloadDescription
readyplayer: ArtplayerInstanceArtplayer instance ready
loadedmetadata—Metadata loaded
waiting—Buffering / waiting (playback paused for data; for loading UI)
canplay—Can play (buffering finished)
play—Fires once per playback start; fires again after a pause or source change, including hls.js autoplay
pause—Pause
seekedcurrentTime: numberSeek complete
timeupdatecurrentTime: number1s-throttled progress
update:currentTimecurrentTime: number500ms-throttled, for v-model:currentTime
ended—Playback ended
paywall—A locked clip's trial segment finished and the paywall is shown (hook your wall-impression conversion tracking here)
pause-ad—Fired when the user pauses and gating passes, on #pause-ad show (ad-impression hook)
autoplay-muted—Audible playback was blocked by the browser and the component has retried muted. muted=false does not guarantee sound — show a "tap for sound" hint on this event
play-next{ source: 'manual' | 'auto' }manual for button clicks; auto for completed countdowns
resume-pendingbooleanHold custom history writes while the resume offer is unresolved.
volume-change{ volume: number, muted: boolean }Volume or mute state changed (bridges native volumechange; fires for both). Typical use: persisting sound preference
fullscreenstate: booleanFullscreen state changed, emitted once on enter and once on exit; a player taking over after an episode switch emits true on mount (see Fullscreen and binge playback)
controls-show—Controls shown
controls-hide—Controls hidden
errorerr: unknownPlayback error. Fatal errors only — non-fatal ones (segment 404, transient network) are retried by hls.js and no longer bother consumers
update:linkCodecode: stringTrack switched, sync v-model

Slots

NameDescription
preroll-adPre-roll layer content (ad carousel / static image etc.), receives { countdown, skip } scope
loadingLoading placeholder inside the video container
paywallPaywall content (shown when a locked clip's trial ends), receives { locked } scope: you render the purchase UI (no forced X). The overlay is teleported into Artplayer's $player (fullscreen-safe)
pause-adPause Ad content (shown when pauseAd is on + gating passes), receives { close } scope: you render SwAdPause. The overlay is teleported into $player (fullscreen-safe), wrapper pointer-events:none (clicking outside the card resumes playback and closes it)
hijacking-browserFull 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). Scope: { browser, suggested }

defineExpose

Pending playback and track-switch callbacks are invalidated by pause(), destroy(), or unmounting, preventing late playback. error carries media loading failures only; unexpected playback and fullscreen operation failures go to framework error monitoring without triggering application track fallback. iOS fullscreen exit resumes according to the entry playback state and respects component pause() calls; pause intent inside native controls is outside this guarantee.

MethodDescription
play()Start playback
pause()Pause
seek(time: number)Seek to seconds
replayPreview(): booleanAfter the paywall shows: hide it + replay the trial segment from the start (re-shows the wall automatically when it ends). Returns whether it succeeded (false when not locked / previewReplayLimit reached)
destroy()Destroy player instance
switchLink(code: string)Switch to specified track (preserves progress)
getPlayer()Get the native Artplayer instance
exitFullscreen()Exit fullscreen if this player is fullscreen (returns a Promise); for cases where the player has no exit control left, such as no playable source or all lines failed
emitDanmaku(danmu: Danmu)Emit one real-time danmaku (for the external input bar)
showDanmaku() / hideDanmaku()Show / hide the danmaku layer
configDanmaku(opt)Update danmaku config at runtime (opacity / speed / area, etc.)
loadDanmaku(data: Danmu[])Reload the danmaku data source
getDanmakuPlugin()Get the artplayer-plugin-danmuku instance (null if disabled / not ready)

Paywall

Paid content shows a purchase gate when the trial segment finishes (monetization, not advertising). 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 it ends naturally with video:ended → the #paywall slot shows. Pass locked: true (compute it from your backend's paid fields, e.g. is_vip && !has_access):

  • Wall on trial end: when locked and the trial segment finishes → #paywall shows + @paywall fires (hook your "wall impression" conversion tracking here).
  • Fullscreen-safe (key): the paywall is teleported into Artplayer's $player (inside the player root) → it fills + covers in fullscreen. A sibling DOM overlay would be clipped behind fullscreen (z-index 9999) — the framework handles this; you just render the UI in the slot.
  • Resume after unlock: on purchase, clear locked + swap to the full source + seek to resume_offset — via getPlayer().switchUrl(fullUrl) + seek(resume_offset). The framework provides no unlock().
  • Replay trial (optional): a "Replay preview" button in the paywall calls replayPreview() via ref — it hides the wall and replays the trial segment from the start, re-showing the wall automatically when the trial ends. Cap it with previewReplayLimit (undefined=unlimited, default; N=at most N times, for "trial = first N minutes" cases needing anti-freeloading; 0=disabled). replayPreview() returns false when not locked or the limit is reached. The counter resets on unlock (locked→false).

#paywall scope { locked } (the player has no skipToNext; exits are unlock / page-back / replay trial):

<!-- preview-replay-limit: cap on trial replays (prevents "replay forever" freeloading; unlimited by default) -->
<SwVideoPlayer ref="playerRef" :url="trialUrl" type="m3u8" :locked="locked" :preview-replay-limit="2" @paywall="onPaywallExposure">
  <template #paywall>
    <div class="paywall">
      <p>Trial ended</p>
      <button @click="unlock">Unlock with membership</button>
    </div>
  </template>
</SwVideoPlayer>

<script setup>
const playerRef = ref()
const locked = ref(true)
async function unlock() {
  await purchase() // your payment flow
  const full = await fetchFullSource() // fetch full m3u8 + resume_offset
  const art = playerRef.value.getPlayer()
  await art.switchUrl(full.url) // swap to the full source
  playerRef.value.seek(full.resume_offset ?? 0) // seek to the resume point
  locked.value = false // clear lock → wall hides
}
</script>

resume_offset comes from the backend (trial = first N seconds → N / mid-clip highlight → 0 / missing → 0); the frontend does not derive it from the trial length. Paid / playback routes must be ssr: false (caught by the ESLint no-isr-on-paid-paths rule). The short-video feed SwVideoFeed has the same paywall.

Pause Ad

A dismissible ad layer that pops up when the user pauses the video (advertising, not monetization; strictly distinct from the paywall). Pass pause-ad to enable it; the #pause-ad slot only shows once the player's built-in gating fully passes — the ad creative / frequency capping / tracking are carried by SwAdPause inside the slot:

  • Gating (core): after video:pause, debounce delay (pauseAdDelay, default 700ms) + filter out system pauses (seeking while dragging / waiting while buffering / backgrounding / pre-roll phase / paywall showing / completion) + eligibility (played ≥ pauseAdMinPlayed) + mutual exclusion with the paywall (only one shows at a time). All pass → #pause-ad shows + @pause-ad fires (ad-impression hook).
  • Fullscreen-safe: like the paywall, the pause ad is teleported into Artplayer's $player, so it stays visible in fullscreen.
  • Doesn't block resume + closes on resume: the overlay wrapper is pointer-events:none, so clicking the picture outside the card passes through and resumes playback (the opposite of the paywall's hard fullscreen block) → video:play auto-dismisses it; SwAdPause's ✕ closes the overlay via the slot's close.

#pause-ad scope { close }:

<!-- pause-ad-delay: debounce from pause to ad popup in ms (default 700; raise it for scrub-heavy scenarios) -->
<SwVideoPlayer :url="videoUrl" type="m3u8" pause-ad :pause-ad-delay="1000" @pause-ad="onPauseExposure">
  <template #pause-ad="{ close }">
    <SwAdPause :items="pauseAds" slot-key="play_pause" @close="close" />
  </template>
</SwVideoPlayer>

Pause ad (pause-triggered, closes on resume)

加载中...

Hijacking-browser block (#hijacking-browser slot)

加载中...

The pause ad reuses the paywall's "fullscreen-safe overlay mount" mechanism; the two are mutually exclusive — no pause ad pops while the paywall is showing. Full component docs: SwAdPause.

Danmaku

The settings panel supports mouse hover and pointer focus. After opening it by touch, scrolling or layout updates do not close it when hover is lost. Tap outside the panel to close it. No additional configuration is required.

Set danmaku to enable. On mount the player conditionally dynamic-imports artplayer-plugin-danmuku (~14KB gzip separate chunk, downloaded only when enabled, zero cost when off) and mounts the scrolling layer. The framework ships no built-in input box (headless, emitter defaults to false) — the input bar is rendered by the applier (outside the player; its style / position / color & mode pickers are fully app-controlled), driven via the defineExpose methods emitDanmaku / showDanmaku / hideDanmaku / configDanmaku / loadDanmaku.

  • danmakuData: danmaku data Danmu[]. The framework is backend-shape-agnostic — for the old-backend /danmaku/list protocol use the built-in danmakuListToDanmu to normalize it in one line (see "Normalizing backend data" below); map any other source into Danmu[] yourself. Auto-load-reloads on change.
  • danmakuOptions: plugin config escape hatch (speed / opacity / margin / modes / fontSize / emitter, etc.), shallow-merged over framework defaults (key defaults: opacity: 0.8, fontSize: '4%' — player height × 4%, min 12px, scales with the player). Pass { emitter: true } to use the plugin's built-in input box; pass { opacity: 1, fontSize: 22 } to restore the old fully-opaque fixed size.
<script setup lang="ts">
import type { Danmu } from '@swift-web/framework'

const playerRef = ref()
const list: Danmu[] = [
  { text: 'first danmaku', time: 1, mode: 0, color: '#FFFFFF' },
  { text: 'top danmaku', time: 2, mode: 1 },
]
</script>

<template>
  <!-- danmaku-options: plugin config escape hatch (shallow-merged with defaults; { emitter: true } uses the plugin's own input bar) -->
  <SwVideoPlayer ref="playerRef" :url="videoUrl" type="m3u8" danmaku :danmaku-data="list" :danmaku-options="{ speed: 7, opacity: 0.9 }" />
  <!-- External danmaku input bar (app-rendered); call emitDanmaku on send -->
  <MyDanmakuBar
    @send="(text) => playerRef.emitDanmaku({ text })"
    @toggle="(on) => (on ? playerRef.showDanmaku() : playerRef.hideDanmaku())"
  />
</template>

Danmaku panel theme color (--sw-video-player-danmaku-color)

The artplayer-plugin-danmuku control panel (opacity / area / font-size / speed sliders, checkboxes, send button, type-shield icons, etc.) ships with a Bilibili-blue accent by default. The framework funnels it into the CSS variable --sw-video-player-danmaku-color, which falls back to var(--primary) — so it follows your site's primary color by default, zero config. To set it explicitly, declare the variable on the player or any ancestor:

<template>
  <!-- Theme the danmaku panel with your brand color -->
  <div style="--sw-video-player-danmaku-color: #e23744">
    <SwVideoPlayer :url="videoUrl" danmaku :danmaku-data="list" />
  </div>
</template>

The panel sits on the dark player surface; if your --primary lacks contrast on dark, override this variable with a higher-contrast color.

Danmu type

interface Danmu {
  text: string       // danmaku text
  time?: number      // appearance time (seconds); optional for real-time emit
  mode?: 0 | 1 | 2   // track: 0 scroll / 1 top / 2 bottom, default 0
  color?: string     // color #RRGGBB
  border?: boolean   // outline, default false
}

Normalizing backend data (danmakuListToDanmu)

The old-backend /danmaku/list protocol (a bare array with content / pos / color / mode fields) is normalized into Danmu[] by the built-in danmakuListToDanmu in one line (auto-imported; symmetric to the ad-side playAdsToSwAdItems):

<script setup lang="ts">
import type { Danmu } from '@swift-web/framework'

// request() strips the { status, data } envelope, leaving a bare array; danmakuListToDanmu normalizes it into Danmu[]
const raw = await request('/danmaku/list', { body: { object_id, sub_id } })
const list: Danmu[] = danmakuListToDanmu(raw)
</script>

<template>
  <SwVideoPlayer :url="url" type="m3u8" danmaku :danmaku-data="list" />
</template>
danmakuListToDanmu(list: DanmakuListRawItem[] | null | undefined): Danmu[]

Normalization rules (display subset only; a pure function, callable on both server and client):

  • content → text; pos (seconds, may be a numeric string) → time (coerced via Number(), defaults to 0 when absent)
  • mode (0 scroll / 1 top / 2 bottom, may be a numeric string) → mode; invalid / missing is omitted so the default track takes over
  • color (decimal number / string, #RRGGBB, or #RGB shorthand) → #RRGGBB; '0' / 0 / missing is omitted so danmakuOptions.color / the plugin's default color takes over
  • null / undefined / non-array → []
  • Backend business fields such as id / like_count / user_id do not enter Danmu — like / report / send orchestration stays in the applier, and the send-direction hex→decimal color conversion is not part of this util either.
interface PlayLink {
  code: string
  id: number | string
  lid: number | string
  m3u8_url: string
}

Style Tokens

These CSS variables can be overridden on your side (the fallback is the default):

VariableDefaultDescription
--sw-video-player-overlay-z100Stacking level for business overlays inside the player (the fw#332 z knob)
--sw-video-player-hijack-z200Stacking level of the hijacking-browser interception layer — deliberately above business overlays

Notes

  • autoplay restrictions: browsers usually require muted: true for autoplay
  • Multiple instances on same page: component uses ref to its own DOM container; multiple SwVideoPlayer instances do not interfere
  • switchLink preserves progress: records currentTime before switch, restores and plays after
  • Automatic resume on expired segment signatures: on the hls.js path, the first 403 or no-response error on a segment / key (the shape of an expired anti-leech signature) makes the player rebuild the hls instance, reload the same URL (a stable backend proxy path that re-signs on request) and resume from the current position; at most once per mount. A second hit is left to hls.js retries until a fatal error reaches @error. 404 / timeouts do not trigger it. The iPhone native HLS path is not covered. Consumers need no handling and must not re-resolve URLs themselves
  • options smart merge: customType and plugins are merged individually rather than fully replaced

See Also

Same-episode resume offer

Pass ownership-validated private history through resume: { key, time }. The key identifies the current account and episode session; time is the saved position in seconds, or null while unavailable. Eligible positions within the known duration and outside intro/outro boundaries are offered for five seconds of actual foreground playback, with resume-now and cancel actions. Pause, seek, source replacement and leaving the active item retire the offer. Applying seeks without unpausing. The player coordinates intro skipping internally; do not temporarily clear skip in page code.

An explicit Continue action uses startTime (seek_time for Feed), without another prompt. Feed session restoration also takes precedence over the saved-history offer. Preloaded neighbours cannot offer or restore. Private history must not enter SSR or shared caches.

Custom history writers listen to resume-pending and hold initialization records while true; built-in onHistory is already held internally. Feed emits (index, pending), affecting only that item. Cross-episode prompts, ownership checks and persistence remain application responsibilities.

<SwVideoPlayer :resume="{ key: 'account:episode', time: 120 }" :skip="{ introEnd: 60, outroLength: 90 }" />
<SwVideoPlayer :start-time="120" />
<SwVideoPlayer :resume="validatedHistory" @resume-pending="historyPending = $event" :on-history="saveHistory" />