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+typefor a single video address - Multi-track mode: pass
linksfor a track list, paired withv-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=trueandprerollAdDuration > 0, the pre-roll layer shows first and counts down - At zero:
prerollAdAutoClose=trueauto-closes the layer and starts the content;false(default) shows an "enter" button awaiting the user - Slot scope
{ countdown, skip }:countdownis 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>
prerollAdAutoCloseis 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, composecountdown+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-nextoff 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-fullscreenand fills the viewport withposition: 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
Fkey (while the player has focus; while this player is fullscreen,Fexits even without focus; disabled byhotkey=false), and double-click on desktop. On mobile, double-tap still toggles play / pause.Escexits; 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 emitstrueon 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
:keyrebuild): 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 withz-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-fullscreento 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-indexabove9999.
Migrating from #447
- Remove
restore-web-fullscreenand any state that records "was fullscreen before switching", and passfullscreen-keyinstead (restoreWebFullscreenhas been removed, framework#454). - Styles that depend on
.art-fullscreen-web/.art-fullscreenshould 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/fullscreenWebany 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 setintroStartwhen a cold open plays before the intro. After each new source loads, reaching this range jumps tointroEndand 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
outroLengthseconds of each episode (works with different episode lengths). Withshow-play-nexton, 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": passintroEnd: nullthere; 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 toPOST /movie/doHistory(legacy backend convention). Reportsvideo_playonloadedmetadata, thenvideo_playagain every 10 seconds, andvideo_completeonendednull: completely disable reportingfunction: 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
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
url | string | — | Direct video URL (preferred over links) | |
type | string | 'm3u8' | Paired with url | |
links | PlayLink[] | [] | Track list | |
linkCode | string | — | Current track code, supports v-model:linkCode | |
startTime | number | 0 | Seconds to seek after init | |
resume | { key: string | number; time: number | null } | null | null | Same-episode private history candidate; see resume behavior below. | |
poster | string | — | Cover image | |
title | string | — | Title | |
prerollAd | boolean | false | Enable the pre-roll phase (content via the #preroll-ad slot) | |
prerollAdDuration | number | 5000 | Pre-roll countdown duration (milliseconds, must be > 0) | |
prerollAdAutoClose | boolean | false | Auto-close at zero and start the content; false shows an "enter" button | |
showPlayNext | boolean | false | Show "Next Episode" button; also enables the end / credits countdown and the two settings toggles (see Play next) | |
fullscreenKey | string | — | 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) | |
skip | SkipSegments | null | null | Skip intro / credits (seconds): { introStart?, introEnd?, outroLength? } (see Skip intro and credits) | |
locked | boolean | false | Paywall lock state (path A, see Paywall): shows #paywall after the trial ends | |
previewReplayLimit | number | undefined | Max trial-replay count (see Paywall): undefined=unlimited / 0=disabled / N=at most N times | |
pauseAd | boolean | false | Pause ad (see Pause Ad section): shows #pause-ad when gating passes on pause | |
pauseAdDelay | number | 700 | Debounce delay (ms) from pause to ad, avoids quick pause→play / seek misfires | |
pauseAdMinPlayed | number | 5000 | Eligibility: won't show until played ≥ this many milliseconds | |
danmaku | boolean | false | Danmaku capability (see Danmaku): conditionally lazy-loads artplayer-plugin-danmuku | |
danmakuData | Danmu[] | [] | Danmaku data (map your backend into Danmu[]); auto-reloads on change | |
danmakuOptions | DanmakuOptions | {} | Plugin config escape hatch (shallow-merged; key defaults: emitter: false = external input, opacity: 0.8, fontSize: '4%' scales with player height, min 12px) | |
theme | string | 'white' | Artplayer theme color | |
volume | number | 0.7 | Initial volume (0-1) | |
muted | boolean | false | Muted | |
autoplay | boolean | false | Autoplay on mount. Without muted, mobile browsers reject it and the component retries muted, firing autoplay-muted | |
loop | boolean | false | Loop | |
playbackRate | boolean | true | Show playback rate menu | |
pip | boolean | false | Picture-in-picture | |
hotkey | boolean | true | Enable keyboard shortcuts | |
options | Partial<ArtplayerOption> | {} | Artplayer native config escape hatch (shallow merge) | |
plugins | ArtplayerPlugin[] | [] | Custom Artplayer plugins | |
onHistory | ((p: VideoHistoryParams) => void) | null | undefined | History reporting: undefined → built-in /movie/doHistory; null → disable; function → override | |
track | VideoPlayerTrackProp | undefined | WebSDK playback-behavior tracking (opt-in), see "Playback tracking" below | |
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) |
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_key | Timing |
|---|---|
video_play | Playback starts (once per video; resume does not re-report) |
video_pause | Paused (only after playback started, filters init noise) |
play_progress_25/50/75/100 | Progress milestones (once each; reset on video switch) |
video_complete | Playback ended (once) |
video_error | Playback error (once-gated against hls error storms) |
video_heartbeat | Playback 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_frame | Self-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_errorcovers 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 whenvideo_idchanges, kept across line switches) ·startup_ms(only onvideo_first_frame: ms from the lastplaybefore 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_idexplicitly: when omitted the backend falls back tovideo_idfor attribution (dev warns once). - Changing
track.video.video_idresets 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
| Name | Payload | Description |
|---|---|---|
ready | player: ArtplayerInstance | Artplayer 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 |
seeked | currentTime: number | Seek complete |
timeupdate | currentTime: number | 1s-throttled progress |
update:currentTime | currentTime: number | 500ms-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-pending | boolean | Hold 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 |
fullscreen | state: boolean | Fullscreen 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 |
error | err: unknown | Playback error. Fatal errors only — non-fatal ones (segment 404, transient network) are retried by hls.js and no longer bother consumers |
update:linkCode | code: string | Track switched, sync v-model |
Slots
| Name | Description |
|---|---|
preroll-ad | Pre-roll layer content (ad carousel / static image etc.), receives { countdown, skip } scope |
loading | Loading placeholder inside the video container |
paywall | Paywall 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-ad | Pause 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-browser | 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). 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.
| Method | Description |
|---|---|
play() | Start playback |
pause() | Pause |
seek(time: number) | Seek to seconds |
replayPreview(): boolean | After 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
lockedand the trial segment finishes →#paywallshows +@paywallfires (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 toresume_offset— viagetPlayer().switchUrl(fullUrl)+seek(resume_offset). The framework provides nounlock(). - 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 withpreviewReplayLimit(undefined=unlimited, default;N=at most N times, for "trial = first N minutes" cases needing anti-freeloading;0=disabled).replayPreview()returnsfalsewhen notlockedor 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_offsetcomes 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 bessr: false(caught by the ESLintno-isr-on-paid-pathsrule). 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 (seekingwhile dragging /waitingwhile 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-adshows +@pause-adfires (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:playauto-dismisses it;SwAdPause's ✕ closes the overlay via the slot'sclose.
#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 dataDanmu[]. The framework is backend-shape-agnostic — for the old-backend/danmaku/listprotocol use the built-indanmakuListToDanmuto normalize it in one line (see "Normalizing backend data" below); map any other source intoDanmu[]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
--primarylacks 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 viaNumber(), defaults to0when absent)mode(0scroll /1top /2bottom, may be a numeric string) →mode; invalid / missing is omitted so the default track takes overcolor(decimal number / string,#RRGGBB, or#RGBshorthand) →#RRGGBB;'0'/0/ missing is omitted sodanmakuOptions.color/ the plugin's default color takes overnull/undefined/ non-array →[]- Backend business fields such as
id/like_count/user_iddo not enterDanmu— like / report / send orchestration stays in the applier, and the send-direction hex→decimal color conversion is not part of this util either.
PlayLink Type
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):
| Variable | Default | Description |
|---|---|---|
--sw-video-player-overlay-z | 100 | Stacking level for business overlays inside the player (the fw#332 z knob) |
--sw-video-player-hijack-z | 200 | Stacking level of the hijacking-browser interception layer — deliberately above business overlays |
Notes
- autoplay restrictions: browsers usually require
muted: truefor 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:
customTypeandpluginsare merged individually rather than fully replaced
See Also
- SwAudioPlayer — audio player
- SwVideoPreview — silent looped preview
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" />