mdeck 3.7.0 → 3.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/README.md +1 -1
- package/docs/site/content/drawing.md +5 -1
- package/docs/site/content/media.md +3 -1
- package/docs/site/content/presenting.md +2 -0
- package/package.json +1 -1
- package/src/components/VideoPlayer.jsx +70 -6
- package/src/components/video-player.css +15 -0
- package/src/core/labels.js +2 -0
- package/src/runtime/ink/bus.js +9 -0
- package/src/runtime/main.jsx +27 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 3.7.1 — 2026-10-09
|
|
4
|
+
|
|
5
|
+
- A video file on a slide (`<videoplayer src="…" />`) follows the presenter view, on the laptop or a paired iPad: play, pause and a jump there do the same in the audience window, which plays the sound while the presenter view's copy stays silent. This works without a server too, in a built folder. An audience window that nobody has clicked yet plays silently and offers **Click for sound** (German: **Klicken für Ton**). Online videos from YouTube, Vimeo or SwitchTube still play only where they are started; the iPad guide says so.
|
|
6
|
+
|
|
3
7
|
## 3.7.0 — 2026-10-09
|
|
4
8
|
|
|
5
9
|
- Palettes have a third accent, `--accent-3`, and the three accents mean something: `--accent` is emphasis, `--accent-2` its companion (gradients, a contrast to the accent), `--accent-3` attention, used sparingly. `--accent-3` marks things and need not be readable as text, so it may be a yellow; `mdeck check` asks only that it can be seen (1.5:1 against the background). A palette without `--accent-3` keeps working and gets an amber (`#d99a00`, at night `#f5c542`); `mdeck check` suggests giving it its own. All built-in palettes and those in the theme repository set it (the repository requires it); `mdeck palettes update` brings installed ones up to date.
|
package/README.md
CHANGED
|
@@ -48,7 +48,11 @@ There are two ways.
|
|
|
48
48
|
|
|
49
49
|
1. Start `mdeck run my-talk.md --network`, so the iPad in the same network can reach your laptop.
|
|
50
50
|
2. On the launch page, under **Present from an iPad**, choose **Show pairing code** and scan it with the iPad's camera. The presenter view opens on the iPad, paired with your laptop, so it may save drawings and steer the phones of a poll. It is the same presenter view as on the laptop; pairing only gives it these rights. Once it is open, the launch page says so.
|
|
51
|
-
3. On the laptop, open the **audience window** from the same place and move it to the projector. It follows the iPad: slides, revealed points,
|
|
51
|
+
3. On the laptop, open the **audience window** from the same place and move it to the projector. It follows the iPad: slides, revealed points, drawings as you draw them, and videos. Navigation in the audience window also updates the iPad's slide, notes and next-slide preview.
|
|
52
|
+
|
|
53
|
+
**Videos.** A video file on a slide (`<videoplayer src="…" />`) plays, pauses and jumps on the projector as you tap it on the iPad. The sound comes from the projector: while the audience window is open, the iPad's copy plays silently. Browsers start sound only on a page someone has clicked, so click the audience window once before the talk (going full screen counts); otherwise the video plays there without sound and shows **Click for sound**.
|
|
54
|
+
|
|
55
|
+
Online videos (`<videoplayer url="…" />` from YouTube, Vimeo or SwitchTube) do not follow the iPad. They run inside the video service's own player, which mdeck cannot start or stop, so they play only on the screen where you start them. For a talk from the iPad, start them in the audience window on the laptop, or download the video and use a video file.
|
|
52
56
|
|
|
53
57
|
A pairing code works once, for ten minutes. **Unpair** on the launch page ends every pairing; so does stopping `mdeck run`. Scan a new code afterwards.
|
|
54
58
|
|
|
@@ -86,6 +86,8 @@ To start a video when its slide appears, add `play="auto"`:
|
|
|
86
86
|
|
|
87
87
|
Automatic playback is muted. Leaving the slide stops the local video; automatic videos also return to the beginning.
|
|
88
88
|
|
|
89
|
+
Presenting from an iPad paired with your laptop? Play, pause or jump in the video on the iPad, and the audience window on the projector does the same, with the sound; see [Present from an iPad](drawing.html#present-from-an-ipad).
|
|
90
|
+
|
|
89
91
|
## Show an online video
|
|
90
92
|
|
|
91
93
|
Use `url` instead of `src` for a YouTube, Vimeo, or SwitchTube address:
|
|
@@ -94,7 +96,7 @@ Use `url` instead of `src` for a YouTube, Vimeo, or SwitchTube address:
|
|
|
94
96
|
<videoplayer url="https://www.youtube.com/watch?v=YOUR_VIDEO_ID" />
|
|
95
97
|
```
|
|
96
98
|
|
|
97
|
-
Replace the example address with your video's address. Online videos need an internet connection and permission from the video service to appear in another page. Test them before your talk.
|
|
99
|
+
Replace the example address with your video's address. Online videos need an internet connection and permission from the video service to appear in another page. Test them before your talk. They play only where you start them: a paired iPad cannot start one on the projector, as it can a video file.
|
|
98
100
|
|
|
99
101
|
## If a picture does not appear
|
|
100
102
|
|
|
@@ -40,6 +40,8 @@ Use its audience-window button to open a separate view of the slides. If you hav
|
|
|
40
40
|
|
|
41
41
|
The two windows follow the same slide and reveal position: navigate in either window and the other follows. This works between windows in the same browser and between your laptop and a paired iPad. To connect the iPad, see [Draw on your slides](drawing.html#present-from-an-ipad).
|
|
42
42
|
|
|
43
|
+
A video file you play, pause or move in the presenter view does the same in the audience window, which plays the sound; the presenter view's copy stays silent while the audience window is open. Click the audience window once before the talk, so the browser lets it play sound.
|
|
44
|
+
|
|
43
45
|
Small icons next to **Speaker View** show what is connected to the deck: presenter views, audience windows and, for decks with polls, how many phones. Point at them to see on which devices, such as "Presenter view on an iPad". The launch page shows the same under **Present**, and marks each view that is open.
|
|
44
46
|
|
|
45
47
|
For a single screen, switch the presenter view to its slide-only layout. **F** or the fullscreen button fills the screen in either the presenter view or the audience window; fullscreen does not select another view.
|
package/package.json
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { h } from 'preact'
|
|
2
2
|
import { useEffect, useRef, useState } from 'preact/hooks'
|
|
3
|
+
import { t } from '../core/labels.js'
|
|
3
4
|
import './video-player.css'
|
|
4
5
|
|
|
5
6
|
function getEmbedUrl(rawUrl, autoplay = false) {
|
|
@@ -58,6 +59,66 @@ export default function VideoPlayer({ src, url, play = 'click', aspect, muted })
|
|
|
58
59
|
return () => stage.removeEventListener('slidechange', onSlideChange)
|
|
59
60
|
}, [play, src])
|
|
60
61
|
|
|
62
|
+
// Local video between the screens of a talk: play, pause or a jump on one
|
|
63
|
+
// (an iPad presenting) does the same on the others (the projector),
|
|
64
|
+
// through the stage event `mediacontrol`, which the ink bus carries
|
|
65
|
+
// (src/runtime/ink/bus.js). A video is known by its slide and its place on
|
|
66
|
+
// it. Only the slide on screen follows, and a phone following the talk
|
|
67
|
+
// does not. The presenter view's own copy goes silent while another
|
|
68
|
+
// screen shows the deck, so the sound comes from there. A screen that may
|
|
69
|
+
// not start sound yet (nobody clicked it) plays silently and offers sound.
|
|
70
|
+
const [offerSound, setOfferSound] = useState(false)
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
const video = videoRef.current
|
|
73
|
+
const stage = containerRef.current?.closest('deck-stage')
|
|
74
|
+
if (!src || !video || !stage || new URLSearchParams(location.search).get('view') === 'follow') return
|
|
75
|
+
const slide = () => containerRef.current?.closest('deck-stage > *')
|
|
76
|
+
const key = () => {
|
|
77
|
+
const own = slide()
|
|
78
|
+
if (!own) return null
|
|
79
|
+
return `${[...stage.children].indexOf(own)}:${[...own.querySelectorAll('.video-player-container')].indexOf(containerRef.current)}`
|
|
80
|
+
}
|
|
81
|
+
// Changes made to follow another screen are not sent back.
|
|
82
|
+
let following = 0
|
|
83
|
+
const tell = action => {
|
|
84
|
+
if (Date.now() < following || !slide()?.hasAttribute('data-deck-active')) return
|
|
85
|
+
if (action === 'play' && stage.hasAttribute('data-others-watching')) video.muted = true
|
|
86
|
+
stage.dispatchEvent(new CustomEvent('mediacontrol', { detail: { video: key(), action, time: video.currentTime, local: true } }))
|
|
87
|
+
}
|
|
88
|
+
const onPlay = () => tell('play')
|
|
89
|
+
const onPause = () => tell('pause')
|
|
90
|
+
const onSeeked = () => tell(video.paused ? 'pause' : 'play')
|
|
91
|
+
const follow = ({ detail }) => {
|
|
92
|
+
if (!detail || detail.local || detail.video !== key() || !slide()?.hasAttribute('data-deck-active')) return
|
|
93
|
+
following = Date.now() + 600
|
|
94
|
+
if (Number.isFinite(detail.time) && Math.abs(video.currentTime - detail.time) > 0.4) video.currentTime = detail.time
|
|
95
|
+
if (detail.action === 'pause') { video.pause(); return }
|
|
96
|
+
// Only a refusal to start sound is retried silently; a pause that came
|
|
97
|
+
// in while it was starting cancels it (AbortError) and stays.
|
|
98
|
+
video.play().catch(error => {
|
|
99
|
+
if (error?.name !== 'NotAllowedError') return
|
|
100
|
+
following = Date.now() + 600
|
|
101
|
+
video.muted = true
|
|
102
|
+
setOfferSound(true)
|
|
103
|
+
return video.play()
|
|
104
|
+
}).catch(() => {})
|
|
105
|
+
}
|
|
106
|
+
video.addEventListener('play', onPlay)
|
|
107
|
+
video.addEventListener('pause', onPause)
|
|
108
|
+
video.addEventListener('seeked', onSeeked)
|
|
109
|
+
stage.addEventListener('mediacontrol', follow)
|
|
110
|
+
return () => {
|
|
111
|
+
video.removeEventListener('play', onPlay)
|
|
112
|
+
video.removeEventListener('pause', onPause)
|
|
113
|
+
video.removeEventListener('seeked', onSeeked)
|
|
114
|
+
stage.removeEventListener('mediacontrol', follow)
|
|
115
|
+
}
|
|
116
|
+
}, [src])
|
|
117
|
+
const turnSoundOn = () => {
|
|
118
|
+
if (videoRef.current) videoRef.current.muted = false
|
|
119
|
+
setOfferSound(false)
|
|
120
|
+
}
|
|
121
|
+
|
|
61
122
|
// Web video auto mode: clear/restore src on slide change to stop/start playback
|
|
62
123
|
useEffect(() => {
|
|
63
124
|
if (play !== 'auto' || !url) return
|
|
@@ -112,12 +173,15 @@ export default function VideoPlayer({ src, url, play = 'click', aspect, muted })
|
|
|
112
173
|
style={{ width: '100%', height: fixedAspect ? '100%' : 'auto', aspectRatio: fixedAspect ? undefined : '16 / 9', border: '0', display: 'block' }} />
|
|
113
174
|
: <div class="video-player-placeholder" />
|
|
114
175
|
) : (
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
176
|
+
<>
|
|
177
|
+
<video
|
|
178
|
+
ref={videoRef}
|
|
179
|
+
src={src}
|
|
180
|
+
controls
|
|
181
|
+
style={{ width: '100%', height: fixedAspect ? '100%' : 'auto', display: 'block' }}
|
|
182
|
+
/>
|
|
183
|
+
{offerSound && <button type="button" class="video-player-sound" onClick={turnSoundOn}>{t('video.sound')}</button>}
|
|
184
|
+
</>
|
|
121
185
|
)}
|
|
122
186
|
</div>
|
|
123
187
|
)
|
|
@@ -12,3 +12,18 @@
|
|
|
12
12
|
height: 100%;
|
|
13
13
|
background: #111;
|
|
14
14
|
}
|
|
15
|
+
|
|
16
|
+
/* A screen that followed another into playing, silently because nobody has
|
|
17
|
+
clicked it yet: one click turns the sound on. */
|
|
18
|
+
.video-player-sound {
|
|
19
|
+
position: absolute;
|
|
20
|
+
top: 12px;
|
|
21
|
+
right: 12px;
|
|
22
|
+
padding: 8px 14px;
|
|
23
|
+
border: 0;
|
|
24
|
+
border-radius: 999px;
|
|
25
|
+
background: rgba(0, 0, 0, .72);
|
|
26
|
+
color: #fff;
|
|
27
|
+
font: 600 15px/1.2 system-ui, sans-serif;
|
|
28
|
+
cursor: pointer;
|
|
29
|
+
}
|
package/src/core/labels.js
CHANGED
|
@@ -33,6 +33,7 @@ export const LABELS = {
|
|
|
33
33
|
'deck.reset': 'Reset',
|
|
34
34
|
'deck.resetHint': 'Reset to first slide',
|
|
35
35
|
'poll.scan': 'Scan to vote',
|
|
36
|
+
'video.sound': 'Click for sound',
|
|
36
37
|
'poll.answer': '1 answer',
|
|
37
38
|
'poll.answers': '{n} answers',
|
|
38
39
|
'poll.reset': 'Reset',
|
|
@@ -105,6 +106,7 @@ export const LABELS = {
|
|
|
105
106
|
'deck.reset': 'Neustart',
|
|
106
107
|
'deck.resetHint': 'Zurück zur ersten Folie',
|
|
107
108
|
'poll.scan': 'Scannen und abstimmen',
|
|
109
|
+
'video.sound': 'Klicken für Ton',
|
|
108
110
|
'poll.answer': '1 Antwort',
|
|
109
111
|
'poll.answers': '{n} Antworten',
|
|
110
112
|
'poll.reset': 'Zurücksetzen',
|
package/src/runtime/ink/bus.js
CHANGED
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
// window shows (stage zoom), at most every ~50 ms
|
|
15
15
|
// { type: 'controls', key, room, controls } an activity closed or opened,
|
|
16
16
|
// its right answer or results shown or hidden (components/activity.jsx)
|
|
17
|
+
// { type: 'media', key, video, action, time } a video in the deck played,
|
|
18
|
+
// paused or moved to `time` (components/VideoPlayer.jsx)
|
|
17
19
|
// { type: 'op', op } a change of the saved ink (applyOp)
|
|
18
20
|
// Keys start with the sending window's id, so strokes never mix.
|
|
19
21
|
import { changeInk, renameSlide } from './store.js'
|
|
@@ -52,6 +54,8 @@ export function createInkBus(stage, transports) {
|
|
|
52
54
|
stage.liveStroke(message.key, known)
|
|
53
55
|
} else if (message.type === 'controls') {
|
|
54
56
|
stage.dispatchEvent(new CustomEvent('pollcontrols', { detail: { room: message.room, controls: message.controls } }))
|
|
57
|
+
} else if (message.type === 'media') {
|
|
58
|
+
stage.dispatchEvent(new CustomEvent('mediacontrol', { detail: { video: message.video, action: message.action, time: message.time } }))
|
|
55
59
|
} else if (message.type === 'zoom') {
|
|
56
60
|
if (message.slideId && message.slideId === stage._inkSlideId()) stage.setZoom(message.scale > 1 ? message : null, 'remote', { announce: false })
|
|
57
61
|
} else if (message.type === 'dot') {
|
|
@@ -105,6 +109,11 @@ export function createInkBus(stage, transports) {
|
|
|
105
109
|
if (detail?.local) send({ type: 'controls', key: `${me}:controls`, room: detail.room, controls: detail.controls })
|
|
106
110
|
})
|
|
107
111
|
|
|
112
|
+
// A video played, paused or moved here: the others do the same.
|
|
113
|
+
stage.addEventListener('mediacontrol', ({ detail }) => {
|
|
114
|
+
if (detail?.local && detail.video) send({ type: 'media', key: `${me}:media`, video: detail.video, action: detail.action, time: detail.time })
|
|
115
|
+
})
|
|
116
|
+
|
|
108
117
|
// The zoom: the latest view, not every step of a pinch.
|
|
109
118
|
let zoom = null, zoomTimer = null
|
|
110
119
|
const sendZoom = () => { zoomTimer = null; if (zoom) send({ type: 'zoom', key: `${me}:zoom`, ...zoom }) }
|
package/src/runtime/main.jsx
CHANGED
|
@@ -50,6 +50,10 @@ if (requestedView(controlUrl) === 'presenter' && !controlUrl.searchParams.has('s
|
|
|
50
50
|
history.replaceState(null, '', controlUrl)
|
|
51
51
|
}
|
|
52
52
|
const DECK_CHANNEL = `deck-control:${controlUrl.pathname}:${controlUrl.searchParams.get('session') ?? 'default'}`
|
|
53
|
+
// An audience window says it is there this often, and counts as gone when
|
|
54
|
+
// it has not said so for this long (or says so as it closes).
|
|
55
|
+
const SCREEN_SAY_MS = 3000
|
|
56
|
+
const SCREEN_GONE_MS = 8000
|
|
53
57
|
|
|
54
58
|
function withConfigOverrides(deckConfig) {
|
|
55
59
|
const url = new URL(window.location.href)
|
|
@@ -788,11 +792,33 @@ async function init() {
|
|
|
788
792
|
followStageRoom(inkStage)
|
|
789
793
|
announceStagePosition(inkStage)
|
|
790
794
|
}
|
|
795
|
+
// Whether another screen shows the deck, so that a video played in the
|
|
796
|
+
// presenter view is silent there and its sound comes from that screen
|
|
797
|
+
// (components/VideoPlayer.jsx). The server says who is connected; an
|
|
798
|
+
// audience window also says so to the windows of its own browser, for a
|
|
799
|
+
// deck opened without a server (a built folder).
|
|
800
|
+
const screens = typeof BroadcastChannel !== 'undefined' ? new BroadcastChannel(`mdeck-screens:${inkStorageKey}`) : null
|
|
801
|
+
let watchedOnServer = false, audienceHere = 0
|
|
802
|
+
const markWatched = () => inkStage?.toggleAttribute('data-others-watching', watchedOnServer || Date.now() - audienceHere < SCREEN_GONE_MS)
|
|
803
|
+
if (screens && audienceMode) {
|
|
804
|
+
const say = message => screens.postMessage(message)
|
|
805
|
+
say('audience')
|
|
806
|
+
setInterval(() => say('audience'), SCREEN_SAY_MS)
|
|
807
|
+
window.addEventListener('pagehide', () => say('gone'))
|
|
808
|
+
} else if (screens && embedded && drawHere) {
|
|
809
|
+
screens.onmessage = ({ data }) => { audienceHere = data === 'audience' ? Date.now() : 0; markWatched() }
|
|
810
|
+
setInterval(markWatched, SCREEN_SAY_MS)
|
|
811
|
+
}
|
|
812
|
+
|
|
791
813
|
if (inkStage && drawHere) {
|
|
792
814
|
attachInk(inkStage, {
|
|
793
815
|
storageKey: inkStorageKey,
|
|
794
816
|
// The presenter's main frame tells the presenter view who is connected.
|
|
795
|
-
transports: [roomTransport({ receive: true, view: embedded ? 'presenter' : 'deck', onPresence: embedded ? presence =>
|
|
817
|
+
transports: [roomTransport({ receive: true, view: embedded ? 'presenter' : 'deck', onPresence: embedded ? presence => {
|
|
818
|
+
watchedOnServer = (presence?.views ?? []).some(entry => entry.view === 'audience' || entry.view === 'deck')
|
|
819
|
+
markWatched()
|
|
820
|
+
window.parent.postMessage({ presence }, window.location.origin)
|
|
821
|
+
} : null })],
|
|
796
822
|
onMode: inking => { if (embedded) window.parent.postMessage({ inkMode: inking }, window.location.origin) },
|
|
797
823
|
})
|
|
798
824
|
// Drawing turns itself on, with the laser and the toolbar folded into
|