@fraku/video 0.0.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.
Files changed (44) hide show
  1. package/README.md +2 -0
  2. package/package.json +75 -0
  3. package/src/App.tsx +7 -0
  4. package/src/components/ZoomVideoPlugin/ZoomVideoPlugin.stories.tsx +50 -0
  5. package/src/components/ZoomVideoPlugin/ZoomVideoPlugin.tsx +253 -0
  6. package/src/components/ZoomVideoPlugin/components/ButtonsDock/ButtonsDock.tsx +353 -0
  7. package/src/components/ZoomVideoPlugin/components/ButtonsDock/DockButton.tsx +90 -0
  8. package/src/components/ZoomVideoPlugin/components/ButtonsDock/MenuItemTemplate.tsx +35 -0
  9. package/src/components/ZoomVideoPlugin/components/ButtonsDock/index.ts +1 -0
  10. package/src/components/ZoomVideoPlugin/components/MobileIconButton/MobileIconButton.tsx +30 -0
  11. package/src/components/ZoomVideoPlugin/components/MobileIconButton/index.ts +1 -0
  12. package/src/components/ZoomVideoPlugin/components/Overlay/Overlay.tsx +74 -0
  13. package/src/components/ZoomVideoPlugin/components/Overlay/index.ts +1 -0
  14. package/src/components/ZoomVideoPlugin/components/ParticipantsList.tsx +52 -0
  15. package/src/components/ZoomVideoPlugin/components/SettingsOverlay/SettingsContent.tsx +19 -0
  16. package/src/components/ZoomVideoPlugin/components/SettingsOverlay/SettingsMenu.tsx +30 -0
  17. package/src/components/ZoomVideoPlugin/components/SettingsOverlay/SettingsOverlay.tsx +52 -0
  18. package/src/components/ZoomVideoPlugin/components/SettingsOverlay/Tabs/AudioSettings.tsx +191 -0
  19. package/src/components/ZoomVideoPlugin/components/SettingsOverlay/Tabs/BackgroundSettings.tsx +47 -0
  20. package/src/components/ZoomVideoPlugin/components/SettingsOverlay/Tabs/DropdownItemTemplate.tsx +20 -0
  21. package/src/components/ZoomVideoPlugin/components/SettingsOverlay/Tabs/DropdownValueTemplate.tsx +12 -0
  22. package/src/components/ZoomVideoPlugin/components/SettingsOverlay/Tabs/VideoSettings.tsx +30 -0
  23. package/src/components/ZoomVideoPlugin/components/SettingsOverlay/context.ts +20 -0
  24. package/src/components/ZoomVideoPlugin/components/SettingsOverlay/index.ts +1 -0
  25. package/src/components/ZoomVideoPlugin/components/Video.tsx +35 -0
  26. package/src/components/ZoomVideoPlugin/constants.ts +4 -0
  27. package/src/components/ZoomVideoPlugin/context.ts +86 -0
  28. package/src/components/ZoomVideoPlugin/hooks/useClientMessages.ts +142 -0
  29. package/src/components/ZoomVideoPlugin/hooks/useDeviceSize.ts +24 -0
  30. package/src/components/ZoomVideoPlugin/hooks/useStartVideoOptions.ts +14 -0
  31. package/src/components/ZoomVideoPlugin/hooks/useZoomVideoPlayer.tsx +142 -0
  32. package/src/components/ZoomVideoPlugin/index.ts +2 -0
  33. package/src/components/ZoomVideoPlugin/lib/platforms.ts +17 -0
  34. package/src/components/ZoomVideoPlugin/pages/AfterSession.tsx +14 -0
  35. package/src/components/ZoomVideoPlugin/pages/MainSession.tsx +53 -0
  36. package/src/components/ZoomVideoPlugin/pages/PanelistsSession.tsx +97 -0
  37. package/src/components/ZoomVideoPlugin/pages/PreSessionConfiguration.tsx +154 -0
  38. package/src/components/ZoomVideoPlugin/types.global.d.ts +15 -0
  39. package/src/components/ZoomVideoPlugin/types.ts +23 -0
  40. package/src/global.d.ts +46 -0
  41. package/src/index.css +4 -0
  42. package/src/index.ts +4 -0
  43. package/src/main.tsx +10 -0
  44. package/src/vite-env.d.ts +12 -0
