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
6 changes: 6 additions & 0 deletions docs/features/remote-access.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
121 changes: 121 additions & 0 deletions packages/client/composables/usePresenterDriver.ts
Original file line number Diff line number Diff line change
@@ -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<LocalPresenterDriver>({
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<PresenterDriver | undefined>
canDrive: ComputedRef<boolean>
driverRequest: ComputedRef<PresenterDriverRequest | undefined>
hasPendingRequest: ComputedRef<boolean>
isActiveDriver: ComputedRef<boolean>
localName: ComputedRef<string>
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,
}
}
19 changes: 17 additions & 2 deletions packages/client/internals/NavControls.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -37,6 +39,7 @@ const {
brush,
drawingEnabled,
} = useDrawings()
const { canDrive } = usePresenterDriver()

const md = breakpoints.smaller('md')
const { isFullscreen, toggle: toggleFullscreen } = fullscreen
Expand All @@ -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<any>()
if (__SLIDEV_FEATURE_RECORD__)
Expand All @@ -67,10 +81,10 @@ if (__SLIDEV_FEATURE_RECORD__)
<div v-if="isFullscreen" class="i-carbon:minimize" />
<div v-else class="i-carbon:maximize" />
</IconButton>
<IconButton :disabled="!hasPrev" title="Go to previous slide" @click="prev">
<IconButton :disabled="!hasPrev || !canNavigate" title="Go to previous slide" @click="goPrev">
<div class="i-carbon:arrow-left" />
</IconButton>
<IconButton :disabled="!hasNext" title="Go to next slide" @click="next">
<IconButton :disabled="!hasNext || !canNavigate" title="Go to next slide" @click="goNext">
<div class="i-carbon:arrow-right" />
</IconButton>
<IconButton v-if="!isEmbedded" title="Show slide overview" @click="toggleOverview()">
Expand Down Expand Up @@ -171,6 +185,7 @@ if (__SLIDEV_FEATURE_RECORD__)
{{ presenterLayout }}
</IconButton>

<PresenterDriverControls v-if="isPresenter" />
<SyncControls v-if="__SLIDEV_FEATURE_PRESENTER__" />

<MenuButton>
Expand Down
114 changes: 114 additions & 0 deletions packages/client/internals/PresenterDriverControls.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { usePresenterDriver } from '../composables/usePresenterDriver'
import IconButton from './IconButton.vue'
import MenuButton from './MenuButton.vue'

const {
acceptRequest: acceptPendingRequest,
activeDriver,
claim,
driverRequest,
hasPendingRequest,
isActiveDriver,
localName,
request,
} = usePresenterDriver()

const menuOpen = ref(false)

const title = computed(() => {
if (isActiveDriver.value && hasPendingRequest.value)
return `${driverRequest.value!.name} requested control of the synced deck.`
if (isActiveDriver.value)
return 'You are controlling the synced deck'
if (activeDriver.value?.name)
return `${activeDriver.value.name} is controlling the synced deck.`
return 'No active presenter driver.'
})

const status = computed(() => {
if (isActiveDriver.value)
return 'You are driving'
if (activeDriver.value?.name)
return `${activeDriver.value.name} is driving`
return 'No active driver'
})

function requestControl() {
request()
menuOpen.value = false
}

function takeControl() {
claim()
menuOpen.value = false
}

function acceptRequest() {
acceptPendingRequest()
menuOpen.value = false
}
</script>

<template>
<MenuButton v-model="menuOpen">
<template #button="{ value }">
<IconButton
:title="title"
:active="value || isActiveDriver"
>
<div
class="i-carbon:user-speaker"
:class="isActiveDriver ? 'text-green6 dark:text-green' : 'op40'"
/>
<div
v-if="hasPendingRequest"
class="absolute right-0.5 top-0.5 h-2 w-2 rounded-full bg-amber"
/>
</IconButton>
</template>
<template #menu>
<div text-sm flex="~ col gap-2" min-w-48 px3>
<div ws-nowrap>
<div text-xs op60>
Presenter driver
</div>
<div font-bold>
{{ status }}
</div>
</div>
<div class="h-1px opacity-10 bg-current w-full" />
<div v-if="localName" text-xs op60 ws-nowrap>
This presenter: {{ localName }}
</div>
<div v-if="isActiveDriver && hasPendingRequest" flex="~ col gap-1">
<div text-xs op75 ws-nowrap>
{{ driverRequest?.name }} requested control
</div>
<button
class="text-left px2 py1 rounded hover:bg-active"
@click="acceptRequest"
>
Accept request
</button>
</div>
<template v-else-if="!isActiveDriver">
<button
v-if="activeDriver"
class="text-left px2 py1 rounded hover:bg-active"
@click="requestControl"
>
Request control
</button>
<button
class="text-left px2 py1 rounded hover:bg-active"
@click="takeControl"
>
Take control now
</button>
</template>
</div>
</template>
</MenuButton>
</template>
10 changes: 9 additions & 1 deletion packages/client/setup/root.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@ 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'
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
Expand Down Expand Up @@ -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() {
Expand All @@ -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)
Expand Down
Loading
Loading