From efb7baba42bae2da9118765d6b3688d61e8eb1b5 Mon Sep 17 00:00:00 2001 From: hrithik18k Date: Tue, 2 Jun 2026 13:05:38 +0530 Subject: [PATCH] feat: coordinate multi-presenter remote sessions --- docs/features/remote-access.md | 6 + .../client/composables/usePresenterDriver.ts | 121 ++++++++++++++++++ packages/client/internals/NavControls.vue | 19 ++- .../internals/PresenterDriverControls.vue | 114 +++++++++++++++++ packages/client/setup/root.ts | 10 +- packages/client/setup/shortcuts.ts | 55 +++++--- packages/client/state/shared.ts | 4 + packages/slidev/node/vite/serverRef.ts | 2 + 8 files changed, 309 insertions(+), 22 deletions(-) create mode 100644 packages/client/composables/usePresenterDriver.ts create mode 100644 packages/client/internals/PresenterDriverControls.vue diff --git a/docs/features/remote-access.md b/docs/features/remote-access.md index 980a45c68a..a06abee2b8 100644 --- a/docs/features/remote-access.md +++ b/docs/features/remote-access.md @@ -45,6 +45,12 @@ deno run dev --remote If you want to share your slides but don't want other people to access the presenter mode, you can pass a password to the option, i.e. `--remote=your_password`. Then the password is required when accessing the presenter mode. +## Multiple Presenters + +When multiple presenter views are connected to the same remote session, Slidev keeps one active presenter driver. Only the active driver publishes navigation changes to the synced deck, so accidental key presses from other presenter laptops won't move the audience view. + +Use the presenter toolbar's driver button to see whether you are controlling the synced deck, or click it to take over. You can add a label to presenter URLs with `?presenter=alice` or `?driver=alice`; the label is shown to other presenter views when you are the active driver. + ## Remote Tunnel You can open a [Cloudflare Quick Tunnels](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/do-more-with-tunnels/trycloudflare/) to expose your local server to the internet. This way, you can share your slides with others without setting up a server. diff --git a/packages/client/composables/usePresenterDriver.ts b/packages/client/composables/usePresenterDriver.ts new file mode 100644 index 0000000000..f18327b85c --- /dev/null +++ b/packages/client/composables/usePresenterDriver.ts @@ -0,0 +1,121 @@ +import type { ComputedRef } from 'vue' +import { computed, reactive } from 'vue' +import { patch, sharedState } from '../state/shared' + +interface LocalPresenterDriver { + id: string + name: string + type: 'presenter' | 'viewer' +} + +export interface PresenterDriver { + id: string + name: string + time: number +} + +export interface PresenterDriverRequest { + id: string + name: string + time: number +} + +const localDriver = reactive({ + id: '', + name: '', + type: 'viewer', +}) + +function getPresenterName(id: string) { + const hashQuery = location.hash.includes('?') + ? location.hash.slice(location.hash.indexOf('?') + 1) + : '' + const query = new URLSearchParams(hashQuery) + new URLSearchParams(location.search).forEach((value, key) => query.set(key, value)) + return query.get('driver') || query.get('presenter') || `Presenter ${id.slice(-4)}` +} + +export function setPresenterDriverIdentity(id: string, type: 'presenter' | 'viewer') { + localDriver.id = id + localDriver.type = type + localDriver.name = getPresenterName(id) +} + +export function claimPresenterDriver() { + if (localDriver.type !== 'presenter' || !localDriver.id) + return + + setActivePresenterDriver({ + id: localDriver.id, + name: localDriver.name, + time: Date.now(), + }) +} + +export function requestPresenterDriver() { + if (localDriver.type !== 'presenter' || !localDriver.id) + return + + patch('driverRequest', { + id: localDriver.id, + name: localDriver.name, + time: Date.now(), + }) +} + +export function acceptPresenterDriverRequest() { + const request = sharedState.driverRequest + if (!request || localDriver.type !== 'presenter' || sharedState.activeDriver?.id !== localDriver.id) + return + + setActivePresenterDriver({ + id: request.id, + name: request.name, + time: Date.now(), + }) +} + +function setActivePresenterDriver(driver: PresenterDriver) { + patch('activeDriver', { + ...driver, + }) + patch('driverRequest', undefined) +} + +export function canDrivePresenter(): boolean { + if (localDriver.type !== 'presenter') + return true + + return !sharedState.activeDriver?.id || sharedState.activeDriver.id === localDriver.id +} + +export function usePresenterDriver(): { + activeDriver: ComputedRef + canDrive: ComputedRef + driverRequest: ComputedRef + hasPendingRequest: ComputedRef + isActiveDriver: ComputedRef + localName: ComputedRef + acceptRequest: () => void + claim: () => void + request: () => void +} { + const activeDriver = computed(() => sharedState.activeDriver) + const driverRequest = computed(() => sharedState.driverRequest) + const isActiveDriver = computed(() => !!activeDriver.value?.id && activeDriver.value.id === localDriver.id) + const canDrive = computed(canDrivePresenter) + const hasPendingRequest = computed(() => !!driverRequest.value?.id && driverRequest.value.id !== localDriver.id) + const localName = computed(() => localDriver.name) + + return { + activeDriver, + driverRequest, + canDrive, + hasPendingRequest, + isActiveDriver, + localName, + acceptRequest: acceptPresenterDriverRequest, + claim: claimPresenterDriver, + request: requestPresenterDriver, + } +} diff --git a/packages/client/internals/NavControls.vue b/packages/client/internals/NavControls.vue index 3bf07e72e3..212459ec22 100644 --- a/packages/client/internals/NavControls.vue +++ b/packages/client/internals/NavControls.vue @@ -3,12 +3,14 @@ import { computed, ref, shallowRef } from 'vue' import CustomNavControls from '#slidev/custom-nav-controls' import { useDrawings } from '../composables/useDrawings' import { useNav } from '../composables/useNav' +import { usePresenterDriver } from '../composables/usePresenterDriver' import { configs } from '../env' import { isColorSchemaConfigured, isDark, toggleDark } from '../logic/dark' import { activeElement, breakpoints, cursorStyle, fullscreen, hasViewerCssFilter, presenterLayout, showEditor, showInfoDialog, showPresenterCursor, toggleOverview, togglePresenterCursor, togglePresenterLayout } from '../state' import { downloadPDF } from '../utils' import IconButton from './IconButton.vue' import MenuButton from './MenuButton.vue' +import PresenterDriverControls from './PresenterDriverControls.vue' import Settings from './Settings.vue' import SyncControls from './SyncControls.vue' @@ -37,6 +39,7 @@ const { brush, drawingEnabled, } = useDrawings() +const { canDrive } = usePresenterDriver() const md = breakpoints.smaller('md') const { isFullscreen, toggle: toggleFullscreen } = fullscreen @@ -50,6 +53,17 @@ function onMouseLeave() { const barStyle = computed(() => props.persist ? 'text-$slidev-controls-foreground bg-transparent' : 'rounded-md bg-main shadow-xl border border-main') +const canNavigate = computed(() => !isPresenter.value || canDrive.value) + +function goPrev() { + if (canNavigate.value) + prev() +} + +function goNext() { + if (canNavigate.value) + next() +} const RecordingControls = shallowRef() if (__SLIDEV_FEATURE_RECORD__) @@ -67,10 +81,10 @@ if (__SLIDEV_FEATURE_RECORD__)
- +
- +
@@ -171,6 +185,7 @@ if (__SLIDEV_FEATURE_RECORD__) {{ presenterLayout }} + diff --git a/packages/client/internals/PresenterDriverControls.vue b/packages/client/internals/PresenterDriverControls.vue new file mode 100644 index 0000000000..6c6467f647 --- /dev/null +++ b/packages/client/internals/PresenterDriverControls.vue @@ -0,0 +1,114 @@ + + + diff --git a/packages/client/setup/root.ts b/packages/client/setup/root.ts index 7d1aa98aa0..d6ec43f9d3 100644 --- a/packages/client/setup/root.ts +++ b/packages/client/setup/root.ts @@ -5,6 +5,7 @@ import setups from '#slidev/setups/root' import { createFixedClicks } from '../composables/useClicks' import { useEmbeddedControl } from '../composables/useEmbeddedCtrl' import { useNav } from '../composables/useNav' +import { canDrivePresenter, claimPresenterDriver, setPresenterDriverIdentity } from '../composables/usePresenterDriver' import { usePrintStyles } from '../composables/usePrintStyles' import { injectionClicksContext, injectionCurrentPage, injectionRenderContext, injectionSlidevContext, TRUST_ORIGINS } from '../constants' import { configs, slidesTitle } from '../env' @@ -12,7 +13,7 @@ import { getSlidePath } from '../logic/slides' import { makeId } from '../logic/utils' import { hmrSkipTransition, syncDirections } from '../state' import { initDrawingState } from '../state/drawings' -import { initSharedState, onPatch, patch } from '../state/shared' +import { initSharedState, onPatch, patch, sharedState } from '../state/shared' export default function setupRoot() { const app = getCurrentInstance()!.appContext.app @@ -59,6 +60,7 @@ export default function setupRoot() { const id = `${location.origin}_${makeId()}` const syncType = computed(() => isPresenter.value ? 'presenter' : 'viewer') + watch(syncType, type => setPresenterDriverIdentity(id, type), { immediate: true }) // update shared state function updateSharedState() { @@ -73,6 +75,12 @@ export default function setupRoot() { // we allow Presenter mode, or Viewer mode from trusted origins to update the shared state if (!isPresenter.value && !TRUST_ORIGINS.includes(location.host.split(':')[0])) return + if (isPresenter.value) { + if (!canDrivePresenter()) + return + if (!sharedState.activeDriver?.id) + claimPresenterDriver() + } patch('page', +currentSlideNo.value) patch('clicks', clicksContext.value.current) diff --git a/packages/client/setup/shortcuts.ts b/packages/client/setup/shortcuts.ts index 1ed651467b..ee1c6d7cc9 100644 --- a/packages/client/setup/shortcuts.ts +++ b/packages/client/setup/shortcuts.ts @@ -3,24 +3,41 @@ import { and, not, or } from '@vueuse/math' import setups from '#slidev/setups/shortcuts' import { useDrawings } from '../composables/useDrawings' import { useNav } from '../composables/useNav' +import { canDrivePresenter } from '../composables/usePresenterDriver' import { toggleDark } from '../logic/dark' import { activeDragElement, magicKeys, showGotoDialog, showOverview, toggleOverview } from '../state' import { downloadPDF } from '../utils' import { currentOverviewPage, downOverviewPage, nextOverviewPage, prevOverviewPage, upOverviewPage } from './../logic/overview' export default function setupShortcuts() { - const { go, goFirst, goLast, next, nextSlide, prev, prevSlide } = useNav() + const { go, goFirst, goLast, isPresenter, next, nextSlide, prev, prevSlide } = useNav() const { drawingEnabled } = useDrawings() const { escape, space, shift, left, right, up, down, enter, d, g, o, '`': backtick } = magicKeys + function withPresenterDriver any>(fn: T): T { + return ((...args: Parameters) => { + if (isPresenter.value && !canDrivePresenter()) + return + return fn(...args) + }) as T + } + + const guardedGo = withPresenterDriver(go) + const guardedGoFirst = withPresenterDriver(goFirst) + const guardedGoLast = withPresenterDriver(goLast) + const guardedNext = withPresenterDriver(next) + const guardedNextSlide = withPresenterDriver(nextSlide) + const guardedPrev = withPresenterDriver(prev) + const guardedPrevSlide = withPresenterDriver(prevSlide) + const context: NavOperations = { - next, - prev, - nextSlide, - prevSlide, - go, - goFirst, - goLast, + next: guardedNext, + prev: guardedPrev, + nextSlide: guardedNextSlide, + prevSlide: guardedPrevSlide, + go: guardedGo, + goFirst: guardedGoFirst, + goLast: guardedGoLast, downloadPDF, toggleDark, toggleOverview, @@ -32,16 +49,16 @@ export default function setupShortcuts() { const navViaArrowKeys = and(not(showOverview), not(activeDragElement)) let shortcuts: ShortcutOptions[] = [ - { name: 'next_space', key: and(space, not(shift)), fn: next, autoRepeat: true }, - { name: 'prev_space', key: and(space, shift), fn: prev, autoRepeat: true }, - { name: 'next_right', key: and(right, not(shift), navViaArrowKeys), fn: next, autoRepeat: true }, - { name: 'prev_left', key: and(left, not(shift), navViaArrowKeys), fn: prev, autoRepeat: true }, - { name: 'next_page_key', key: 'pageDown', fn: next, autoRepeat: true }, - { name: 'prev_page_key', key: 'pageUp', fn: prev, autoRepeat: true }, - { name: 'next_down', key: and(down, navViaArrowKeys), fn: nextSlide, autoRepeat: true }, - { name: 'prev_up', key: and(up, navViaArrowKeys), fn: prevSlide, autoRepeat: true }, - { name: 'next_shift', key: and(right, shift), fn: nextSlide, autoRepeat: true }, - { name: 'prev_shift', key: and(left, shift), fn: prevSlide, autoRepeat: true }, + { name: 'next_space', key: and(space, not(shift)), fn: guardedNext, autoRepeat: true }, + { name: 'prev_space', key: and(space, shift), fn: guardedPrev, autoRepeat: true }, + { name: 'next_right', key: and(right, not(shift), navViaArrowKeys), fn: guardedNext, autoRepeat: true }, + { name: 'prev_left', key: and(left, not(shift), navViaArrowKeys), fn: guardedPrev, autoRepeat: true }, + { name: 'next_page_key', key: 'pageDown', fn: guardedNext, autoRepeat: true }, + { name: 'prev_page_key', key: 'pageUp', fn: guardedPrev, autoRepeat: true }, + { name: 'next_down', key: and(down, navViaArrowKeys), fn: guardedNextSlide, autoRepeat: true }, + { name: 'prev_up', key: and(up, navViaArrowKeys), fn: guardedPrevSlide, autoRepeat: true }, + { name: 'next_shift', key: and(right, shift), fn: guardedNextSlide, autoRepeat: true }, + { name: 'prev_shift', key: and(left, shift), fn: guardedPrevSlide, autoRepeat: true }, { name: 'toggle_dark', key: and(d, not(drawingEnabled)), fn: toggleDark }, { name: 'toggle_overview', key: and(or(o, backtick), not(drawingEnabled)), fn: toggleOverview }, { name: 'hide_overview', key: and(escape, not(drawingEnabled)), fn: () => showOverview.value = false }, @@ -54,7 +71,7 @@ export default function setupShortcuts() { name: 'goto_from_overview', key: and(enter, showOverview), fn: () => { - go(currentOverviewPage.value) + guardedGo(currentOverviewPage.value) showOverview.value = false }, }, diff --git a/packages/client/state/shared.ts b/packages/client/state/shared.ts index a3ece371f7..096cebd00a 100644 --- a/packages/client/state/shared.ts +++ b/packages/client/state/shared.ts @@ -1,3 +1,4 @@ +import type { PresenterDriver, PresenterDriverRequest } from '../composables/usePresenterDriver' // @ts-expect-error - virtual module import serverState from 'server-reactive:nav' import { createSyncState } from './syncState' @@ -23,6 +24,9 @@ export interface SharedState { style?: 'cursor' | 'laser' } + activeDriver?: PresenterDriver + driverRequest?: PresenterDriverRequest + lastUpdate?: { id: string type: 'presenter' | 'viewer' diff --git a/packages/slidev/node/vite/serverRef.ts b/packages/slidev/node/vite/serverRef.ts index a3d8c4e074..1d4782337f 100644 --- a/packages/slidev/node/vite/serverRef.ts +++ b/packages/slidev/node/vite/serverRef.ts @@ -14,6 +14,8 @@ export async function createServerRefPlugin( nav: { page: 0, clicks: 0, + activeDriver: undefined, + driverRequest: undefined, timer: { status: 'stopped', slides: {},