Skip to content
Merged
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
2 changes: 2 additions & 0 deletions demo/starter/slides.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ drawings:
transition: slide-left
# enable MDC Syntax: https://sli.dev/features/mdc
mdc: true
# duration of the presentation
duration: 35min
---

# Welcome to Slidev
Expand Down
23 changes: 23 additions & 0 deletions docs/features/timer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
tags: [presenter]
description: Timer for the presenter mode.
---

# Presenter Timer

Slidev provides a timer for the presenter mode. You can start, pause, and reset the timer.

It will show a timer (in stopwatch or countdown mode), and a progress bar in the presenter mode.

## Configuration

You can set the duration of the presentation in the headmatter. Default is `30min`.

```yaml
---
# duration of the presentation, default is '30min'
duration: 30min
# timer mode, can be 'countdown' or 'stopwatch', default is 'stopwatch'
timer: stopwatch
---
```
79 changes: 57 additions & 22 deletions packages/client/composables/useTimer.ts
Original file line number Diff line number Diff line change
@@ -1,55 +1,85 @@
import { parseTimeString } from '@slidev/parser/utils'
import { useInterval } from '@vueuse/core'
import { computed, toRef } from 'vue'
import { configs } from '../env'
import { sharedState } from '../state/shared'

export function useTimer() {
const mode = computed(() => configs.timer || 'stopwatch')
const duration = computed(() => parseTimeString(configs.duration).seconds)
const interval = useInterval(100, { controls: true })

const state = toRef(sharedState, 'timerStatus')
const timer = computed(() => {
if (sharedState.timerStatus === 'stopped' && sharedState.timerStartedAt === 0)
return { h: '', m: '-', s: '--', ms: '-' }
const state = toRef(sharedState, 'timer')
const status = computed(() => state.value?.status)
const passedMs = computed(() => {
// eslint-disable-next-line ts/no-unused-expressions
interval.counter.value
const passed = (Date.now() - sharedState.timerStartedAt)
let h = Math.floor(passed / 1000 / 60 / 60).toString()
if (state.value.status === 'stopped' || !state.value.startedAt)
return 0
return Date.now() - state.value.startedAt
})
const passed = computed(() => passedMs.value / 1000)
const percentage = computed(() => passed.value / duration.value * 100)

const timer = computed(() => {
if (mode.value === 'stopwatch') {
if (state.value.status === 'stopped' || !state.value.startedAt)
return { h: '', m: '-', s: '--', ms: '-' }
}

const total = mode.value === 'countdown'
? duration.value * 1000 - passedMs.value
: passedMs.value

let h = Math.floor(total / 1000 / 60 / 60).toString()
if (h === '0')
h = ''
let min = Math.floor(passed / 1000 / 60 % 60).toString()
let min = Math.floor(total / 1000 / 60 % 60).toString()
if (h)
min = min.padStart(2, '0')
const sec = Math.floor(passed / 1000 % 60).toString().padStart(2, '0')
const ms = Math.floor(passed % 1000 / 100).toString()
return { h, m: min, s: sec, ms }
const sec = Math.floor(total / 1000 % 60).toString().padStart(2, '0')
const ms = Math.floor(total % 1000 / 100).toString()

return {
h,
m: min,
s: sec,
ms,
}
})

function reset() {
interval.pause()
sharedState.timerStatus = 'stopped'
sharedState.timerStartedAt = 0
sharedState.timerPausedAt = 0
state.value = {
status: 'stopped',
slides: {},
startedAt: 0,
pausedAt: 0,
}
}

function resume() {
if (sharedState.timerStatus === 'stopped') {
sharedState.timerStatus = 'running'
sharedState.timerStartedAt = Date.now()
if (!state.value)
return
if (state.value?.status === 'stopped') {
state.value.status = 'running'
state.value.startedAt = Date.now()
}
else if (sharedState.timerStatus === 'paused') {
sharedState.timerStatus = 'running'
sharedState.timerStartedAt = Date.now() - (sharedState.timerPausedAt - sharedState.timerStartedAt)
else if (state.value.status === 'paused') {
state.value.status = 'running'
state.value.startedAt = Date.now() - (state.value.pausedAt - state.value.startedAt)
}
interval.resume()
}

function pause() {
sharedState.timerStatus = 'paused'
sharedState.timerPausedAt = Date.now()
state.value.status = 'paused'
state.value.pausedAt = Date.now()
interval.pause()
}

function toggle() {
if (sharedState.timerStatus === 'running') {
if (state.value.status === 'running') {
pause()
}
else {
Expand All @@ -59,10 +89,15 @@ export function useTimer() {

return {
state,
status,
timer,
reset,
toggle,
resume,
pause,
passed,
percentage,
duration,
mode,
}
}
49 changes: 49 additions & 0 deletions packages/client/internals/TimerBar.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<script setup lang="ts">
// import { parseTimesplits } from '@slidev/parser/utils'
import { computed, reactive } from 'vue'
// import { useNav } from '../composables/useNav'
import { useTimer } from '../composables/useTimer'

// const { slides } = useNav()

const timer = reactive(useTimer())
// TODO: timesplit
// const slidesWithTimesplits = computed(() => slides.value.filter(i => i.meta.slide?.frontmatter.timesplit))

// const _timesplits = computed(() => {
// const parsed = parseTimesplits(
// slidesWithTimesplits.value
// .map(i => ({ no: i.no, timesplit: i.meta.slide?.frontmatter.timesplit as string })),
// )
// return parsed
// })

// TODO: maybe make it configurable, or somehow more smart
const color = computed(() => {
if (timer.status === 'stopped')
return 'op50'
if (timer.status === 'paused')
return 'bg-blue'

if (timer.percentage > 80)
return 'bg-yellow'
else if (timer.percentage > 100)
return 'bg-red'
else
return 'bg-green'
})
</script>

<template>
<div
class="border-b mt-px border-main relative flex h-4px"
>
<div
v-if="timer.status !== 'stopped'"
class="h-4px"
:class="color"
:style="{ width: `${timer.percentage}%` }"
/>
<!-- {{ timesplits }} -->
</div>
</template>
26 changes: 22 additions & 4 deletions packages/client/internals/TimerInlined.vue
Original file line number Diff line number Diff line change
@@ -1,19 +1,37 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useTimer } from '../composables/useTimer'

const { state, timer, reset, toggle } = useTimer()
const { status, percentage, mode, timer, reset, toggle } = useTimer()

const color = computed(() => {
if (status.value === 'stopped')
return 'op50'
if (status.value === 'paused')
return 'text-blue6 dark:text-blue3'

if (percentage.value > 80)
return 'text-yellow6 dark:text-yellow3'
else if (percentage.value > 100)
return 'text-red6 dark:text-red3'
else
return 'text-green6 dark:text-green3'
})
</script>

<template>
<div
class="group flex items-center justify-center pl-4 select-none"
:class="{ running: 'text-green6 dark:text-green3', paused: 'text-orange6 dark:text-orange3', stopped: 'op50' }[state]"
:class="color"
>
<div class="w-22px cursor-pointer">
<div class="i-carbon:time group-hover:hidden text-xl" />
<div
class="group-hover:hidden text-2xl"
:class="mode === 'countdown' ? 'i-carbon:timer' : 'i-carbon:time'"
/>
<div class="group-not-hover:hidden flex flex-col items-center">
<div class="relative op-80 hover:op-100" @click="toggle">
<div v-if="state === 'running'" class="i-carbon:pause text-lg" />
<div v-if="status === 'running'" class="i-carbon:pause text-lg" />
<div v-else class="i-carbon:play" />
</div>
<div class="op-80 hover:op-100" @click="reset">
Expand Down
25 changes: 0 additions & 25 deletions packages/client/internals/TimesplitBar.vue

This file was deleted.

2 changes: 2 additions & 0 deletions packages/client/pages/notes.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import CurrentProgressBar from '../internals/CurrentProgressBar.vue'
import IconButton from '../internals/IconButton.vue'
import Modal from '../internals/Modal.vue'
import NoteDisplay from '../internals/NoteDisplay.vue'
import TimerBar from '../internals/TimerBar.vue'
import { fullscreen } from '../state'
import { sharedState } from '../state/shared'

Expand Down Expand Up @@ -61,6 +62,7 @@ const clicksContext = computed(() => {
</Modal>
<div class="h-full flex flex-col">
<CurrentProgressBar :clicks-context="clicksContext" :current="pageNo" />
<TimerBar />
<div
ref="scroller"
class="px-5 py-3 flex-auto h-full overflow-auto"
Expand Down
2 changes: 2 additions & 0 deletions packages/client/pages/presenter.vue
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import SegmentControl from '../internals/SegmentControl.vue'
import SlideContainer from '../internals/SlideContainer.vue'
import SlidesShow from '../internals/SlidesShow.vue'
import SlideWrapper from '../internals/SlideWrapper.vue'
import TimerBar from '../internals/TimerBar.vue'
import TimerInlined from '../internals/TimerInlined.vue'
import { onContextMenu } from '../logic/contextMenu'
import { registerShortcuts } from '../logic/shortcuts'
Expand Down Expand Up @@ -117,6 +118,7 @@ onMounted(() => {
<div class="bg-main h-full slidev-presenter grid grid-rows-[max-content_1fr] of-hidden">
<div>
<CurrentProgressBar />
<TimerBar />
</div>
<div class="grid-container" :class="`layout${presenterLayout}`">
<div ref="main" class="relative grid-section main flex flex-col">
Expand Down
22 changes: 16 additions & 6 deletions packages/client/state/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,16 @@ export interface SharedState {
page: number
clicks: number
clicksTotal: number
timerStatus: 'stopped' | 'running' | 'paused'
timerStartedAt: number
timerPausedAt: number

timer: {
status: 'stopped' | 'running' | 'paused'
slides: Record<number, {
start?: number
end?: number
}>
startedAt: number
pausedAt: number
}

cursor?: {
x: number
Expand All @@ -26,9 +33,12 @@ const { init, onPatch, onUpdate, patch, state } = createSyncState<SharedState>(s
page: 1,
clicks: 0,
clicksTotal: 0,
timerStatus: 'stopped',
timerStartedAt: 0,
timerPausedAt: 0,
timer: {
status: 'stopped',
slides: {},
startedAt: 0,
pausedAt: 0,
},
})

export {
Expand Down
29 changes: 9 additions & 20 deletions packages/parser/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,26 +12,15 @@
},
"bugs": "https://github.com/slidevjs/slidev/issues",
"exports": {
".": {
"types": "./dist/index.d.mts",
"import": "./dist/index.mjs"
},
"./core": {
"types": "./dist/core.d.mts",
"import": "./dist/core.mjs"
},
"./fs": {
"types": "./dist/fs.d.mts",
"import": "./dist/fs.mjs"
},
"./utils": {
"types": "./dist/utils.d.mts",
"import": "./dist/utils.mjs"
}
".": "./dist/index.mjs",
"./core": "./dist/core.mjs",
"./fs": "./dist/fs.mjs",
"./utils": "./dist/utils.mjs",
"./package.json": "./package.json"
},
"main": "dist/index.mjs",
"module": "dist/index.mjs",
"types": "dist/index.d.mts",
"main": "./dist/index.mjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.mts",
"files": [
"*.d.ts",
"dist"
Expand All @@ -40,7 +29,7 @@
"node": ">=18.0.0"
},
"scripts": {
"build": "tsdown src/index.ts src/core.ts src/fs.ts src/utils.ts",
"build": "tsdown",
"dev": "nr build --watch",
"prepublishOnly": "npm run build"
},
Expand Down
3 changes: 3 additions & 0 deletions packages/parser/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ export function getDefaultConfig(): SlidevConfig {
remote: false,
mdc: false,
seoMeta: {},
notesAutoRuby: {},
duration: '30min',
timer: 'stopwatch',
}
}

Expand Down
2 changes: 2 additions & 0 deletions packages/parser/src/timesplit/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './timesplit'
export * from './timestring'
Loading
Loading