Skip to content

Commit ff6d15b

Browse files
feat: support setting duration of timer and provide countdown mode (#2309)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent d48008c commit ff6d15b

File tree

21 files changed

+487
-81
lines changed

21 files changed

+487
-81
lines changed

demo/starter/slides.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ drawings:
2020
transition: slide-left
2121
# enable MDC Syntax: https://sli.dev/features/mdc
2222
mdc: true
23+
# duration of the presentation
24+
duration: 35min
2325
---
2426

2527
# Welcome to Slidev

docs/features/timer.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
tags: [presenter]
3+
description: Timer for the presenter mode.
4+
---
5+
6+
# Presenter Timer
7+
8+
Slidev provides a timer for the presenter mode. You can start, pause, and reset the timer.
9+
10+
It will show a timer (in stopwatch or countdown mode), and a progress bar in the presenter mode.
11+
12+
## Configuration
13+
14+
You can set the duration of the presentation in the headmatter. Default is `30min`.
15+
16+
```yaml
17+
---
18+
# duration of the presentation, default is '30min'
19+
duration: 30min
20+
# timer mode, can be 'countdown' or 'stopwatch', default is 'stopwatch'
21+
timer: stopwatch
22+
---
23+
```
Lines changed: 57 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,85 @@
1+
import { parseTimeString } from '@slidev/parser/utils'
12
import { useInterval } from '@vueuse/core'
23
import { computed, toRef } from 'vue'
4+
import { configs } from '../env'
35
import { sharedState } from '../state/shared'
46

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

8-
const state = toRef(sharedState, 'timerStatus')
9-
const timer = computed(() => {
10-
if (sharedState.timerStatus === 'stopped' && sharedState.timerStartedAt === 0)
11-
return { h: '', m: '-', s: '--', ms: '-' }
12+
const state = toRef(sharedState, 'timer')
13+
const status = computed(() => state.value?.status)
14+
const passedMs = computed(() => {
1215
// eslint-disable-next-line ts/no-unused-expressions
1316
interval.counter.value
14-
const passed = (Date.now() - sharedState.timerStartedAt)
15-
let h = Math.floor(passed / 1000 / 60 / 60).toString()
17+
if (state.value.status === 'stopped' || !state.value.startedAt)
18+
return 0
19+
return Date.now() - state.value.startedAt
20+
})
21+
const passed = computed(() => passedMs.value / 1000)
22+
const percentage = computed(() => passed.value / duration.value * 100)
23+
24+
const timer = computed(() => {
25+
if (mode.value === 'stopwatch') {
26+
if (state.value.status === 'stopped' || !state.value.startedAt)
27+
return { h: '', m: '-', s: '--', ms: '-' }
28+
}
29+
30+
const total = mode.value === 'countdown'
31+
? duration.value * 1000 - passedMs.value
32+
: passedMs.value
33+
34+
let h = Math.floor(total / 1000 / 60 / 60).toString()
1635
if (h === '0')
1736
h = ''
18-
let min = Math.floor(passed / 1000 / 60 % 60).toString()
37+
let min = Math.floor(total / 1000 / 60 % 60).toString()
1938
if (h)
2039
min = min.padStart(2, '0')
21-
const sec = Math.floor(passed / 1000 % 60).toString().padStart(2, '0')
22-
const ms = Math.floor(passed % 1000 / 100).toString()
23-
return { h, m: min, s: sec, ms }
40+
const sec = Math.floor(total / 1000 % 60).toString().padStart(2, '0')
41+
const ms = Math.floor(total % 1000 / 100).toString()
42+
43+
return {
44+
h,
45+
m: min,
46+
s: sec,
47+
ms,
48+
}
2449
})
2550

2651
function reset() {
2752
interval.pause()
28-
sharedState.timerStatus = 'stopped'
29-
sharedState.timerStartedAt = 0
30-
sharedState.timerPausedAt = 0
53+
state.value = {
54+
status: 'stopped',
55+
slides: {},
56+
startedAt: 0,
57+
pausedAt: 0,
58+
}
3159
}
3260

3361
function resume() {
34-
if (sharedState.timerStatus === 'stopped') {
35-
sharedState.timerStatus = 'running'
36-
sharedState.timerStartedAt = Date.now()
62+
if (!state.value)
63+
return
64+
if (state.value?.status === 'stopped') {
65+
state.value.status = 'running'
66+
state.value.startedAt = Date.now()
3767
}
38-
else if (sharedState.timerStatus === 'paused') {
39-
sharedState.timerStatus = 'running'
40-
sharedState.timerStartedAt = Date.now() - (sharedState.timerPausedAt - sharedState.timerStartedAt)
68+
else if (state.value.status === 'paused') {
69+
state.value.status = 'running'
70+
state.value.startedAt = Date.now() - (state.value.pausedAt - state.value.startedAt)
4171
}
4272
interval.resume()
4373
}
4474

4575
function pause() {
46-
sharedState.timerStatus = 'paused'
47-
sharedState.timerPausedAt = Date.now()
76+
state.value.status = 'paused'
77+
state.value.pausedAt = Date.now()
4878
interval.pause()
4979
}
5080

5181
function toggle() {
52-
if (sharedState.timerStatus === 'running') {
82+
if (state.value.status === 'running') {
5383
pause()
5484
}
5585
else {
@@ -59,10 +89,15 @@ export function useTimer() {
5989

6090
return {
6191
state,
92+
status,
6293
timer,
6394
reset,
6495
toggle,
6596
resume,
6697
pause,
98+
passed,
99+
percentage,
100+
duration,
101+
mode,
67102
}
68103
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<script setup lang="ts">
2+
// import { parseTimesplits } from '@slidev/parser/utils'
3+
import { computed, reactive } from 'vue'
4+
// import { useNav } from '../composables/useNav'
5+
import { useTimer } from '../composables/useTimer'
6+
7+
// const { slides } = useNav()
8+
9+
const timer = reactive(useTimer())
10+
// TODO: timesplit
11+
// const slidesWithTimesplits = computed(() => slides.value.filter(i => i.meta.slide?.frontmatter.timesplit))
12+
13+
// const _timesplits = computed(() => {
14+
// const parsed = parseTimesplits(
15+
// slidesWithTimesplits.value
16+
// .map(i => ({ no: i.no, timesplit: i.meta.slide?.frontmatter.timesplit as string })),
17+
// )
18+
// return parsed
19+
// })
20+
21+
// TODO: maybe make it configurable, or somehow more smart
22+
const color = computed(() => {
23+
if (timer.status === 'stopped')
24+
return 'op50'
25+
if (timer.status === 'paused')
26+
return 'bg-blue'
27+
28+
if (timer.percentage > 80)
29+
return 'bg-yellow'
30+
else if (timer.percentage > 100)
31+
return 'bg-red'
32+
else
33+
return 'bg-green'
34+
})
35+
</script>
36+
37+
<template>
38+
<div
39+
class="border-b mt-px border-main relative flex h-4px"
40+
>
41+
<div
42+
v-if="timer.status !== 'stopped'"
43+
class="h-4px"
44+
:class="color"
45+
:style="{ width: `${timer.percentage}%` }"
46+
/>
47+
<!-- {{ timesplits }} -->
48+
</div>
49+
</template>

packages/client/internals/TimerInlined.vue

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,37 @@
11
<script setup lang="ts">
2+
import { computed } from 'vue'
23
import { useTimer } from '../composables/useTimer'
34
4-
const { state, timer, reset, toggle } = useTimer()
5+
const { status, percentage, mode, timer, reset, toggle } = useTimer()
6+
7+
const color = computed(() => {
8+
if (status.value === 'stopped')
9+
return 'op50'
10+
if (status.value === 'paused')
11+
return 'text-blue6 dark:text-blue3'
12+
13+
if (percentage.value > 80)
14+
return 'text-yellow6 dark:text-yellow3'
15+
else if (percentage.value > 100)
16+
return 'text-red6 dark:text-red3'
17+
else
18+
return 'text-green6 dark:text-green3'
19+
})
520
</script>
621

722
<template>
823
<div
924
class="group flex items-center justify-center pl-4 select-none"
10-
:class="{ running: 'text-green6 dark:text-green3', paused: 'text-orange6 dark:text-orange3', stopped: 'op50' }[state]"
25+
:class="color"
1126
>
1227
<div class="w-22px cursor-pointer">
13-
<div class="i-carbon:time group-hover:hidden text-xl" />
28+
<div
29+
class="group-hover:hidden text-2xl"
30+
:class="mode === 'countdown' ? 'i-carbon:timer' : 'i-carbon:time'"
31+
/>
1432
<div class="group-not-hover:hidden flex flex-col items-center">
1533
<div class="relative op-80 hover:op-100" @click="toggle">
16-
<div v-if="state === 'running'" class="i-carbon:pause text-lg" />
34+
<div v-if="status === 'running'" class="i-carbon:pause text-lg" />
1735
<div v-else class="i-carbon:play" />
1836
</div>
1937
<div class="op-80 hover:op-100" @click="reset">

packages/client/internals/TimesplitBar.vue

Lines changed: 0 additions & 25 deletions
This file was deleted.

packages/client/pages/notes.vue

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import CurrentProgressBar from '../internals/CurrentProgressBar.vue'
1010
import IconButton from '../internals/IconButton.vue'
1111
import Modal from '../internals/Modal.vue'
1212
import NoteDisplay from '../internals/NoteDisplay.vue'
13+
import TimerBar from '../internals/TimerBar.vue'
1314
import { fullscreen } from '../state'
1415
import { sharedState } from '../state/shared'
1516
@@ -61,6 +62,7 @@ const clicksContext = computed(() => {
6162
</Modal>
6263
<div class="h-full flex flex-col">
6364
<CurrentProgressBar :clicks-context="clicksContext" :current="pageNo" />
65+
<TimerBar />
6466
<div
6567
ref="scroller"
6668
class="px-5 py-3 flex-auto h-full overflow-auto"

packages/client/pages/presenter.vue

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import SegmentControl from '../internals/SegmentControl.vue'
2323
import SlideContainer from '../internals/SlideContainer.vue'
2424
import SlidesShow from '../internals/SlidesShow.vue'
2525
import SlideWrapper from '../internals/SlideWrapper.vue'
26+
import TimerBar from '../internals/TimerBar.vue'
2627
import TimerInlined from '../internals/TimerInlined.vue'
2728
import { onContextMenu } from '../logic/contextMenu'
2829
import { registerShortcuts } from '../logic/shortcuts'
@@ -117,6 +118,7 @@ onMounted(() => {
117118
<div class="bg-main h-full slidev-presenter grid grid-rows-[max-content_1fr] of-hidden">
118119
<div>
119120
<CurrentProgressBar />
121+
<TimerBar />
120122
</div>
121123
<div class="grid-container" :class="`layout${presenterLayout}`">
122124
<div ref="main" class="relative grid-section main flex flex-col">

packages/client/state/shared.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,16 @@ export interface SharedState {
66
page: number
77
clicks: number
88
clicksTotal: number
9-
timerStatus: 'stopped' | 'running' | 'paused'
10-
timerStartedAt: number
11-
timerPausedAt: number
9+
10+
timer: {
11+
status: 'stopped' | 'running' | 'paused'
12+
slides: Record<number, {
13+
start?: number
14+
end?: number
15+
}>
16+
startedAt: number
17+
pausedAt: number
18+
}
1219

1320
cursor?: {
1421
x: number
@@ -26,9 +33,12 @@ const { init, onPatch, onUpdate, patch, state } = createSyncState<SharedState>(s
2633
page: 1,
2734
clicks: 0,
2835
clicksTotal: 0,
29-
timerStatus: 'stopped',
30-
timerStartedAt: 0,
31-
timerPausedAt: 0,
36+
timer: {
37+
status: 'stopped',
38+
slides: {},
39+
startedAt: 0,
40+
pausedAt: 0,
41+
},
3242
})
3343

3444
export {

packages/parser/package.json

Lines changed: 9 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -12,26 +12,15 @@
1212
},
1313
"bugs": "https://github.com/slidevjs/slidev/issues",
1414
"exports": {
15-
".": {
16-
"types": "./dist/index.d.mts",
17-
"import": "./dist/index.mjs"
18-
},
19-
"./core": {
20-
"types": "./dist/core.d.mts",
21-
"import": "./dist/core.mjs"
22-
},
23-
"./fs": {
24-
"types": "./dist/fs.d.mts",
25-
"import": "./dist/fs.mjs"
26-
},
27-
"./utils": {
28-
"types": "./dist/utils.d.mts",
29-
"import": "./dist/utils.mjs"
30-
}
15+
".": "./dist/index.mjs",
16+
"./core": "./dist/core.mjs",
17+
"./fs": "./dist/fs.mjs",
18+
"./utils": "./dist/utils.mjs",
19+
"./package.json": "./package.json"
3120
},
32-
"main": "dist/index.mjs",
33-
"module": "dist/index.mjs",
34-
"types": "dist/index.d.mts",
21+
"main": "./dist/index.mjs",
22+
"module": "./dist/index.mjs",
23+
"types": "./dist/index.d.mts",
3524
"files": [
3625
"*.d.ts",
3726
"dist"
@@ -40,7 +29,7 @@
4029
"node": ">=18.0.0"
4130
},
4231
"scripts": {
43-
"build": "tsdown src/index.ts src/core.ts src/fs.ts src/utils.ts",
32+
"build": "tsdown",
4433
"dev": "nr build --watch",
4534
"prepublishOnly": "npm run build"
4635
},

0 commit comments

Comments
 (0)