@@ -0,0 +1,52 @@
1
+ import { useEffect, useMemo, useState } from 'react'
2
+ import { Breakpoint } from '../../hooks/useDeviceSize'
3
+ import Overlay from '../Overlay'
4
+ import { SETTINGS_OVERLAY_WIDTH } from '../../constants'
5
+ import SettingsMenu from './SettingsMenu'
6
+ import SettingsContent from './SettingsContent'
7
+ import { settingsOverlayContext, SettingsOverlayContextProps, SettingsTab } from './context'
8
+
9
+ type Props = {
10
+ breakpoint: Breakpoint
11
+ visible: boolean
12
+ onHide: () => void
13
+ defaultTab?: SettingsTab
14
+ }
15
+
16
+ const SettingsOverlay = ({ visible, onHide, breakpoint, defaultTab = SettingsTab.Audio }: Props) => {
17
+ const [selectedSettingsTab, setSelectedSettingsTab] = useState<SettingsTab>(defaultTab)
18
+
19
+ useEffect(() => {
20
+ if (visible) setSelectedSettingsTab(defaultTab)
21
+ }, [defaultTab, visible])
22
+
23
+ const contextVal = useMemo<SettingsOverlayContextProps>(
24
+ () => ({
25
+ selectedSettingsTab,
26
+ setSelectedSettingsTab
27
+ }),
28
+ [selectedSettingsTab]
29
+ )
30
+
31
+ return (
32
+ <Overlay
33
+ visible={visible}
34
+ onHide={onHide}
35
+ header="Settings"
36
+ breakpoint={breakpoint}
37
+ width={SETTINGS_OVERLAY_WIDTH}
38
+ className="min-w-[30vw] min-h-[30vh] max-h-[60vh] h-full [&_.p-dialog-content]:overflow-hidden"
39
+ >
40
+ <settingsOverlayContext.Provider value={contextVal}>
41
+ <div className="flex flex-col md:flex-row divide-y md:divide-y-0 md:divide-x divide-neutral-80 h-full">
42
+ <SettingsMenu />
43
+ <div className="flex-1 overflow-y-auto p-12">
44
+ <SettingsContent />
45
+ </div>
46
+ </div>
47
+ </settingsOverlayContext.Provider>
48
+ </Overlay>
49
+ )
50
+ }
51
+
52
+ export default SettingsOverlay
@@ -0,0 +1,191 @@
1
+ import { useState, useCallback, useRef, useEffect } from 'react'
2
+ import { ProgressBar } from 'primereact/progressbar'
3
+ import cn from 'classnames'
4
+ import { Dropdown } from 'primereact/dropdown'
5
+ import { useZoomVideoContext } from '../../../context'
6
+ import DropdownValueTemplate from './DropdownValueTemplate'
7
+ import DropdownItemTemplate from './DropdownItemTemplate'
8
+
9
+ const UPDATE_INTERVAL_MS = 300
10
+
11
+ const AudioSettings = () => {
12
+ const {
13
+ activeAudioOutput,
14
+ activeMicrophone,
15
+ audioOutputList,
16
+ localAudio,
17
+ micList,
18
+ switchActiveAudioOutput,
19
+ switchActiveMicrophone
20
+ } = useZoomVideoContext()
21
+
22
+ const [micLevel, setMicLevel] = useState<number>(0)
23
+ const [speakerLevel, setSpeakerLevel] = useState<number>(0)
24
+ const [micState, setMicState] = useState<'idle' | 'recording' | 'playing'>('idle')
25
+
26
+ const micListOptions = micList.map((mic) => ({
27
+ label: mic.label || `Micrófono ${mic.deviceId}`,
28
+ value: mic.deviceId
29
+ }))
30
+
31
+ const speakerOptions = audioOutputList.map((spk) => ({
32
+ label: spk.label || `Altavoz ${spk.deviceId}`,
33
+ value: spk.deviceId
34
+ }))
35
+
36
+ const micTesterRef = useRef<any>(null)
37
+ const lastUpdateMicRef = useRef<number>(0)
38
+ const handleMicTest = useCallback(async () => {
39
+ if (!localAudio) return
40
+
41
+ if (micTesterRef.current) {
42
+ micTesterRef.current.stop?.()
43
+ micTesterRef.current = null
44
+ setMicState('idle')
45
+ setMicLevel(0)
46
+ return
47
+ }
48
+
49
+ const tester = localAudio.testMicrophone({
50
+ microphoneId: activeMicrophone,
51
+ speakerId: activeAudioOutput,
52
+ recordAndPlay: true,
53
+ onAnalyseFrequency: (v: number) => {
54
+ const now = Date.now()
55
+ // only update every UPDATE_INTERVAL_MS ms
56
+ if (now - lastUpdateMicRef.current > UPDATE_INTERVAL_MS) {
57
+ lastUpdateMicRef.current = now
58
+ setMicLevel(v)
59
+ }
60
+ },
61
+ onStartRecording: () => setMicState('recording'),
62
+ onStartPlayRecording: () => setMicState('playing'),
63
+ onStopPlayRecording: () => {
64
+ setMicState('idle')
65
+ micTesterRef.current = null
66
+ setMicLevel(0)
67
+ }
68
+ })
69
+
70
+ micTesterRef.current = tester
71
+ setMicState('recording')
72
+ }, [localAudio, activeMicrophone, activeAudioOutput])
73
+
74
+ const speakerTesterRef = useRef<any>(null)
75
+ const lastUpdateSpeakerRef = useRef<number>(0)
76
+ const handleSpeakerTest = useCallback(async () => {
77
+ if (!localAudio) return
78
+ if (speakerTesterRef.current) {
79
+ speakerTesterRef.current.destroy?.()
80
+ speakerTesterRef.current = null
81
+ setSpeakerLevel(0)
82
+ return
83
+ }
84
+ const tester = localAudio.testSpeaker({
85
+ speakerId: activeAudioOutput,
86
+ onAnalyseFrequency: (v: number) => {
87
+ const now = Date.now()
88
+ // only update every UPDATE_INTERVAL_MS ms
89
+ if (now - lastUpdateSpeakerRef.current > UPDATE_INTERVAL_MS) {
90
+ lastUpdateSpeakerRef.current = now
91
+ setSpeakerLevel(v)
92
+ }
93
+ }
94
+ })
95
+ speakerTesterRef.current = tester
96
+ }, [localAudio, activeAudioOutput])
97
+
98
+ useEffect(() => {
99
+ return () => {
100
+ micTesterRef.current?.stop?.()
101
+ speakerTesterRef.current?.destroy?.()
102
+ }
103
+ }, [])
104
+
105
+ return (
106
+ <div className="flex flex-col">
107
+ {/* Microphone Section */}
108
+ <p className="text-s font-bold">Micrófono</p>
109
+ <p className="text-s mb-8 text-neutral-40">Selecciona un método de entrada de audio</p>
110
+ <Dropdown
111
+ className="mb-16 w-full"
112
+ itemTemplate={(option) => <DropdownItemTemplate option={option} isActive={option.value === activeMicrophone} />}
113
+ placeholder="Selecciona un micrófono"
114
+ options={micListOptions}
115
+ onChange={async (e) => await switchActiveMicrophone(e.value)}
116
+ value={activeMicrophone}
117
+ valueTemplate={(option) => <DropdownValueTemplate option={option} icon="fa-regular fa-microphone" />}
118
+ />
119
+
120
+ {/* Microphone Test Header */}
121
+ <div className="mt-4 mb-8">
122
+ <p className="text-s font-bold">Prueba de micrófono</p>
123
+ <div className="mt-4 self-start">
124
+ <p className="text-s text-neutral-40 text-start">
125
+ {micState === 'idle' && 'Graba una muestra de tu voz y escúchela para verificar el funcionamiento.'}
126
+ {micState === 'recording' && 'Grabando la voz... espera unos segundos.'}
127
+ {micState === 'playing' && 'Reproduciendo grabación...'}
128
+ </p>
129
+ </div>
130
+ </div>
131
+
132
+ <button
133
+ type="button"
134
+ onClick={handleMicTest}
135
+ className={cn(
136
+ 'w-fit rounded-l p-8 font-semibold transition-all duration-200 border hover:border-neutral-70 text-white',
137
+ {
138
+ 'bg-neutral-60': micState === 'recording' || micState === 'playing',
139
+ 'bg-neutral-50': micState === 'idle'
140
+ }
141
+ )}
142
+ >
143
+ {micState === 'recording' && 'Detener grabación'}
144
+ {micState === 'playing' && 'Detener reproducción'}
145
+ {micState === 'idle' && 'Probar micrófono'}
146
+ </button>
147
+
148
+ {/* Status message */}
149
+ <ProgressBar
150
+ value={micState === 'idle' ? 0 : micLevel}
151
+ showValue={false}
152
+ className="w-full mt-4 h-8 rounded-m border border-neutral-80"
153
+ />
154
+
155
+ {/* Speaker Section */}
156
+ <div className="mt-32">
157
+ <p className="text-s font-bold">Altavoz</p>
158
+ <p className="text-s mb-8 text-neutral-40">Selecciona un método de salida de audio</p>
159
+ <Dropdown
160
+ className="mb-16 w-full"
161
+ itemTemplate={(option) => (
162
+ <DropdownItemTemplate option={option} isActive={option.value === activeAudioOutput} />
163
+ )}
164
+ options={speakerOptions}
165
+ placeholder="Selecciona un altavoz"
166
+ onChange={async (e) => await switchActiveAudioOutput(e.value)}
167
+ value={activeAudioOutput}
168
+ valueTemplate={(option) => <DropdownValueTemplate option={option} icon="fa-regular fa-volume" />}
169
+ />
170
+
171
+ <button
172
+ type="button"
173
+ onClick={handleSpeakerTest}
174
+ className={cn(
175
+ 'w-fit rounded-l p-8 font-semibold transition-all duration-200 border hover:border-neutral-70 text-white',
176
+ speakerTesterRef.current ? 'bg-neutral-60' : 'bg-neutral-50'
177
+ )}
178
+ >
179
+ {speakerTesterRef.current ? 'Detener prueba de altavoz' : 'Probar altavoz'}
180
+ </button>
181
+ <ProgressBar
182
+ value={speakerLevel}
183
+ showValue={false}
184
+ className="w-full mt-8 h-8 rounded-m border border-neutral-80"
185
+ />
186
+ </div>
187
+ </div>
188
+ )
189
+ }
190
+
191
+ export default AudioSettings
@@ -0,0 +1,47 @@
1
+ import cn from 'classnames'
2
+ import { useZoomVideoContext } from '../../../context'
3
+
4
+ const BackgroundSettings = () => {
5
+ const { isBlurred, setIsBlurred } = useZoomVideoContext()
6
+
7
+ const options = [
8
+ {
9
+ label: 'Ninguno',
10
+ icon: 'fa-ban',
11
+ isActive: !isBlurred
12
+ },
13
+ {
14
+ label: 'Desenfocar',
15
+ icon: 'fa-droplet',
16
+ isActive: isBlurred
17
+ }
18
+ ]
19
+
20
+ return (
21
+ <div className="flex flex-col">
22
+ <p className="text-s font-bold mb-8">Fondo</p>
23
+ <div className="flex gap-12">
24
+ {options.map(({ label, icon, isActive }) => {
25
+ return (
26
+ <button
27
+ key={label}
28
+ type="button"
29
+ onClick={() => setIsBlurred((prev: boolean) => (isActive ? prev : !prev))}
30
+ className={cn(
31
+ 'w-full h-80 flex flex-col items-center justify-center gap-8 border rounded-l bg-white font-bold transition-colors ease-in-out',
32
+ isActive ? '!border-neutral-60 border-2' : 'border-neutral-80 hover:border-neutral-70'
33
+ )}
34
+ >
35
+ <span className="flex items-center justify-center w-24 h-24">
36
+ <i className={cn('fa-regular fa-xl', icon)} aria-hidden="true" />
37
+ </span>
38
+ <span>{label}</span>
39
+ </button>
40
+ )
41
+ })}
42
+ </div>
43
+ </div>
44
+ )
45
+ }
46
+
47
+ export default BackgroundSettings
@@ -0,0 +1,20 @@
1
+ const DropdownItemTemplate = ({
2
+ option,
3
+ isActive
4
+ }: {
5
+ option: { label: string; value: string }
6
+ isActive: boolean
7
+ }) => {
8
+ return (
9
+ <div className="flex items-center gap-4">
10
+ {isActive && (
11
+ <span className="flex items-center justify-center w-24 h-24">
12
+ <i className="fa-regular fa-check" aria-hidden="true" />
13
+ </span>
14
+ )}
15
+ <span className="mr-2">{option.label}</span>
16
+ </div>
17
+ )
18
+ }
19
+
20
+ export default DropdownItemTemplate
@@ -0,0 +1,12 @@
1
+ const DropdownValueTemplate = ({ option, icon }: { option: { label: string; value: string }; icon: string }) => {
2
+ return (
3
+ <div className="flex items-center gap-4 font-semibold text-neutral-50">
4
+ <span className="flex items-center justify-center w-24 h-24">
5
+ <i className={icon} aria-hidden="true" />
6
+ </span>
7
+ <span className="truncate">{option.label}</span>
8
+ </div>
9
+ )
10
+ }
11
+
12
+ export default DropdownValueTemplate
@@ -0,0 +1,30 @@
1
+ import { Dropdown } from 'primereact/dropdown'
2
+ import { useZoomVideoContext } from '../../../context'
3
+ import DropdownValueTemplate from './DropdownValueTemplate'
4
+ import DropdownItemTemplate from './DropdownItemTemplate'
5
+
6
+ const VideoSettings = () => {
7
+ const { activeCamera, cameraList, setActiveCamera } = useZoomVideoContext()
8
+
9
+ const cameraOptions = cameraList.map((camera) => ({
10
+ label: camera.label || `Cámara ${camera.deviceId}`,
11
+ value: camera.deviceId
12
+ }))
13
+
14
+ return (
15
+ <div className="flex flex-col">
16
+ <p className="text-s font-bold mb-8">Cámara</p>
17
+ <Dropdown
18
+ itemTemplate={(option) => <DropdownItemTemplate option={option} isActive={option.value === activeCamera} />}
19
+ className="mb-16"
20
+ onChange={(e) => setActiveCamera(e.value)}
21
+ options={cameraOptions}
22
+ placeholder="Selecciona una cámara"
23
+ value={activeCamera ?? ''}
24
+ valueTemplate={(option) => <DropdownValueTemplate option={option} icon="fa-regular fa-video" />}
25
+ />
26
+ </div>
27
+ )
28
+ }
29
+
30
+ export default VideoSettings
@@ -0,0 +1,20 @@
1
+ import { useContext, createContext } from 'react'
2
+ import { ReactSetter } from '../../types'
3
+
4
+ export enum SettingsTab {
5
+ Audio = 'Audio',
6
+ Video = 'Video',
7
+ Background = 'Background'
8
+ }
9
+
10
+ export type SettingsOverlayContextProps = {
11
+ selectedSettingsTab: SettingsTab
12
+ setSelectedSettingsTab: ReactSetter<SettingsTab>
13
+ }
14
+
15
+ export const settingsOverlayContext = createContext<SettingsOverlayContextProps>({
16
+ selectedSettingsTab: SettingsTab.Audio,
17
+ setSelectedSettingsTab: () => {}
18
+ })
19
+
20
+ export const useSettingsOverlayContext = () => useContext(settingsOverlayContext)
@@ -0,0 +1 @@
1
+ export { default } from './SettingsOverlay'
@@ -0,0 +1,35 @@
1
+ import styled from 'styled-components'
2
+
3
+ type VideoProps = {
4
+ fullRef: React.RefObject<HTMLVideoElement> | null
5
+ }
6
+ const Video = ({ fullRef }: VideoProps) => {
7
+ return (
8
+ <StyledVideoContainer>
9
+ <video-player-container className="video-player-container">
10
+ <video-player ref={fullRef} className="video-player" />
11
+ </video-player-container>
12
+ </StyledVideoContainer>
13
+ )
14
+ }
15
+
16
+ const StyledVideoContainer = styled.div`
17
+ width: 100%;
18
+ height: auto;
19
+ aspect-ratio: 16/9;
20
+
21
+ & video-player-container {
22
+ margin-left: auto;
23
+ margin-right: auto;
24
+ width: 100%;
25
+ height: auto;
26
+
27
+ video-player {
28
+ width: 100%;
29
+ height: auto;
30
+ aspect-ratio: 16/9;
31
+ }
32
+ }
33
+ `
34
+
35
+ export default Video
@@ -0,0 +1,4 @@
1
+ export const VIDEO_PLACEHOLDER =
2
+ '<div class="video-placeholder flex items-center justify-center bg-neutral-20 w-full h-full aspect-video text-white"><i class="fa-regular fa-video-slash fa-lg"/></div>'
3
+
4
+ export const SETTINGS_OVERLAY_WIDTH = 680
@@ -0,0 +1,86 @@
1
+ import { createContext, useContext } from 'react'
2
+ import ZoomVideo, {
3
+ ActiveSpeaker,
4
+ ConnectionState,
5
+ LocalAudioTrack,
6
+ LocalVideoTrack,
7
+ MediaDevice,
8
+ Participant,
9
+ Stream,
10
+ VideoClient
11
+ } from '@zoom/videosdk'
12
+ import { Breakpoint } from './hooks/useDeviceSize'
13
+
14
+ export const zmClient = ZoomVideo.createClient()
15
+
16
+ export type ZoomVideoContextType = {
17
+ activeAudioOutput: string | undefined
18
+ activeCamera: string | undefined
19
+ activeMicrophone: string | undefined
20
+ activeSpeakers: ActiveSpeaker[]
21
+ activeVideoId: number | null
22
+ audioOutputList: MediaDevice[]
23
+ breakpoint: Breakpoint
24
+ cameraList: MediaDevice[]
25
+ closeParentContainer: () => void
26
+ connectionState: ConnectionState | null
27
+ isBlurred: boolean
28
+ isCamOn: boolean
29
+ isMicOn: boolean
30
+ localAudio: LocalAudioTrack | undefined
31
+ localVideo: LocalVideoTrack | undefined
32
+ mediaStream: typeof Stream | null
33
+ micList: MediaDevice[]
34
+ participants: Participant[]
35
+ setActiveCamera: ReactSetter<string | undefined>
36
+ setIsBlurred: ReactSetter<boolean>
37
+ setIsCamOn: ReactSetter<boolean>
38
+ setIsMicOn: ReactSetter<boolean>
39
+ setLocalAudio: ReactSetter<LocalAudioTrack>
40
+ setLocalVideo: ReactSetter<LocalVideoTrack>
41
+ setMediaStream: ReactSetter<typeof Stream | null>
42
+ setParticipants: ReactSetter<Participant[]>
43
+ stopSession: () => Promise<void>
44
+ switchActiveAudioOutput: (deviceId: string) => Promise<void>
45
+ switchActiveMicrophone: (deviceId: string) => Promise<void>
46
+ zmClient: typeof VideoClient
47
+ }
48
+
49
+ export const ZoomVideoContext = createContext<ZoomVideoContextType>({
50
+ activeAudioOutput: undefined,
51
+ activeCamera: undefined,
52
+ activeMicrophone: undefined,
53
+ activeSpeakers: [],
54
+ activeVideoId: null,
55
+ audioOutputList: [],
56
+ breakpoint: Breakpoint.Mobile,
57
+ cameraList: [],
58
+ closeParentContainer: () => {},
59
+ connectionState: null,
60
+ isBlurred: false,
61
+ isCamOn: false,
62
+ isMicOn: false,
63
+ localAudio: undefined,
64
+ localVideo: undefined,
65
+ mediaStream: null,
66
+ micList: [],
67
+ participants: [],
68
+ setActiveCamera: () => {},
69
+ setIsBlurred: () => {},
70
+ setIsCamOn: () => {},
71
+ setIsMicOn: () => {},
72
+ setLocalAudio: () => {},
73
+ setLocalVideo: () => {},
74
+ setMediaStream: () => {},
75
+ setParticipants: () => {},
76
+ stopSession: async () => {},
77
+ switchActiveAudioOutput: () => Promise.resolve(),
78
+ switchActiveMicrophone: () => Promise.resolve(),
79
+ zmClient
80
+ })
81
+
82
+ export const useZoomVideoContext = () => {
83
+ const ctx = useContext(ZoomVideoContext)
84
+ if (!ctx) throw new Error('useZoomVideo must be used within ZoomVideoProvider')
85
+ return ctx
86
+ }
@@ -0,0 +1,142 @@
1
+ import { useEffect } from 'react'
2
+ import {
3
+ type ActiveSpeaker,
4
+ CommandChannelMsg,
5
+ ConnectionChangePayload,
6
+ ConnectionState,
7
+ MediaDevice,
8
+ type Participant,
9
+ type Stream,
10
+ VideoActiveState,
11
+ type VideoClient
12
+ } from '@zoom/videosdk'
13
+ import { isAndroidOrIOSBrowser } from '../lib/platforms'
14
+
15
+ type Props = {
16
+ zmClient: typeof VideoClient | null
17
+ mediaStream: typeof Stream | null
18
+ setActiveMicrophone: ReactSetter<string | undefined>
19
+ setActiveAudioOutput: ReactSetter<string | undefined>
20
+ setActiveCamera: ReactSetter<string | undefined>
21
+ setMicList: ReactSetter<MediaDevice[]>
22
+ setAudioOutputList: ReactSetter<MediaDevice[]>
23
+ setCameraList: ReactSetter<MediaDevice[]>
24
+ setConnectionState: ReactSetter<ConnectionState | null>
25
+ setIsCamOn: ReactSetter<boolean>
26
+ setIsMicOn: ReactSetter<boolean>
27
+ setActiveSpeakers: ReactSetter<ActiveSpeaker[]>
28
+ setParticipants: ReactSetter<Participant[]>
29
+ setActiveVideoId: ReactSetter<number | null>
30
+ setHandRaises: ReactSetter<Record<string, boolean>>
31
+ }
32
+
33
+ export const useClientMessages = ({
34
+ zmClient,
35
+ mediaStream,
36
+ setActiveMicrophone,
37
+ setActiveAudioOutput,
38
+ setActiveCamera,
39
+ setMicList,
40
+ setAudioOutputList,
41
+ setCameraList,
42
+ setConnectionState,
43
+ setIsCamOn,
44
+ setIsMicOn,
45
+ setActiveSpeakers,
46
+ setActiveVideoId,
47
+ setParticipants,
48
+ setHandRaises
49
+ }: Props) => {
50
+ useEffect(() => {
51
+ if (!mediaStream || !zmClient) return
52
+
53
+ setActiveVideoId(mediaStream?.getActiveVideoId() ?? null)
54
+
55
+ const onCommandChannelMessage = (payload: CommandChannelMsg) => {
56
+ console.log('onCommandChannelMessage payload: ', payload)
57
+ try {
58
+ const data = JSON.parse(payload?.text)
59
+ if (data.type === 'RAISE_HAND') {
60
+ setHandRaises((prev) => ({ ...prev, [data.userId]: true }))
61
+ } else if (data.type === 'LOWER_HAND') {
62
+ setHandRaises((prev) => ({ ...prev, [data.userId]: false }))
63
+ }
64
+ } catch (e) {
65
+ console.warn('Invalid command message', e)
66
+ }
67
+ }
68
+
69
+ const onConnectionChange = (payload: ConnectionChangePayload) => {
70
+ setConnectionState(payload.state)
71
+ console.log(`Connection state changed to ${payload.state}, reason: ${payload.reason}`)
72
+ }
73
+
74
+ const onParticipantChange = () => {
75
+ const allUsers = zmClient.getAllUser()
76
+ setParticipants(allUsers)
77
+ }
78
+
79
+ const onActiveVideoChange = (payload: { state: VideoActiveState; userId: number }) => {
80
+ const { state, userId } = payload
81
+ if (state === 'Active') {
82
+ setActiveVideoId(userId)
83
+ console.log(`User ${userId} video is active`)
84
+ }
85
+ }
86
+
87
+ const onActiveSpeakerChange = (payload: ActiveSpeaker[]) => {
88
+ if (Array.isArray(payload)) {
89
+ setActiveSpeakers(payload)
90
+ }
91
+ }
92
+
93
+ const onDeviceChange = () => {
94
+ if (mediaStream) {
95
+ console.log('onDeviceChange: update device list')
96
+ setMicList(mediaStream.getMicList())
97
+ setAudioOutputList(mediaStream.getSpeakerList())
98
+ if (!isAndroidOrIOSBrowser()) setCameraList(mediaStream.getCameraList())
99
+ setActiveMicrophone(mediaStream.getActiveMicrophone())
100
+ setActiveAudioOutput(mediaStream.getActiveSpeaker())
101
+ setActiveCamera(mediaStream.getActiveCamera())
102
+ }
103
+ }
104
+
105
+ /* https://marketplacefront.zoom.us/sdk/custom/web/modules/ZoomVideo.VideoClient.html#on */
106
+ zmClient.on('active-speaker', onActiveSpeakerChange)
107
+ zmClient.on('command-channel-message', onCommandChannelMessage)
108
+ zmClient.on('connection-change', onConnectionChange)
109
+ zmClient.on('device-change', onDeviceChange)
110
+ zmClient.on('user-added', onParticipantChange)
111
+ zmClient.on('user-removed', onParticipantChange)
112
+ zmClient.on('user-updated', onParticipantChange)
113
+ zmClient.on('video-active-change', onActiveVideoChange) // It fires when the active speaker is talking for more than one second, allowing for a smoother user experience than the Audio active-speaker event. Event fires for active speakers who have their video on or off
114
+
115
+ return () => {
116
+ zmClient.off('active-speaker', onActiveSpeakerChange)
117
+ zmClient.off('command-channel-message', onCommandChannelMessage)
118
+ zmClient.off('connection-change', onConnectionChange)
119
+ zmClient.off('device-change', onDeviceChange)
120
+ zmClient.off('user-added', onParticipantChange)
121
+ zmClient.off('user-removed', onParticipantChange)
122
+ zmClient.off('user-updated', onParticipantChange)
123
+ zmClient.off('video-active-change', onActiveVideoChange)
124
+ }
125
+ }, [
126
+ mediaStream,
127
+ setActiveCamera,
128
+ setActiveMicrophone,
129
+ setActiveAudioOutput,
130
+ setActiveSpeakers,
131
+ setActiveVideoId,
132
+ setCameraList,
133
+ setConnectionState,
134
+ setHandRaises,
135
+ setIsCamOn,
136
+ setIsMicOn,
137
+ setMicList,
138
+ setParticipants,
139
+ zmClient,
140
+ setAudioOutputList
141
+ ])
142
+ }
@@ -0,0 +1,24 @@
1
+ import { useMemo } from 'react'
2
+ import { useMeasure } from 'react-use'
3
+
4
+ export enum Breakpoint {
5
+ Mobile,
6
+ Tablet,
7
+ Desktop
8
+ }
9
+
10
+ // TODO: Use tailwind screens when created a new project with zoom video
11
+ const MOBILE_MAX = 768
12
+ const TABLET_MAX = 1024
13
+
14
+ export const useContainerSize = () => {
15
+ const [ref, { width }] = useMeasure<HTMLDivElement>()
16
+
17
+ const breakpoint = useMemo(() => {
18
+ if (width < MOBILE_MAX) return Breakpoint.Mobile
19
+ if (width < TABLET_MAX) return Breakpoint.Tablet
20
+ return Breakpoint.Desktop
21
+ }, [width])
22
+
23
+ return { ref, width, breakpoint }
24
+ }