Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@ Presentation <b>slide</b>s for <b>dev</b>elopers 🧑‍💻👩‍💻👨‍
<table>
<tbody>
<td align="center">
<img width="2000" height="0" alt="" aria-hiden><br>
<img width="2000" height="0" alt="" aria-hidden><br>
<sub>Made possible by my <a href="https://github.com/sponsors/antfu">Sponsor Program 💖</a></sub><br>
<img width="2000" height="0" alt="" aria-hiden>
<img width="2000" height="0" alt="" aria-hidden>
</td>
</tbody>
</table>
Expand Down
24 changes: 24 additions & 0 deletions docs/guide/video-playback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# SlidevVideo Controlled Playback

This demo shows how to use the `pause` prop on `<SlidevVideo>` to control playback in steps.

## Example

```vue
<SlidevVideo src="/videos/demo.mp4" :pause="[1, 2, 3, 'end']" />
```

The video will:

1. Pause at **1s**
2. Pause at **2s**
3. Pause at **3s**
4. Pause at the **end**

## Usage

Use `pause` to sync video playback with your slide steps or narration.

```vue
<SlidevVideo src="/videos/pipeline.mp4" :pause="[3, 6, 10, 'end']" />
```
137 changes: 87 additions & 50 deletions packages/client/builtin/SlidevVideo.vue
Original file line number Diff line number Diff line change
@@ -1,74 +1,111 @@
<script setup lang="ts">
import { and } from '@vueuse/math'
import { computed, onMounted, ref, watch } from 'vue'
import { useNav } from '../composables/useNav'
import { useSlideContext } from '../context'
import { resolvedClickMap } from '../modules/v-click'
import videojs from 'video.js'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import 'video.js/dist/video-js.css'

const props = defineProps<{
autoplay?: boolean | 'once'
autoreset?: 'slide' | 'click'
pause?: (number | 'end')[]
poster?: string
printPoster?: string
timestamp?: string | number
printTimestamp?: string | number | 'last'
controls?: boolean
}>()

const printPoster = computed(() => props.printPoster ?? props.poster)
const printTimestamp = computed(() => props.printTimestamp ?? props.timestamp ?? 0)
const videoRef = ref()
const player = ref<ReturnType<typeof videojs> | null>(null)
const currentInterval = ref<any>(null)

const { $slidev, $renderContext, $route } = useSlideContext()
const { isPrintMode } = useNav()
const rawPause = props.pause ?? []
const pauseTimestamps = computed(() => {
const out: (number | 'end')[] = [0]
for (const segment of rawPause) {
const last = out[out.length - 1]
if (segment === 'end') {
out.push('end')
}
else {
const lastNum = typeof last === 'number' ? last : 0
out.push(lastNum + segment)
}
}
return out
})

const noPlay = computed(() => isPrintMode.value || !['slide', 'presenter'].includes($renderContext.value))
const pauseIndex = ref(1)
const isPlaying = ref(false)

const video = ref<HTMLMediaElement>()
const played = ref(false)
function playNextSegment() {
const from = pauseTimestamps.value[pauseIndex.value - 1]
const to = pauseTimestamps.value[pauseIndex.value]

onMounted(() => {
if (noPlay.value)
if (!player.value || from == null || to == null)
return

const timestamp = +(props.timestamp ?? 0)
video.value!.currentTime = timestamp
if (typeof from === 'number') {
player.value.currentTime(from)
}

const matchRoute = computed(() => !!$route && $route.no === $slidev?.nav.currentSlideNo)
const matchClick = computed(() => !!video.value && (resolvedClickMap.get(video.value)?.isShown?.value ?? true))
const matchRouteAndClick = and(matchRoute, matchClick)
try {
isPlaying.value = true
player.value.play()
}
catch {
isPlaying.value = false
return
}

watch(matchRouteAndClick, () => {
if (matchRouteAndClick.value) {
if (props.autoplay === true || (props.autoplay === 'once' && !played.value))
video.value!.play()
}
else {
video.value!.pause()
if (props.autoreset === 'click' || (props.autoreset === 'slide' && !matchRoute.value))
video.value!.currentTime = timestamp
if (to === 'end') {
pauseIndex.value++
return
}

if (currentInterval.value)
clearInterval(currentInterval.value)

currentInterval.value = setInterval(() => {
if (!player.value)
return
if (player.value.currentTime() >= to) {
player.value.pause()
clearInterval(currentInterval.value)
isPlaying.value = false
pauseIndex.value++
}
}, { immediate: true })
}, 100)
}

onMounted(() => {
player.value = videojs(videoRef.value, {
controls: props.controls !== false,
autoplay: false,
preload: 'auto',
poster: props.poster,
})

player.value.ready(() => {
player.value.controls(true)
player.value.trigger('resize')
player.value.pause()
player.value.currentTime(0)

player.value.on('play', () => {
if (!isPlaying.value) {
playNextSegment()
}
else {
player.value?.pause()
}
})
})
})

function onLoadedMetadata(ev: Event) {
// The video may be loaded before component mounted
const element = ev.target as HTMLMediaElement
if (noPlay.value && (!printPoster.value || props.printTimestamp)) {
element.currentTime = printTimestamp.value === 'last'
? element.duration
: +printTimestamp.value
}
}
onBeforeUnmount(() => {
if (currentInterval.value)
clearInterval(currentInterval.value)
player.value?.dispose()
})
</script>

<template>
<video
ref="video"
:poster="noPlay ? printPoster : props.poster"
:controls="!noPlay && props.controls"
@play="played = true"
@loadedmetadata="onLoadedMetadata"
>
<video ref="videoRef" class="video-js vjs-default-skin" playsinline>
<slot />
</video>
</template>
15 changes: 11 additions & 4 deletions packages/client/setup/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,28 @@ import type { App } from 'vue'
import setups from '#slidev/setups/main'
import TwoSlashFloatingVue from '@shikijs/vitepress-twoslash/client'
import { createHead } from '@unhead/vue/client'
import { createRouter, createWebHashHistory, createWebHistory } from 'vue-router'
import {
createRouter,
createWebHashHistory,
createWebHistory,
} from 'vue-router'
import { createVClickDirectives } from '../modules/v-click'
import { createVDragDirective } from '../modules/v-drag'
import { createVMarkDirective } from '../modules/v-mark'
import { createVMotionDirectives } from '../modules/v-motion'
import setupRoutes from '../setup/routes'
import 'video.js/dist/video-js.css'

import '#slidev/styles'

export default async function setupMain(app: App) {
function setMaxHeight() {
// disable the mobile navbar scroll
// see https://css-tricks.com/the-trick-to-viewport-units-on-mobile/
document.documentElement.style.setProperty('--vh', `${window.innerHeight * 0.01}px`)
document.documentElement.style.setProperty(
'--vh',
`${window.innerHeight * 0.01}px`,
)
}
setMaxHeight()
window.addEventListener('resize', setMaxHeight)
Expand All @@ -41,6 +49,5 @@ export default async function setupMain(app: App) {
router,
}

for (const setup of setups)
await setup(context)
for (const setup of setups) await setup(context)
}
5 changes: 5 additions & 0 deletions playground/slides.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# 測試 SlidevVideo

<SlidevVideo :pause="[1,2,3,'end']" controls>
<source src="https://www.w3schools.com/html/mov_bbb.mp4" type="video/mp4" />
</SlidevVideo>
Loading