├── Gemfile ├── .prettierrc ├── apps ├── web │ ├── src │ │ ├── react-app-env.d.ts │ │ ├── __mocks__ │ │ │ ├── fileMock.ts │ │ │ ├── @twilio │ │ │ │ └── conversations.ts │ │ │ └── twilio-video.ts │ │ ├── images │ │ │ ├── Desert.jpg │ │ │ ├── Flower.jpg │ │ │ ├── Nature.jpg │ │ │ ├── Ocean.jpg │ │ │ ├── Patio.jpg │ │ │ ├── Plant.jpg │ │ │ ├── Abstract.jpg │ │ │ ├── BohoHome.jpg │ │ │ ├── Bookshelf.jpg │ │ │ ├── CozyHome.jpg │ │ │ ├── Fishing.jpg │ │ │ ├── Kitchen.jpg │ │ │ ├── CoffeeShop.jpg │ │ │ ├── ModernHome.jpg │ │ │ ├── thumb │ │ │ │ ├── Ocean.jpg │ │ │ │ ├── Patio.jpg │ │ │ │ ├── Plant.jpg │ │ │ │ ├── Abstract.jpg │ │ │ │ ├── BohoHome.jpg │ │ │ │ ├── CozyHome.jpg │ │ │ │ ├── Desert.jpg │ │ │ │ ├── Fishing.jpg │ │ │ │ ├── Flower.jpg │ │ │ │ ├── Kitchen.jpg │ │ │ │ ├── Nature.jpg │ │ │ │ ├── Bookshelf.jpg │ │ │ │ ├── CoffeeShop.jpg │ │ │ │ ├── ModernHome.jpg │ │ │ │ ├── Contemporary.jpg │ │ │ │ └── SanFrancisco.jpg │ │ │ ├── Contemporary.jpg │ │ │ └── SanFrancisco.jpg │ │ ├── hooks │ │ │ ├── useMediaStreamTrack │ │ │ │ ├── __mocks__ │ │ │ │ │ └── useMediaStreamTrack.ts │ │ │ │ ├── useMediaStreamTrack.ts │ │ │ │ └── useMediaStreamTrack.test.ts │ │ │ ├── useQuery │ │ │ │ ├── useQuery.ts │ │ │ │ └── useQuery.test.ts │ │ │ ├── useChatContext │ │ │ │ ├── useChatContext.ts │ │ │ │ └── useChatContext.test.tsx │ │ │ ├── useSyncContext │ │ │ │ └── useSyncContext.ts │ │ │ ├── useVideoContext │ │ │ │ ├── useVideoContext.ts │ │ │ │ └── useVideoContext.test.ts │ │ │ ├── usePlayerContext │ │ │ │ └── usePlayerContext.ts │ │ │ ├── useSnackbar │ │ │ │ └── useSnackbar.ts │ │ │ ├── useHeight │ │ │ │ ├── useHeight.ts │ │ │ │ └── useHeight.test.tsx │ │ │ ├── useLocalAudioToggle │ │ │ │ └── useLocalAudioToggle.tsx │ │ │ ├── usePlayerState │ │ │ │ └── usePlayerState.tsx │ │ │ ├── useParticipantNetworkQualityLevel │ │ │ │ ├── useParticipantNetworkQualityLevel.tsx │ │ │ │ └── useParticipantNetworkQualityLevel.test.tsx │ │ │ ├── useParticipantIsReconnecting │ │ │ │ └── useParticipantIsReconnecting.ts │ │ │ ├── useTrack │ │ │ │ ├── useTrack.tsx │ │ │ │ └── useTrack.test.tsx │ │ │ ├── useVideoTrackDimensions │ │ │ │ └── useVideoTrackDimensions.tsx │ │ │ ├── useIsRecording │ │ │ │ └── useIsRecording.ts │ │ │ ├── useRoomState │ │ │ │ └── useRoomState.tsx │ │ │ ├── useIsTrackEnabled │ │ │ │ └── useIsTrackEnabled.tsx │ │ │ ├── useDevices │ │ │ │ └── useDevices.tsx │ │ │ ├── usePublicationIsTrackEnabled │ │ │ │ └── usePublicationIsTrackEnabled.tsx │ │ │ ├── useViewersMap │ │ │ │ └── useViewersMap.tsx │ │ │ ├── useIsTrackSwitchedOff │ │ │ │ └── useIsTrackSwitchedOff.tsx │ │ │ ├── useMainParticipant │ │ │ │ └── useMainParticipant.tsx │ │ │ ├── usePublications │ │ │ │ └── usePublications.tsx │ │ │ ├── useRaisedHandsMap │ │ │ │ └── useRaisedHandsMap.ts │ │ │ ├── useLocalVideoToggle │ │ │ │ └── useLocalVideoToggle.tsx │ │ │ └── useSpeakersMap │ │ │ │ └── useSpeakersMap.ts │ │ ├── setupProxy.js │ │ ├── components │ │ │ ├── ErrorDialog │ │ │ │ ├── enhanceMessage.ts │ │ │ │ └── ErrorDialog.tsx │ │ │ ├── ReconnectingNotification │ │ │ │ └── ReconnectingNotification.tsx │ │ │ ├── LocalAudioLevelIndicator │ │ │ │ └── LocalAudioLevelIndicator.tsx │ │ │ ├── VideoProvider │ │ │ │ ├── useHandleTrackPublicationFailed │ │ │ │ │ └── useHandleTrackPublicationFailed.ts │ │ │ │ └── useRestartAudioTrackOnDeviceChange │ │ │ │ │ └── useRestartAudioTrackOnDeviceChange.ts │ │ │ ├── BackgroundSelectionDialog │ │ │ │ └── BackgroundSelectionHeader │ │ │ │ │ ├── BackgroundSelectionHeader.test.tsx │ │ │ │ │ └── BackgroundSelectionHeader.tsx │ │ │ ├── ParticipantTracks │ │ │ │ └── __snapshots__ │ │ │ │ │ └── ParticipantTracks.test.tsx.snap │ │ │ ├── IntroContainer │ │ │ │ ├── TwilioLogo.tsx │ │ │ │ └── UserMenu │ │ │ │ │ └── UserMenu.tsx │ │ │ ├── PreJoinScreens │ │ │ │ └── LoadingScreen │ │ │ │ │ └── LoadingScreen.tsx │ │ │ ├── PrivateRoute │ │ │ │ └── PrivateRoute.tsx │ │ │ ├── ChatWindow │ │ │ │ ├── ChatWindowHeader │ │ │ │ │ ├── ChatWindowHeader.test.tsx │ │ │ │ │ └── ChatWindowHeader.tsx │ │ │ │ └── MessageList │ │ │ │ │ └── MessageInfo │ │ │ │ │ └── MessageInfo.tsx │ │ │ ├── UnsupportedBrowserWarning │ │ │ │ └── UnsupportedBrowserWarning.test.tsx │ │ │ ├── AudioTrack │ │ │ │ └── AudioTrack.tsx │ │ │ ├── NetworkQualityLevel │ │ │ │ └── NetworkQualityLevel.test.tsx │ │ │ ├── Buttons │ │ │ │ ├── ToggleAudioButton │ │ │ │ │ └── ToggleAudioButton.tsx │ │ │ │ └── ToggleVideoButton │ │ │ │ │ └── ToggleVideoButton.tsx │ │ │ ├── ParticipantInfo │ │ │ │ ├── ParticipantConnectionIndicator │ │ │ │ │ └── ParticipantConnectionIndicator.tsx │ │ │ │ └── PinIcon │ │ │ │ │ └── PinIcon.tsx │ │ │ ├── ViewersList │ │ │ │ └── RaisedHand │ │ │ │ │ └── RaisedHand.tsx │ │ │ ├── MobileTopMenuBar │ │ │ │ └── MobileTopMenuBar.tsx │ │ │ ├── Snackbar │ │ │ │ ├── Snackbar.test.tsx │ │ │ │ └── SnackbarProvider.tsx │ │ │ ├── Participant │ │ │ │ └── Participant.tsx │ │ │ ├── ParticipantWindow │ │ │ │ ├── ParticipantWindow.tsx │ │ │ │ └── ParticipantWindowHeader │ │ │ │ │ └── ParticipantWindowHeader.tsx │ │ │ ├── DeviceSelectionDialog │ │ │ │ └── AudioOutputList │ │ │ │ │ └── AudioOutputList.tsx │ │ │ └── Publication │ │ │ │ └── Publication.tsx │ │ ├── constants.ts │ │ ├── icons │ │ │ ├── VideoOnIcon.tsx │ │ │ ├── ChatIcon.tsx │ │ │ ├── InfoIconOutlined.tsx │ │ │ ├── SuccessIcon.tsx │ │ │ ├── StopRecordingIcon.tsx │ │ │ ├── MicIcon.tsx │ │ │ ├── InfoIcon.tsx │ │ │ ├── SpeakerMenuIcon.tsx │ │ │ ├── StartRecordingIcon.tsx │ │ │ ├── RightArrowIcon.tsx │ │ │ ├── BackArrowIcon.tsx │ │ │ ├── CloseIcon.tsx │ │ │ ├── ScreenShareIcon.tsx │ │ │ ├── ParticipantIcon.tsx │ │ │ ├── SendMessageIcon.tsx │ │ │ ├── VideoOffIcon.tsx │ │ │ ├── AvatarIcon.tsx │ │ │ ├── CreateEventIcon.tsx │ │ │ ├── SpeakerIcon.tsx │ │ │ ├── MicOffIcon.tsx │ │ │ ├── WarningIcon.tsx │ │ │ └── BackgroundIcon.tsx │ │ ├── setupTests.ts │ │ ├── state │ │ │ ├── useActiveSinkId │ │ │ │ └── useActiveSinkId.ts │ │ │ └── settings │ │ │ │ └── settingsReducer.test.ts │ │ └── types.ts │ ├── public │ │ ├── robots.txt │ │ ├── favicon.ico │ │ └── .well-known │ │ │ ├── apple-app-site-association │ │ │ └── assetlinks.json │ ├── .github │ │ ├── ISSUE_TEMPLATE │ │ │ ├── question.md │ │ │ ├── feature_request.md │ │ │ └── bug_report.md │ │ └── pull_request_template.md │ ├── .gitignore │ ├── tsconfig.json │ ├── jest.config.js │ └── scripts │ │ └── build.js └── ios │ └── LiveVideo │ ├── LiveVideo │ ├── Assets.xcassets │ │ ├── Contents.json │ │ ├── Colors │ │ │ ├── Contents.json │ │ │ ├── ShadowLow.colorset │ │ │ │ └── Contents.json │ │ │ ├── Border.colorset │ │ │ │ └── Contents.json │ │ │ ├── Background.colorset │ │ │ │ └── Contents.json │ │ │ ├── IconPurple.colorset │ │ │ │ └── Contents.json │ │ │ ├── TextWeak.colorset │ │ │ │ └── Contents.json │ │ │ ├── BackgroundBrand.colorset │ │ │ │ └── Contents.json │ │ │ ├── BackgroundPrimary.colorset │ │ │ │ └── Contents.json │ │ │ ├── BackgroundStrong.colorset │ │ │ │ └── Contents.json │ │ │ ├── BorderSuccessWeak.colorset │ │ │ │ └── Contents.json │ │ │ ├── BackgroundDestructive.colorset │ │ │ │ └── Contents.json │ │ │ ├── BackgroundHighlight.colorset │ │ │ │ └── Contents.json │ │ │ ├── BackgroundLiveBadge.colorset │ │ │ │ └── Contents.json │ │ │ ├── BackgroundPrimaryWeak.colorset │ │ │ │ └── Contents.json │ │ │ ├── BackgroundStronger.colorset │ │ │ │ └── Contents.json │ │ │ ├── BackgroundStrongest.colorset │ │ │ │ └── Contents.json │ │ │ ├── BorderWeaker.colorset │ │ │ │ └── Contents.json │ │ │ ├── BackgroundBrandStronger.colorset │ │ │ │ └── Contents.json │ │ │ ├── BackgroundPrimaryWeaker.colorset │ │ │ │ └── Contents.json │ │ │ ├── BackgroundPrimaryWeakest.colorset │ │ │ │ └── Contents.json │ │ │ └── BackgroundSuccess.colorset │ │ │ │ └── Contents.json │ │ ├── Images │ │ │ ├── Contents.json │ │ │ ├── LiveVideoLaunchLogo.imageset │ │ │ │ ├── LiveVideoLaunchLogo.pdf │ │ │ │ └── Contents.json │ │ │ ├── RedTwilioLogo.imageset │ │ │ │ └── Contents.json │ │ │ ├── LaunchBackground.imageset │ │ │ │ └── Contents.json │ │ │ └── TwilioLaunchLogo.imageset │ │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset │ │ │ ├── AppIcon1024.png │ │ │ ├── AppIcon20@2x.png │ │ │ ├── AppIcon20@3x.png │ │ │ ├── AppIcon29@2x.png │ │ │ ├── AppIcon29@3x.png │ │ │ ├── AppIcon40@2x.png │ │ │ ├── AppIcon40@3x.png │ │ │ ├── AppIcon60@2x.png │ │ │ └── AppIcon60@3x.png │ │ └── AccentColor.colorset │ │ │ └── Contents.json │ ├── Preview Content │ │ └── Preview Assets.xcassets │ │ │ └── Contents.json │ ├── LiveVideoApp.swift │ ├── Managers │ │ ├── AppSettings │ │ │ ├── AppSettingsManager.swift │ │ │ └── TwilioEnvironment.swift │ │ ├── AppInfo │ │ │ └── AppInfoStore.swift │ │ ├── API │ │ │ ├── APIErrorResponse.swift │ │ │ ├── APIRequest.swift │ │ │ ├── VerifyPasscodeRequest.swift │ │ │ ├── DeleteStreamRequest.swift │ │ │ ├── RemoveSpeakerRequest.swift │ │ │ ├── SendSpeakerInviteRequest.swift │ │ │ ├── ViewerConnectedToPlayerRequest.swift │ │ │ ├── RaiseHandRequest.swift │ │ │ └── CreateOrJoinStreamRequest.swift │ │ ├── HostControls │ │ │ └── HostControlsManager.swift │ │ ├── Auth │ │ │ └── PasscodeComponents.swift │ │ └── SpeakerSettings │ │ │ └── SpeakerSettingsManager.swift │ ├── Views │ │ ├── Style │ │ │ ├── TitleStyle.swift │ │ │ ├── TipStyle.swift │ │ │ ├── ErrorAlert.swift │ │ │ ├── PrimaryButtonStyle.swift │ │ │ ├── FormTextFieldStyle.swift │ │ │ ├── ProgressHUD.swift │ │ │ └── FormStack.swift │ │ ├── Stream │ │ │ ├── DisplayNameFactory.swift │ │ │ ├── SwiftUIPlayerView.swift │ │ │ ├── PresentationLayout │ │ │ │ ├── PresentationVideoView.swift │ │ │ │ └── PresentationStatusView.swift │ │ │ ├── SpeakerVideoViewModelFactory.swift │ │ │ ├── LiveBadge.swift │ │ │ ├── StreamToolbar.swift │ │ │ ├── OffscreenSpeakersView.swift │ │ │ └── StreamStatusView.swift │ │ ├── Participants │ │ │ └── ParticipantsHeader.swift │ │ ├── Settings │ │ │ ├── TitleValueView.swift │ │ │ └── SignInSettingsView.swift │ │ ├── StreamConfigFlow │ │ │ └── StreamConfigFlowModel.swift │ │ ├── Chat │ │ │ ├── ChatBubbleView.swift │ │ │ ├── ChatView.swift │ │ │ ├── ChatHeaderView.swift │ │ │ └── ChatInputBar.swift │ │ └── Home │ │ │ └── EnvironmentBadge.swift │ ├── Helpers │ │ ├── JSONDecoderExtension.swift │ │ ├── JSONEncoderExtension.swift │ │ └── LiveVideoError.swift │ ├── Twilio │ │ ├── Room │ │ │ ├── RoomMessage.swift │ │ │ └── TrackName.swift │ │ ├── Sync │ │ │ └── SyncObjectConnecting.swift │ │ ├── Stream │ │ │ └── StreamConfig.swift │ │ └── Chat │ │ │ └── ChatMessage.swift │ ├── ContentView.swift │ └── Launch │ │ ├── AppDelegate.swift │ │ └── SceneDelegate.swift │ ├── fastlane │ ├── Appfile │ ├── Matchfile │ └── README.md │ ├── LiveVideo.xcodeproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ └── IDEWorkspaceChecks.plist │ └── xcshareddata │ │ └── IDETemplateMacros.plist │ ├── LiveVideoTests │ └── Info.plist │ └── LiveVideoUITests │ ├── Info.plist │ └── LiveVideoUITests.swift ├── .circleci ├── .netrc └── config.yml ├── .gitignore ├── .github ├── ISSUE_TEMPLATE │ ├── question.md │ ├── feature_request.md │ └── bug_report.md └── pull_request_template.md ├── serverless ├── constants.js ├── functions │ ├── verify-passcode.js │ ├── remove-speaker.js │ ├── delete-stream.js │ └── send-speaker-invite.js ├── middleware │ ├── auth.private.js │ └── common.private.js └── scripts │ └── list.js ├── .env.example └── package.json /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'fastlane' 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | trailingComma: "es5" 2 | singleQuote: true 3 | printWidth: 120 4 | -------------------------------------------------------------------------------- /apps/web/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /apps/web/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | -------------------------------------------------------------------------------- /apps/web/src/__mocks__/fileMock.ts: -------------------------------------------------------------------------------- 1 | const mockFile = 'test-file-stub'; 2 | export default mockFile; 3 | -------------------------------------------------------------------------------- /.circleci/.netrc: -------------------------------------------------------------------------------- 1 | machine twilio.jfrog.io 2 | login ARTIFACTORY_USERNAME 3 | password ARTIFACTORY_API_KEY -------------------------------------------------------------------------------- /apps/web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio/twilio-live-interactive-video/HEAD/apps/web/public/favicon.ico -------------------------------------------------------------------------------- /apps/web/src/images/Desert.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio/twilio-live-interactive-video/HEAD/apps/web/src/images/Desert.jpg -------------------------------------------------------------------------------- /apps/web/src/images/Flower.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio/twilio-live-interactive-video/HEAD/apps/web/src/images/Flower.jpg -------------------------------------------------------------------------------- /apps/web/src/images/Nature.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio/twilio-live-interactive-video/HEAD/apps/web/src/images/Nature.jpg -------------------------------------------------------------------------------- /apps/web/src/images/Ocean.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio/twilio-live-interactive-video/HEAD/apps/web/src/images/Ocean.jpg -------------------------------------------------------------------------------- /apps/web/src/images/Patio.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio/twilio-live-interactive-video/HEAD/apps/web/src/images/Patio.jpg -------------------------------------------------------------------------------- /apps/web/src/images/Plant.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio/twilio-live-interactive-video/HEAD/apps/web/src/images/Plant.jpg -------------------------------------------------------------------------------- /apps/web/src/images/Abstract.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio/twilio-live-interactive-video/HEAD/apps/web/src/images/Abstract.jpg -------------------------------------------------------------------------------- /apps/web/src/images/BohoHome.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio/twilio-live-interactive-video/HEAD/apps/web/src/images/BohoHome.jpg -------------------------------------------------------------------------------- /apps/web/src/images/Bookshelf.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio/twilio-live-interactive-video/HEAD/apps/web/src/images/Bookshelf.jpg -------------------------------------------------------------------------------- /apps/web/src/images/CozyHome.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio/twilio-live-interactive-video/HEAD/apps/web/src/images/CozyHome.jpg -------------------------------------------------------------------------------- /apps/web/src/images/Fishing.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio/twilio-live-interactive-video/HEAD/apps/web/src/images/Fishing.jpg -------------------------------------------------------------------------------- /apps/web/src/images/Kitchen.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio/twilio-live-interactive-video/HEAD/apps/web/src/images/Kitchen.jpg -------------------------------------------------------------------------------- /apps/web/src/images/CoffeeShop.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio/twilio-live-interactive-video/HEAD/apps/web/src/images/CoffeeShop.jpg -------------------------------------------------------------------------------- /apps/web/src/images/ModernHome.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio/twilio-live-interactive-video/HEAD/apps/web/src/images/ModernHome.jpg -------------------------------------------------------------------------------- /apps/web/src/images/thumb/Ocean.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio/twilio-live-interactive-video/HEAD/apps/web/src/images/thumb/Ocean.jpg -------------------------------------------------------------------------------- /apps/web/src/images/thumb/Patio.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio/twilio-live-interactive-video/HEAD/apps/web/src/images/thumb/Patio.jpg -------------------------------------------------------------------------------- /apps/web/src/images/thumb/Plant.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio/twilio-live-interactive-video/HEAD/apps/web/src/images/thumb/Plant.jpg -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /apps/web/src/images/Contemporary.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio/twilio-live-interactive-video/HEAD/apps/web/src/images/Contemporary.jpg -------------------------------------------------------------------------------- /apps/web/src/images/SanFrancisco.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio/twilio-live-interactive-video/HEAD/apps/web/src/images/SanFrancisco.jpg -------------------------------------------------------------------------------- /apps/web/src/images/thumb/Abstract.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio/twilio-live-interactive-video/HEAD/apps/web/src/images/thumb/Abstract.jpg -------------------------------------------------------------------------------- /apps/web/src/images/thumb/BohoHome.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio/twilio-live-interactive-video/HEAD/apps/web/src/images/thumb/BohoHome.jpg -------------------------------------------------------------------------------- /apps/web/src/images/thumb/CozyHome.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio/twilio-live-interactive-video/HEAD/apps/web/src/images/thumb/CozyHome.jpg -------------------------------------------------------------------------------- /apps/web/src/images/thumb/Desert.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio/twilio-live-interactive-video/HEAD/apps/web/src/images/thumb/Desert.jpg -------------------------------------------------------------------------------- /apps/web/src/images/thumb/Fishing.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio/twilio-live-interactive-video/HEAD/apps/web/src/images/thumb/Fishing.jpg -------------------------------------------------------------------------------- /apps/web/src/images/thumb/Flower.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio/twilio-live-interactive-video/HEAD/apps/web/src/images/thumb/Flower.jpg -------------------------------------------------------------------------------- /apps/web/src/images/thumb/Kitchen.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio/twilio-live-interactive-video/HEAD/apps/web/src/images/thumb/Kitchen.jpg -------------------------------------------------------------------------------- /apps/web/src/images/thumb/Nature.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio/twilio-live-interactive-video/HEAD/apps/web/src/images/thumb/Nature.jpg -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Assets.xcassets/Colors/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Assets.xcassets/Images/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /apps/web/src/images/thumb/Bookshelf.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio/twilio-live-interactive-video/HEAD/apps/web/src/images/thumb/Bookshelf.jpg -------------------------------------------------------------------------------- /apps/web/src/images/thumb/CoffeeShop.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio/twilio-live-interactive-video/HEAD/apps/web/src/images/thumb/CoffeeShop.jpg -------------------------------------------------------------------------------- /apps/web/src/images/thumb/ModernHome.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio/twilio-live-interactive-video/HEAD/apps/web/src/images/thumb/ModernHome.jpg -------------------------------------------------------------------------------- /apps/web/src/images/thumb/Contemporary.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio/twilio-live-interactive-video/HEAD/apps/web/src/images/thumb/Contemporary.jpg -------------------------------------------------------------------------------- /apps/web/src/images/thumb/SanFrancisco.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio/twilio-live-interactive-video/HEAD/apps/web/src/images/thumb/SanFrancisco.jpg -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/fastlane/Appfile: -------------------------------------------------------------------------------- 1 | team_id "SX5J6BN2KX" 2 | 3 | # For more information about the Appfile, see: 4 | # https://docs.fastlane.tools/advanced/#appfile 5 | -------------------------------------------------------------------------------- /apps/web/src/hooks/useMediaStreamTrack/__mocks__/useMediaStreamTrack.ts: -------------------------------------------------------------------------------- 1 | const useMediaStreamTrack = (track: any) => track?.mediaStreamTrack; 2 | export default useMediaStreamTrack; 3 | -------------------------------------------------------------------------------- /apps/web/src/hooks/useQuery/useQuery.ts: -------------------------------------------------------------------------------- 1 | import { useLocation } from 'react-router-dom'; 2 | 3 | export function useQuery() { 4 | const { search } = useLocation(); 5 | return new URLSearchParams(search); 6 | } 7 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Assets.xcassets/AppIcon.appiconset/AppIcon1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio/twilio-live-interactive-video/HEAD/apps/ios/LiveVideo/LiveVideo/Assets.xcassets/AppIcon.appiconset/AppIcon1024.png -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Assets.xcassets/AppIcon.appiconset/AppIcon20@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio/twilio-live-interactive-video/HEAD/apps/ios/LiveVideo/LiveVideo/Assets.xcassets/AppIcon.appiconset/AppIcon20@2x.png -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Assets.xcassets/AppIcon.appiconset/AppIcon20@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio/twilio-live-interactive-video/HEAD/apps/ios/LiveVideo/LiveVideo/Assets.xcassets/AppIcon.appiconset/AppIcon20@3x.png -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Assets.xcassets/AppIcon.appiconset/AppIcon29@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio/twilio-live-interactive-video/HEAD/apps/ios/LiveVideo/LiveVideo/Assets.xcassets/AppIcon.appiconset/AppIcon29@2x.png -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Assets.xcassets/AppIcon.appiconset/AppIcon29@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio/twilio-live-interactive-video/HEAD/apps/ios/LiveVideo/LiveVideo/Assets.xcassets/AppIcon.appiconset/AppIcon29@3x.png -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Assets.xcassets/AppIcon.appiconset/AppIcon40@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio/twilio-live-interactive-video/HEAD/apps/ios/LiveVideo/LiveVideo/Assets.xcassets/AppIcon.appiconset/AppIcon40@2x.png -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Assets.xcassets/AppIcon.appiconset/AppIcon40@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio/twilio-live-interactive-video/HEAD/apps/ios/LiveVideo/LiveVideo/Assets.xcassets/AppIcon.appiconset/AppIcon40@3x.png -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Assets.xcassets/AppIcon.appiconset/AppIcon60@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio/twilio-live-interactive-video/HEAD/apps/ios/LiveVideo/LiveVideo/Assets.xcassets/AppIcon.appiconset/AppIcon60@2x.png -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Assets.xcassets/AppIcon.appiconset/AppIcon60@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio/twilio-live-interactive-video/HEAD/apps/ios/LiveVideo/LiveVideo/Assets.xcassets/AppIcon.appiconset/AppIcon60@3x.png -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/fastlane/Matchfile: -------------------------------------------------------------------------------- 1 | git_url(ENV["FASTLANE_MATCH_GIT_URL"]) 2 | 3 | storage_mode("git") 4 | 5 | type("development") 6 | 7 | username(ENV["FASTLANE_MATCH_USERNAME"]) 8 | 9 | # The docs are available on https://docs.fastlane.tools/actions/match 10 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Assets.xcassets/Images/LiveVideoLaunchLogo.imageset/LiveVideoLaunchLogo.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio/twilio-live-interactive-video/HEAD/apps/ios/LiveVideo/LiveVideo/Assets.xcassets/Images/LiveVideoLaunchLogo.imageset/LiveVideoLaunchLogo.pdf -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/LiveVideoApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (C) 2021 Twilio, Inc. 3 | // 4 | 5 | import SwiftUI 6 | 7 | @main 8 | struct LiveVideoApp: App { 9 | var body: some Scene { 10 | WindowGroup { 11 | ContentView() 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Managers/AppSettings/AppSettingsManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (C) 2022 Twilio, Inc. 3 | // 4 | 5 | import SwiftUI 6 | 7 | class AppSettingsManager: ObservableObject { 8 | @AppStorage("TwilioEnvironment") var environment: TwilioEnvironment = .prod 9 | } 10 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Assets.xcassets/Images/RedTwilioLogo.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "RedTwilioLogo.pdf", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Assets.xcassets/Images/LaunchBackground.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "LaunchBackground.pdf", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Assets.xcassets/Images/TwilioLaunchLogo.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "TwilioLaunchLogo.pdf", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Assets.xcassets/Images/LiveVideoLaunchLogo.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "LiveVideoLaunchLogo.pdf", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Views/Style/TitleStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (C) 2021 Twilio, Inc. 3 | // 4 | 5 | import SwiftUI 6 | 7 | struct TitleStyle: ViewModifier { 8 | func body(content: Content) -> some View { 9 | content 10 | .font(.system(size: 28, weight: .bold)) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Managers/AppInfo/AppInfoStore.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (C) 2021 Twilio, Inc. 3 | // 4 | 5 | import Foundation 6 | 7 | class AppInfoStore { 8 | var version: String { bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as! String } 9 | private let bundle = Bundle.main 10 | } 11 | -------------------------------------------------------------------------------- /apps/web/public/.well-known/apple-app-site-association: -------------------------------------------------------------------------------- 1 | { 2 | "applinks": { 3 | "apps": [], 4 | "details": [{ 5 | "appID": "SX5J6BN2KX.com.twilio.video-app", 6 | "paths": ["/room/*"] 7 | }, 8 | { 9 | "appID": "SX5J6BN2KX.com.twilio.video-app-internal", 10 | "paths": ["/room/*"] 11 | } 12 | ] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Helpers/JSONDecoderExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (C) 2021 Twilio, Inc. 3 | // 4 | 5 | import Foundation 6 | 7 | extension JSONDecoder { 8 | convenience init(keyDecodingStrategy: KeyDecodingStrategy) { 9 | self.init() 10 | self.keyDecodingStrategy = keyDecodingStrategy 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Helpers/JSONEncoderExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (C) 2021 Twilio, Inc. 3 | // 4 | 5 | import Foundation 6 | 7 | extension JSONEncoder { 8 | convenience init(keyEncodingStrategy: KeyEncodingStrategy) { 9 | self.init() 10 | self.keyEncodingStrategy = keyEncodingStrategy 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Managers/API/APIErrorResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (C) 2021 Twilio, Inc. 3 | // 4 | 5 | import Foundation 6 | 7 | struct APIErrorResponse: Decodable { 8 | struct Error: Decodable { 9 | let message: String 10 | let explanation: String 11 | } 12 | 13 | let error: Error 14 | } 15 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Twilio/Room/RoomMessage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (C) 2021 Twilio, Inc. 3 | // 4 | 5 | import Foundation 6 | 7 | struct RoomMessage: Codable { 8 | enum MessageType: String, Codable { 9 | case mute 10 | } 11 | 12 | let messageType: MessageType 13 | let toParticipantIdentity: String 14 | } 15 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Twilio/Room/TrackName.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (C) 2020 Twilio, Inc. 3 | // 4 | 5 | import Foundation 6 | 7 | /// Names for video and audio tracks 8 | enum TrackName { 9 | static let camera = "camera" 10 | static let presentation = "video-composer-presentation" 11 | static let mic = "microphone" 12 | } 13 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Views/Style/TipStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (C) 2021 Twilio, Inc. 3 | // 4 | 5 | import SwiftUI 6 | 7 | struct TipStyle: ViewModifier { 8 | func body(content: Content) -> some View { 9 | content 10 | .foregroundColor(.textWeak) 11 | .font(.system(size: 15)) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Views/Stream/DisplayNameFactory.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (C) 2022 Twilio, Inc. 3 | // 4 | 5 | import Foundation 6 | 7 | class DisplayNameFactory { 8 | func makeDisplayName(identity: String, isHost: Bool, isYou: Bool = false) -> String { 9 | "\(isYou ? "You" : identity)\(isHost ? " (Host)" : "")" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Twilio/Sync/SyncObjectConnecting.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (C) 2021 Twilio, Inc. 3 | // 4 | 5 | import TwilioSyncClient 6 | 7 | protocol SyncObjectConnecting: AnyObject { 8 | var errorHandler: ((Error) -> Void)? { get set } 9 | func connect(client: TwilioSyncClient, completion: @escaping (Error?) -> Void) 10 | func disconnect() 11 | } 12 | -------------------------------------------------------------------------------- /apps/web/.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question 3 | about: Ask a question about the video app 4 | title: '' 5 | labels: question 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Question** 11 | Ask a question about the video app here. 12 | 13 | **Additional context** 14 | Add any other context or screenshots about the your question here. The more detail, the better. 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | node_modules/ 3 | .twilio-functions 4 | .twiliodeployinfo 5 | assets/ 6 | .DS_Store 7 | 8 | # iOS 9 | xcuserdata/ 10 | *.hmap 11 | *.ipa 12 | *.dSYM.zip 13 | *.dSYM 14 | .build/ 15 | apps/ios/LiveVideo/fastlane/Preview.html 16 | apps/ios/LiveVideo/fastlane/screenshots/**/*.png 17 | apps/ios/LiveVideo/fastlane/test_output 18 | apps/ios/LiveVideo/fastlane/report.xml 19 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo.xcodeproj/xcshareddata/IDETemplateMacros.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | FILEHEADER 6 | 7 | // Copyright (C) ___YEAR___ Twilio, Inc. 8 | // 9 | 10 | 11 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Managers/API/APIRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (C) 2021 Twilio, Inc. 3 | // 4 | 5 | import Foundation 6 | 7 | protocol APIRequest { 8 | associatedtype Parameters: Encodable 9 | associatedtype Response: Decodable 10 | var path: String { get } 11 | var parameters: Parameters { get } 12 | var responseType: Response.Type { get } 13 | } 14 | -------------------------------------------------------------------------------- /apps/web/src/hooks/useChatContext/useChatContext.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | import { ChatContext } from '../../components/ChatProvider'; 3 | 4 | export default function useChatContext() { 5 | const context = useContext(ChatContext); 6 | if (!context) { 7 | throw new Error('useChatContext must be used within a ChatProvider'); 8 | } 9 | return context; 10 | } 11 | -------------------------------------------------------------------------------- /apps/web/src/hooks/useSyncContext/useSyncContext.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | import { SyncContext } from '../../components/SyncProvider'; 3 | 4 | export default function useSyncContext() { 5 | const context = useContext(SyncContext); 6 | if (!context) { 7 | throw new Error('useSyncContext must be used within a SyncProvider'); 8 | } 9 | return context; 10 | } 11 | -------------------------------------------------------------------------------- /apps/web/src/hooks/useVideoContext/useVideoContext.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | import { VideoContext } from '../../components/VideoProvider'; 3 | 4 | export default function useVideoContext() { 5 | const context = useContext(VideoContext); 6 | if (!context) { 7 | throw new Error('useVideoContext must be used within a VideoProvider'); 8 | } 9 | return context; 10 | } 11 | -------------------------------------------------------------------------------- /apps/web/src/hooks/usePlayerContext/usePlayerContext.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | import { PlayerContext } from '../../components/PlayerProvider'; 3 | 4 | export default function usePlayerContext() { 5 | const context = useContext(PlayerContext); 6 | if (!context) { 7 | throw new Error('usePlayerContext must be used within a PlayerProvider'); 8 | } 9 | return context; 10 | } 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question 3 | about: Ask a question about the interactive live video app 4 | title: '' 5 | labels: question 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Question** 11 | Ask a question about the interactive live video app here. 12 | 13 | **Additional context** 14 | Add any other context or screenshots about the your question here. The more detail, the better. 15 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (C) 2021 Twilio, Inc. 3 | // 4 | 5 | import SwiftUI 6 | 7 | struct ContentView: View { 8 | var body: some View { 9 | Text("Hello, world!") 10 | .padding() 11 | } 12 | } 13 | 14 | struct ContentView_Previews: PreviewProvider { 15 | static var previews: some View { 16 | ContentView() 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /apps/web/src/setupProxy.js: -------------------------------------------------------------------------------- 1 | const { createProxyMiddleware } = require('http-proxy-middleware'); 2 | const path = require('path'); 3 | 4 | require('dotenv').config({ path: path.join(__dirname, '../../../.env') }); 5 | 6 | module.exports = function(app) { 7 | app.post( 8 | '*', 9 | createProxyMiddleware({ 10 | target: process.env.WEB_PROXY_URL, 11 | changeOrigin: true, 12 | }) 13 | ); 14 | }; 15 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Twilio/Stream/StreamConfig.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (C) 2021 Twilio, Inc. 3 | // 4 | 5 | import Foundation 6 | 7 | struct StreamConfig { 8 | enum Role: String, Identifiable { 9 | case host 10 | case speaker 11 | case viewer 12 | 13 | var id: String { rawValue } 14 | } 15 | 16 | let streamName: String 17 | let userIdentity: String 18 | var role: Role 19 | } 20 | -------------------------------------------------------------------------------- /apps/web/src/hooks/useChatContext/useChatContext.test.tsx: -------------------------------------------------------------------------------- 1 | import useChatContext from './useChatContext'; 2 | import { renderHook } from '@testing-library/react-hooks'; 3 | 4 | describe('the useChatContext hook', () => { 5 | it('should throw an error if used outside of the ChatProvider', () => { 6 | const { result } = renderHook(useChatContext); 7 | expect(result.error.message).toBe('useChatContext must be used within a ChatProvider'); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /apps/web/src/components/ErrorDialog/enhanceMessage.ts: -------------------------------------------------------------------------------- 1 | // This function is used to provide error messages to the user that are 2 | // different than the error messages provided by the SDK. 3 | export default function enhanceMessage(message = '', code?: number) { 4 | switch (code) { 5 | case 20101: // Invalid token error 6 | return message + '. Please make sure you are using the correct credentials.'; 7 | default: 8 | return message; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Views/Style/ErrorAlert.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (C) 2021 Twilio, Inc. 3 | // 4 | 5 | import SwiftUI 6 | 7 | extension Alert { 8 | init(error: Error, action: (() -> Void)? = nil) { 9 | self.init( 10 | title: Text("Error"), 11 | message: Text(error.localizedDescription), 12 | dismissButton: .default(Text("OK")) { 13 | action?() 14 | } 15 | ) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /apps/web/src/hooks/useVideoContext/useVideoContext.test.ts: -------------------------------------------------------------------------------- 1 | import useVideoContext from './useVideoContext'; 2 | import { renderHook } from '@testing-library/react-hooks'; 3 | 4 | describe('the useVideoContext hook', () => { 5 | it('should throw an error if used outside of the VideoProvider', () => { 6 | const { result } = renderHook(useVideoContext); 7 | expect(result.error.message).toBe('useVideoContext must be used within a VideoProvider'); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /serverless/constants.js: -------------------------------------------------------------------------------- 1 | const syncServicePrefix = 'Live Video '; 2 | 3 | module.exports = { 4 | MEDIA_EXTENSION: 'video-composer-v2', 5 | SERVICE_NAME: 'twilio-live-interactive-video', 6 | API_KEY_NAME: 'Twilio Live Interactive Video API Key', 7 | TWILIO_CONVERSATIONS_SERVICE_NAME: 'Twilio Live Interactive Video Conversations Service', 8 | SYNC_SERVICE_NAME_PREFIX: syncServicePrefix, 9 | BACKEND_STORAGE_SYNC_SERVICE_NAME: syncServicePrefix + 'Backend Storage', 10 | }; 11 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.867", 9 | "green" : "0.384", 10 | "red" : "0.007" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | setup: true 3 | orbs: 4 | path-filtering: circleci/path-filtering@0.0.2 5 | workflows: 6 | always-run: 7 | jobs: 8 | - path-filtering/filter: 9 | name: check-updated-files 10 | mapping: | 11 | functions/.* backend-updated true 12 | apps/web/.* web-app-updated true 13 | apps/ios/.* ios-app-updated true 14 | base-revision: main 15 | config-path: .circleci/continue_config.yml 16 | -------------------------------------------------------------------------------- /apps/web/src/__mocks__/@twilio/conversations.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | 3 | const mockConversation: any = new EventEmitter(); 4 | mockConversation.getMessages = jest.fn(() => Promise.resolve({ items: ['mockMessage'] })); 5 | 6 | const mockClient = { 7 | getConversationByUniqueName: jest.fn(() => Promise.resolve(mockConversation)), 8 | }; 9 | 10 | const Client = { 11 | create: jest.fn(() => Promise.resolve(mockClient)), 12 | }; 13 | 14 | export { Client, mockClient, mockConversation }; 15 | -------------------------------------------------------------------------------- /apps/web/src/hooks/useSnackbar/useSnackbar.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | import { OptionsObject, useSnackbar } from 'notistack'; 3 | import { SnackbarMessage } from '../../components/Snackbar/SnackbarProvider'; 4 | 5 | export function useEnqueueSnackbar() { 6 | const { enqueueSnackbar } = useSnackbar(); 7 | // This is so useSnackbar has the right type signature 8 | 9 | return useCallback((message: SnackbarMessage, options?: OptionsObject) => enqueueSnackbar(message, options), [ 10 | enqueueSnackbar, 11 | ]); 12 | } 13 | -------------------------------------------------------------------------------- /apps/web/src/components/ReconnectingNotification/ReconnectingNotification.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Snackbar from '../Snackbar/Snackbar'; 3 | import useRoomState from '../../hooks/useRoomState/useRoomState'; 4 | 5 | export default function ReconnectingNotification() { 6 | const roomState = useRoomState(); 7 | 8 | return ( 9 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Managers/API/VerifyPasscodeRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (C) 2021 Twilio, Inc. 3 | // 4 | 5 | import Foundation 6 | 7 | struct VerifyPasscodeRequest: APIRequest { 8 | struct Parameters: Encodable { 9 | // The backend verifies the passcode in the request header so no parameters needed 10 | } 11 | 12 | struct Response: Decodable { 13 | let verified: Bool 14 | } 15 | 16 | let path = "verify-passcode" 17 | let parameters = Parameters() 18 | let responseType = Response.self 19 | } 20 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Views/Participants/ParticipantsHeader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (C) 2021 Twilio, Inc. 3 | // 4 | 5 | import SwiftUI 6 | 7 | struct ParticipantsHeader: View { 8 | let title: String 9 | 10 | var body: some View { 11 | Text(title) 12 | .foregroundColor(.black) 13 | } 14 | } 15 | 16 | struct ParticipantsHeader_Previews: PreviewProvider { 17 | static var previews: some View { 18 | ParticipantsHeader(title: "Speakers") 19 | .previewLayout(.sizeThatFits) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /apps/web/src/hooks/useQuery/useQuery.test.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from './useQuery'; 2 | import { renderHook } from '@testing-library/react-hooks'; 3 | 4 | jest.mock('react-router', () => ({ 5 | useLocation: () => ({ search: '?test=123&foo=bar' }), 6 | })); 7 | 8 | describe('the useQuery hook', () => { 9 | it('should return a URLSearchParams object from the useLocation hook', () => { 10 | const { result } = renderHook(useQuery); 11 | expect(result.current.get('test')).toBe('123'); 12 | expect(result.current.get('foo')).toBe('bar'); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /apps/web/src/components/LocalAudioLevelIndicator/LocalAudioLevelIndicator.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { LocalAudioTrack } from 'twilio-video'; 3 | import useVideoContext from '../../hooks/useVideoContext/useVideoContext'; 4 | import AudioLevelIndicator from '../AudioLevelIndicator/AudioLevelIndicator'; 5 | 6 | export default function LocalAudioLevelIndicator() { 7 | const { localTracks } = useVideoContext(); 8 | const audioTrack = localTracks.find(track => track.kind === 'audio') as LocalAudioTrack; 9 | 10 | return ; 11 | } 12 | -------------------------------------------------------------------------------- /apps/web/src/hooks/useHeight/useHeight.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | export default function useHeight() { 4 | const [height, setHeight] = useState(window.innerHeight * (window.visualViewport?.scale || 1)); 5 | 6 | useEffect(() => { 7 | const onResize = () => { 8 | setHeight(window.innerHeight * (window.visualViewport?.scale || 1)); 9 | }; 10 | 11 | window.addEventListener('resize', onResize); 12 | return () => { 13 | window.removeEventListener('resize', onResize); 14 | }; 15 | }); 16 | 17 | return height + 'px'; 18 | } 19 | -------------------------------------------------------------------------------- /serverless/functions/verify-passcode.js: -------------------------------------------------------------------------------- 1 | /* global Twilio Runtime */ 2 | 'use strict'; 3 | 4 | // verifies that auth.js does not throw error for passcode: 5 | 6 | module.exports.handler = async (context, event, callback) => { 7 | const authHandler = require(Runtime.getAssets()['/auth.js'].path); 8 | authHandler(context, event, callback); 9 | 10 | let response = new Twilio.Response(); 11 | response.appendHeader('Content-Type', 'application/json'); 12 | 13 | response.setStatusCode(200); 14 | 15 | response.setBody({ verified: true }); 16 | 17 | return callback(null, response); 18 | }; 19 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Managers/API/DeleteStreamRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (C) 2021 Twilio, Inc. 3 | // 4 | 5 | import Foundation 6 | 7 | struct DeleteStreamRequest: APIRequest { 8 | struct Parameters: Encodable { 9 | let streamName: String 10 | } 11 | 12 | struct Response: Decodable { 13 | let deleted: Bool 14 | } 15 | 16 | let path = "delete-stream" 17 | let parameters: Parameters 18 | let responseType = Response.self 19 | 20 | init(streamName: String) { 21 | parameters = Parameters(streamName: streamName) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /apps/web/src/components/VideoProvider/useHandleTrackPublicationFailed/useHandleTrackPublicationFailed.ts: -------------------------------------------------------------------------------- 1 | import { Room } from 'twilio-video'; 2 | import { useEffect } from 'react'; 3 | 4 | import { Callback } from '../../../types'; 5 | 6 | export default function useHandleTrackPublicationFailed(room: Room | null, onError: Callback) { 7 | useEffect(() => { 8 | if (room) { 9 | room.localParticipant.on('trackPublicationFailed', onError); 10 | return () => { 11 | room.localParticipant.off('trackPublicationFailed', onError); 12 | }; 13 | } 14 | }, [room, onError]); 15 | } 16 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Views/Style/PrimaryButtonStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (C) 2021 Twilio, Inc. 3 | // 4 | 5 | import SwiftUI 6 | 7 | struct PrimaryButtonStyle: ButtonStyle { 8 | var isEnabled = true 9 | 10 | func makeBody(configuration: Configuration) -> some View { 11 | configuration.label 12 | .padding(16) 13 | .frame(maxWidth: .infinity) 14 | .background(isEnabled ? Color.backgroundPrimary : Color.backgroundPrimaryWeak) 15 | .foregroundColor(.white) 16 | .cornerRadius(4.0) 17 | .font(.body.bold()) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /apps/web/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # IntelliJ IDEA project config 4 | .idea 5 | 6 | # dependencies 7 | node_modules 8 | .pnp 9 | .pnp.js 10 | 11 | # testing 12 | coverage 13 | 14 | # production 15 | build 16 | 17 | # misc 18 | .DS_Store 19 | .env.local 20 | .env.development.local 21 | .env.test.local 22 | .env.production.local 23 | 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | .env 29 | .vscode 30 | 31 | test-reports 32 | junit.xml 33 | serviceAccountKey.json 34 | 35 | public/virtualbackground/ 36 | public/player/ 37 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Launch/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (C) 2021 Twilio, Inc. 3 | // 4 | 5 | import UIKit 6 | 7 | class AppDelegate: NSObject, UIApplicationDelegate { 8 | func application( 9 | _ application: UIApplication, 10 | configurationForConnecting connectingSceneSession: UISceneSession, 11 | options: UIScene.ConnectionOptions 12 | ) -> UISceneConfiguration { 13 | let sceneConfig = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role) 14 | sceneConfig.delegateClass = SceneDelegate.self 15 | return sceneConfig 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Managers/API/RemoveSpeakerRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (C) 2021 Twilio, Inc. 3 | // 4 | 5 | struct RemoveSpeakerRequest: APIRequest { 6 | struct Parameters: Encodable { 7 | let roomName: String 8 | let userIdentity: String 9 | } 10 | 11 | struct Response: Decodable { 12 | let removed: Bool 13 | } 14 | 15 | let path = "remove-speaker" 16 | let parameters: Parameters 17 | let responseType = Response.self 18 | 19 | init(roomName: String, userIdentity: String) { 20 | parameters = Parameters(roomName: roomName, userIdentity: userIdentity) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /apps/web/src/constants.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_VIDEO_CONSTRAINTS: MediaStreamConstraints['video'] = { 2 | width: 1280, 3 | height: 720, 4 | frameRate: 24, 5 | }; 6 | 7 | // These are used to store the selected media devices in localStorage 8 | export const SELECTED_AUDIO_INPUT_KEY = 'TwilioVideoApp-selectedAudioInput'; 9 | export const SELECTED_AUDIO_OUTPUT_KEY = 'TwilioVideoApp-selectedAudioOutput'; 10 | export const SELECTED_VIDEO_INPUT_KEY = 'TwilioVideoApp-selectedVideoInput'; 11 | 12 | // This is used to store the current background settings in localStorage 13 | export const SELECTED_BACKGROUND_SETTINGS_KEY = 'TwilioVideoApp-selectedBackgroundSettings'; 14 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Views/Settings/TitleValueView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (C) 2022 Twilio, Inc. 3 | // 4 | 5 | import SwiftUI 6 | 7 | struct TitleValueView: View { 8 | let title: String 9 | let value: String 10 | 11 | var body: some View { 12 | HStack { 13 | Text(title) 14 | Spacer() 15 | Text(value) 16 | .foregroundColor(.secondary) 17 | } 18 | } 19 | } 20 | 21 | struct TitleValueView_Previews: PreviewProvider { 22 | static var previews: some View { 23 | Form { 24 | TitleValueView(title: "Title", value: "Value") 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Launch/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (C) 2021 Twilio, Inc. 3 | // 4 | 5 | import TwilioVideo 6 | import UIKit 7 | 8 | class SceneDelegate: NSObject, UIWindowSceneDelegate { 9 | func windowScene( 10 | _ windowScene: UIWindowScene, 11 | didUpdate previousCoordinateSpace: UICoordinateSpace, 12 | interfaceOrientation previousInterfaceOrientation: UIInterfaceOrientation, 13 | traitCollection previousTraitCollection: UITraitCollection 14 | ) { 15 | // So the camera handles orientation changes correctly 16 | UserInterfaceTracker.sceneInterfaceOrientationDidChange(windowScene) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Managers/API/SendSpeakerInviteRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (C) 2021 Twilio, Inc. 3 | // 4 | 5 | import Foundation 6 | 7 | struct SendSpeakerInviteRequest: APIRequest { 8 | struct Parameters: Encodable { 9 | let userIdentity: String 10 | let roomSid: String 11 | } 12 | 13 | struct Response: Decodable { 14 | let sent: Bool 15 | } 16 | 17 | let path = "send-speaker-invite" 18 | let parameters: Parameters 19 | let responseType = Response.self 20 | 21 | init(userIdentity: String, roomSID: String) { 22 | parameters = Parameters(userIdentity: userIdentity, roomSid: roomSID) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /apps/web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "noEmit": true, 16 | "jsx": "react", 17 | "noImplicitAny": true, 18 | "noImplicitThis": true, 19 | "strictNullChecks": true, 20 | "noFallthroughCasesInSwitch": true 21 | }, 22 | "include": ["src"] 23 | } 24 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Views/Stream/SwiftUIPlayerView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (C) 2021 Twilio, Inc. 3 | // 4 | 5 | import SwiftUI 6 | import TwilioLivePlayer 7 | 8 | struct SwiftUIPlayerView: UIViewRepresentable { 9 | @Binding var player: Player? 10 | 11 | func makeUIView(context: Context) -> PlayerView { 12 | PlayerView() 13 | } 14 | 15 | func updateUIView(_ uiView: PlayerView, context: Context) { 16 | guard player?.playerView !== uiView else { 17 | /// If `playerView` is already set don't set it again. This prevents the view from flickering sometimes. 18 | return 19 | } 20 | 21 | player?.playerView = uiView 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Managers/API/ViewerConnectedToPlayerRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (C) 2021 Twilio, Inc. 3 | // 4 | 5 | import Foundation 6 | 7 | struct ViewerConnectedToPlayerRequest: APIRequest { 8 | struct Parameters: Encodable { 9 | let userIdentity: String 10 | let streamName: String 11 | } 12 | 13 | struct Response: Decodable { 14 | let success: Bool 15 | } 16 | 17 | let path = "viewer-connected-to-player" 18 | let parameters: Parameters 19 | let responseType = Response.self 20 | 21 | init(userIdentity: String, streamName: String) { 22 | parameters = Parameters(userIdentity: userIdentity, streamName: streamName) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Views/Style/FormTextFieldStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (C) 2021 Twilio, Inc. 3 | // 4 | 5 | import SwiftUI 6 | 7 | struct FormTextFieldStyle: TextFieldStyle { 8 | private let cornerRadius: CGFloat = 3 9 | 10 | func _body(configuration: TextField<_Label>) -> some View { 11 | configuration 12 | .padding(10) 13 | .background( 14 | ZStack { 15 | RoundedRectangle(cornerRadius: cornerRadius) 16 | .fill(Color.white) 17 | RoundedRectangle(cornerRadius: cornerRadius) 18 | .strokeBorder(Color.border) 19 | } 20 | ) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Twilio/Chat/ChatMessage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (C) 2022 Twilio, Inc. 3 | // 4 | 5 | import TwilioConversationsClient 6 | 7 | struct ChatMessage: Identifiable { 8 | let id: String 9 | let author: String 10 | let date: Date 11 | let body: String 12 | 13 | init?(message: TCHMessage) { 14 | guard 15 | let sid = message.sid, 16 | let date = message.dateCreatedAsDate, 17 | let author = message.author, 18 | let body = message.body 19 | else { 20 | return nil 21 | } 22 | 23 | id = sid 24 | self.author = author 25 | self.date = date 26 | self.body = body 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /apps/web/src/components/BackgroundSelectionDialog/BackgroundSelectionHeader/BackgroundSelectionHeader.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import CloseIcon from '../../../icons/CloseIcon'; 4 | import BackgroundSelectionHeader from './BackgroundSelectionHeader'; 5 | 6 | const mockCloseDialog = jest.fn(); 7 | 8 | describe('The Background Selection Header Component', () => { 9 | it('should close the selection dialog when "X" is clicked', () => { 10 | const wrapper = shallow(); 11 | wrapper 12 | .find(CloseIcon) 13 | .parent() 14 | .simulate('click'); 15 | expect(mockCloseDialog).toHaveBeenCalled(); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /apps/web/src/components/ParticipantTracks/__snapshots__/ParticipantTracks.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`the ParticipantTracks component should render an array of publications 1`] = ` 4 | 5 | 16 | 27 | 28 | `; 29 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 2 | AUTH_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 3 | 4 | 5 | # WEB_PROXY_URL is used for local development with the 'npm start' command. 6 | # To get a URL, first deploy the app with 'npm run serverless:deploy'. 7 | # Then take note of the URL that is printed to the console. Do not include 8 | # the passcode. 9 | # WEB_PROXY_URL=https://twilio-live-interactive-video-1234-5678-dev.twil.io 10 | 11 | # Uncomment to run in stage environment. 12 | # Must also use correct credentials for stage. 13 | # REACT_APP_TWILIO_ENVIRONMENT=stage 14 | # TWILIO_REGION=stage 15 | 16 | # Un-comment the following line to disable the Twilio Conversations functionality in the app. 17 | # DISABLE_CHAT=true 18 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Managers/API/RaiseHandRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (C) 2021 Twilio, Inc. 3 | // 4 | 5 | import Foundation 6 | 7 | struct RaiseHandRequest: APIRequest { 8 | struct Parameters: Encodable { 9 | let userIdentity: String 10 | let streamName: String 11 | let handRaised: Bool 12 | } 13 | 14 | struct Response: Decodable { 15 | let sent: Bool 16 | } 17 | 18 | let path = "raise-hand" 19 | let parameters: Parameters 20 | let responseType = Response.self 21 | 22 | init(userIdentity: String, streamName: String, handRaised: Bool) { 23 | parameters = Parameters(userIdentity: userIdentity, streamName: streamName, handRaised: handRaised) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: feature 6 | assignees: '' 7 | 8 | --- 9 | 10 | **What is the feature that you would like to see in the app? Please describe.** 11 | A clear and concise description of the proposed feature. 12 | 13 | **Is your feature request related to a problem? Please describe.** 14 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /apps/web/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ['/src'], 3 | transform: { 4 | '^.+\\.tsx?$': 'ts-jest', 5 | }, 6 | testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$', 7 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], 8 | snapshotSerializers: ['enzyme-to-json/serializer'], 9 | setupFiles: ['/src/setupTests.ts'], 10 | reporters: ['default', 'jest-junit'], 11 | 12 | // We don't need to test the static JSX in the icons folder, so let's exclude it from our test coverage report 13 | coveragePathIgnorePatterns: ['node_modules', 'src/icons'], 14 | moduleNameMapper: { 15 | '.+\\.(css|styl|less|sass|scss|png|jpg|ttf|woff|woff2)$': '/src/__mocks__/fileMock.ts', 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /apps/web/.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: feature 6 | assignees: '' 7 | 8 | --- 9 | 10 | **What is the feature that you would like to see in the app? Please describe.** 11 | A clear and concise description of the proposed feature. 12 | 13 | **Is your feature request related to a problem? Please describe.** 14 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /apps/web/src/icons/VideoOnIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function VideoOnIcon() { 4 | return ( 5 | 6 | 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /apps/web/src/icons/ChatIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function ChatIcon() { 4 | return ( 5 | 6 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Views/Stream/PresentationLayout/PresentationVideoView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (C) 2022 Twilio, Inc. 3 | // 4 | 5 | import SwiftUI 6 | import TwilioVideo 7 | 8 | struct PresentationVideoView: View { 9 | @Binding var videoTrack: VideoTrack? 10 | 11 | var body: some View { 12 | ZStack { 13 | Color.black 14 | SwiftUIVideoView(videoTrack: $videoTrack, shouldMirror: .constant(false), fill: false) 15 | } 16 | .cornerRadius(4) 17 | } 18 | } 19 | 20 | struct PresentationVideoView_Previews: PreviewProvider { 21 | static var previews: some View { 22 | PresentationVideoView(videoTrack: .constant(nil)) 23 | .frame(height: 400) 24 | .previewLayout(.sizeThatFits) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /apps/web/src/components/IntroContainer/TwilioLogo.tsx: -------------------------------------------------------------------------------- 1 | import React, { SVGProps } from 'react'; 2 | 3 | export default function TwilioLogo(props: SVGProps) { 4 | return ( 5 | 6 | 7 | 8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /serverless/functions/remove-speaker.js: -------------------------------------------------------------------------------- 1 | exports.handler = async function (context, event, callback) { 2 | let response = new Twilio.Response(); 3 | response.appendHeader('Content-Type', 'application/json'); 4 | 5 | const client = context.getTwilioClient(); 6 | 7 | try { 8 | await client.video.rooms(event.room_name).participants(event.user_identity).update({ status: 'disconnected' }) 9 | } catch (e) { 10 | console.error(e); 11 | response.setStatusCode(500); 12 | response.setBody({ 13 | error: { 14 | message: 'error removing speaker', 15 | explanation: e.message, 16 | } 17 | }); 18 | return callback(null, response); 19 | } 20 | 21 | response.setBody({ 22 | removed: true, 23 | }); 24 | 25 | callback(null, response); 26 | }; 27 | -------------------------------------------------------------------------------- /apps/web/src/components/PreJoinScreens/LoadingScreen/LoadingScreen.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { CircularProgress, Grid, Typography } from '@material-ui/core'; 3 | import { appStateTypes } from '../../../state/appState/appReducer'; 4 | 5 | export function LoadingScreen({ state }: { state: appStateTypes }) { 6 | return ( 7 | 8 |
9 | 10 |
11 |
12 | 13 | {state.participantType === 'host' ? 'Going Live' : 'Joining Live Event'} 14 | 15 |
16 |
17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /apps/web/src/hooks/useLocalAudioToggle/useLocalAudioToggle.tsx: -------------------------------------------------------------------------------- 1 | import { LocalAudioTrack } from 'twilio-video'; 2 | import { useCallback } from 'react'; 3 | import useIsTrackEnabled from '../useIsTrackEnabled/useIsTrackEnabled'; 4 | import useVideoContext from '../useVideoContext/useVideoContext'; 5 | 6 | export default function useLocalAudioToggle() { 7 | const { localTracks } = useVideoContext(); 8 | const audioTrack = localTracks.find(track => track.kind === 'audio') as LocalAudioTrack; 9 | const isEnabled = useIsTrackEnabled(audioTrack); 10 | 11 | const toggleAudioEnabled = useCallback(() => { 12 | if (audioTrack) { 13 | audioTrack.isEnabled ? audioTrack.disable() : audioTrack.enable(); 14 | } 15 | }, [audioTrack]); 16 | 17 | return [isEnabled, toggleAudioEnabled] as const; 18 | } 19 | -------------------------------------------------------------------------------- /apps/web/src/hooks/usePlayerState/usePlayerState.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { Player as TwilioPlayer } from '@twilio/live-player-sdk'; 3 | import usePlayerContext from '../usePlayerContext/usePlayerContext'; 4 | 5 | export function usePlayerState() { 6 | const { player } = usePlayerContext(); 7 | const [state, setState] = useState(); 8 | 9 | useEffect(() => { 10 | if (player) { 11 | const setPlayerState = () => setState(player.state as TwilioPlayer.State); 12 | setPlayerState(); 13 | 14 | player.on(TwilioPlayer.Event.StateChanged, setPlayerState); 15 | 16 | return () => { 17 | player.off(TwilioPlayer.Event.StateChanged, setPlayerState); 18 | }; 19 | } 20 | }, [player]); 21 | 22 | return state; 23 | } 24 | -------------------------------------------------------------------------------- /apps/web/src/icons/InfoIconOutlined.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function InfoIconOutlined() { 4 | return ( 5 | 6 | 7 | 8 | 12 | 13 | 14 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /apps/web/public/.well-known/assetlinks.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "relation": [ 4 | "delegate_permission/common.handle_all_urls" 5 | ], 6 | "target": { 7 | "namespace": "android_app", 8 | "package_name": "com.twilio.video.app.internal", 9 | "sha256_cert_fingerprints": [ 10 | "48:F3:DA:B7:2B:1B:06:2B:35:7E:29:B8:63:2E:03:B9:77:BB:E9:F2:86:BF:50:FB:1C:83:7A:12:38:BD:C7:3F" 11 | ] 12 | } 13 | }, 14 | { 15 | "relation": [ 16 | "delegate_permission/common.handle_all_urls" 17 | ], 18 | "target": { 19 | "namespace": "android_app", 20 | "package_name": "com.twilio.video.app", 21 | "sha256_cert_fingerprints": [ 22 | "48:F3:DA:B7:2B:1B:06:2B:35:7E:29:B8:63:2E:03:B9:77:BB:E9:F2:86:BF:50:FB:1C:83:7A:12:38:BD:C7:3F" 23 | ] 24 | } 25 | } 26 | ] -------------------------------------------------------------------------------- /apps/web/src/icons/SuccessIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export function SuccessIcon() { 4 | return ( 5 | 6 | 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /apps/web/src/icons/StopRecordingIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function StopRecordingIcon() { 4 | return ( 5 | 6 | 7 | 8 | 12 | 13 | 14 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideoTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideoUITests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Views/StreamConfigFlow/StreamConfigFlowModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (C) 2021 Twilio, Inc. 3 | // 4 | 5 | import Foundation 6 | 7 | class StreamConfigFlowModel: ObservableObject { 8 | struct Parameters { 9 | var userIdentity: String? 10 | var streamName: String? 11 | var role: StreamConfig.Role? 12 | } 13 | 14 | @Published var isShowing = false 15 | var parameters = Parameters() 16 | 17 | var config: StreamConfig? { 18 | guard 19 | let userIdentity = parameters.userIdentity, 20 | let streamName = parameters.streamName, 21 | let role = parameters.role 22 | else { 23 | return nil 24 | } 25 | 26 | return StreamConfig(streamName: streamName, userIdentity: userIdentity, role: role) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /apps/web/src/icons/MicIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function MicIcon() { 4 | return ( 5 | 6 | 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /apps/web/src/icons/InfoIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function InfoIcon() { 4 | return ( 5 | 6 | 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /apps/web/src/components/PrivateRoute/PrivateRoute.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Location } from 'history'; 3 | import { Redirect, Route, RouteProps } from 'react-router-dom'; 4 | import { useAppState } from '../../state'; 5 | 6 | export default function PrivateRoute({ children, ...rest }: RouteProps) { 7 | const { isAuthReady, user } = useAppState(); 8 | 9 | if (!user && !isAuthReady) { 10 | return null; 11 | } 12 | 13 | function getRedirectTo(location: Location) { 14 | const redirectTo = { 15 | pathname: '/login', 16 | search: '', 17 | }; 18 | 19 | if (location.pathname !== '/') { 20 | redirectTo.search = '?redirect=' + location.pathname; 21 | } 22 | 23 | return redirectTo; 24 | } 25 | 26 | return (user ? children : )} />; 27 | } 28 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Views/Stream/SpeakerVideoViewModelFactory.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (C) 2022 Twilio, Inc. 3 | // 4 | 5 | import Foundation 6 | 7 | class SpeakerVideoViewModelFactory { 8 | private var speakersMap: SyncUsersMap! 9 | 10 | func configure(speakersMap: SyncUsersMap) { 11 | self.speakersMap = speakersMap 12 | } 13 | 14 | func makeSpeaker(participant: LocalParticipantManager) -> SpeakerVideoViewModel { 15 | let isHost = speakersMap.host?.identity == participant.identity 16 | return SpeakerVideoViewModel(participant: participant, isHost: isHost) 17 | } 18 | 19 | func makeSpeaker(participant: RemoteParticipantManager) -> SpeakerVideoViewModel { 20 | let isHost = speakersMap.host?.identity == participant.identity 21 | return SpeakerVideoViewModel(participant: participant, isHost: isHost) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /apps/web/src/hooks/useParticipantNetworkQualityLevel/useParticipantNetworkQualityLevel.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { Participant } from 'twilio-video'; 3 | 4 | export default function useParticipantNetworkQualityLevel(participant: Participant) { 5 | const [networkQualityLevel, setNetworkQualityLevel] = useState(participant.networkQualityLevel); 6 | 7 | useEffect(() => { 8 | const handleNewtorkQualityLevelChange = (newNetworkQualityLevel: number) => 9 | setNetworkQualityLevel(newNetworkQualityLevel); 10 | 11 | setNetworkQualityLevel(participant.networkQualityLevel); 12 | participant.on('networkQualityLevelChanged', handleNewtorkQualityLevelChange); 13 | return () => { 14 | participant.off('networkQualityLevelChanged', handleNewtorkQualityLevelChange); 15 | }; 16 | }, [participant]); 17 | 18 | return networkQualityLevel; 19 | } 20 | -------------------------------------------------------------------------------- /apps/web/src/hooks/useParticipantIsReconnecting/useParticipantIsReconnecting.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { Participant } from 'twilio-video'; 3 | 4 | export default function useParticipantIsReconnecting(participant: Participant) { 5 | const [isReconnecting, setIsReconnecting] = useState(false); 6 | 7 | useEffect(() => { 8 | const handleReconnecting = () => setIsReconnecting(true); 9 | const handleReconnected = () => setIsReconnecting(false); 10 | 11 | handleReconnected(); // Reset state when there is a new participant 12 | 13 | participant.on('reconnecting', handleReconnecting); 14 | participant.on('reconnected', handleReconnected); 15 | return () => { 16 | participant.off('reconnecting', handleReconnecting); 17 | participant.off('reconnected', handleReconnected); 18 | }; 19 | }, [participant]); 20 | 21 | return isReconnecting; 22 | } 23 | -------------------------------------------------------------------------------- /apps/web/src/hooks/useTrack/useTrack.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { LocalTrackPublication, RemoteTrackPublication } from 'twilio-video'; 3 | 4 | export default function useTrack(publication: LocalTrackPublication | RemoteTrackPublication | undefined) { 5 | const [track, setTrack] = useState(publication && publication.track); 6 | 7 | useEffect(() => { 8 | // Reset the track when the 'publication' variable changes. 9 | setTrack(publication && publication.track); 10 | 11 | if (publication) { 12 | const removeTrack = () => setTrack(null); 13 | 14 | publication.on('subscribed', setTrack); 15 | publication.on('unsubscribed', removeTrack); 16 | return () => { 17 | publication.off('subscribed', setTrack); 18 | publication.off('unsubscribed', removeTrack); 19 | }; 20 | } 21 | }, [publication]); 22 | 23 | return track; 24 | } 25 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Assets.xcassets/Colors/ShadowLow.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "0.100", 8 | "blue" : "45", 9 | "green" : "28", 10 | "red" : "18" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "1.000", 27 | "green" : "1.000", 28 | "red" : "1.000" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Assets.xcassets/Colors/Border.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.667", 9 | "green" : "0.569", 10 | "red" : "0.533" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "1.000", 27 | "green" : "1.000", 28 | "red" : "1.000" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Assets.xcassets/Colors/Background.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.965", 9 | "green" : "0.957", 10 | "red" : "0.957" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "1.000", 27 | "green" : "1.000", 28 | "red" : "1.000" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Assets.xcassets/Colors/IconPurple.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.741", 9 | "green" : "0.090", 10 | "red" : "0.345" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "1.000", 27 | "green" : "1.000", 28 | "red" : "1.000" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Assets.xcassets/Colors/TextWeak.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.522", 9 | "green" : "0.420", 10 | "red" : "0.376" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "1.000", 27 | "green" : "1.000", 28 | "red" : "1.000" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /apps/web/src/hooks/useVideoTrackDimensions/useVideoTrackDimensions.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import { LocalVideoTrack, RemoteVideoTrack } from 'twilio-video'; 3 | 4 | type TrackType = LocalVideoTrack | RemoteVideoTrack; 5 | 6 | export default function useVideoTrackDimensions(track?: TrackType) { 7 | const [dimensions, setDimensions] = useState(track?.dimensions); 8 | 9 | useEffect(() => { 10 | setDimensions(track?.dimensions); 11 | 12 | if (track) { 13 | const handleDimensionsChanged = (newTrack: TrackType) => 14 | setDimensions({ 15 | width: newTrack.dimensions.width, 16 | height: newTrack.dimensions.height, 17 | }); 18 | track.on('dimensionsChanged', handleDimensionsChanged); 19 | return () => { 20 | track.off('dimensionsChanged', handleDimensionsChanged); 21 | }; 22 | } 23 | }, [track]); 24 | 25 | return dimensions; 26 | } 27 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Assets.xcassets/Colors/BackgroundBrand.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.537", 9 | "green" : "0.078", 10 | "red" : "0.000" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "1.000", 27 | "green" : "1.000", 28 | "red" : "1.000" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Assets.xcassets/Colors/BackgroundPrimary.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.867", 9 | "green" : "0.384", 10 | "red" : "0.007" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "1.000", 27 | "green" : "1.000", 28 | "red" : "1.000" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Assets.xcassets/Colors/BackgroundStrong.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.918", 9 | "green" : "0.890", 10 | "red" : "0.882" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "1.000", 27 | "green" : "1.000", 28 | "red" : "1.000" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Assets.xcassets/Colors/BorderSuccessWeak.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.463", 9 | "green" : "0.835", 10 | "red" : "0.211" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "1.000", 27 | "green" : "1.000", 28 | "red" : "1.000" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /apps/web/.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Environment (please complete the following information):** 27 | - OS: [e.g. Ubuntu 18.04] 28 | - Browser: [e.g. Chrome 80, Firefox 72] 29 | - App Version: [e.g. 0.1.0] 30 | - SDK Version: [e.g 2.1.0] 31 | - Node.js version: [e.g. 12.14.1] 32 | 33 | **Additional context** 34 | Add any other context about the problem here. 35 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Assets.xcassets/Colors/BackgroundDestructive.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.122", 9 | "green" : "0.122", 10 | "red" : "0.839" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "1.000", 27 | "green" : "1.000", 28 | "red" : "1.000" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Assets.xcassets/Colors/BackgroundHighlight.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "1.000", 9 | "green" : "0.549", 10 | "red" : "0.000" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "1.000", 27 | "green" : "1.000", 28 | "red" : "1.000" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Assets.xcassets/Colors/BackgroundLiveBadge.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.765", 9 | "green" : "0.965", 10 | "red" : "0.635" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "1.000", 27 | "green" : "1.000", 28 | "red" : "1.000" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Assets.xcassets/Colors/BackgroundPrimaryWeak.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "1.000", 9 | "green" : "0.804", 10 | "red" : "0.600" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "1.000", 27 | "green" : "1.000", 28 | "red" : "1.000" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Assets.xcassets/Colors/BackgroundStronger.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.659", 9 | "green" : "0.561", 10 | "red" : "0.529" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "1.000", 27 | "green" : "1.000", 28 | "red" : "1.000" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Assets.xcassets/Colors/BackgroundStrongest.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.443", 9 | "green" : "0.337", 10 | "red" : "0.294" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "1.000", 27 | "green" : "1.000", 28 | "red" : "1.000" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Assets.xcassets/Colors/BorderWeaker.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "display-p3", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.914", 9 | "green" : "0.890", 10 | "red" : "0.883" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "1.000", 27 | "green" : "1.000", 28 | "red" : "1.000" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /apps/web/src/hooks/useIsRecording/useIsRecording.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import useVideoContext from '../useVideoContext/useVideoContext'; 3 | 4 | export default function useIsRecording() { 5 | const { room } = useVideoContext(); 6 | const [isRecording, setIsRecording] = useState(Boolean(room?.isRecording)); 7 | 8 | useEffect(() => { 9 | if (room) { 10 | setIsRecording(room.isRecording); 11 | 12 | const handleRecordingStarted = () => setIsRecording(true); 13 | const handleRecordingStopped = () => setIsRecording(false); 14 | 15 | room.on('recordingStarted', handleRecordingStarted); 16 | room.on('recordingStopped', handleRecordingStopped); 17 | 18 | return () => { 19 | room.off('recordingStarted', handleRecordingStarted); 20 | room.off('recordingStopped', handleRecordingStopped); 21 | }; 22 | } 23 | }, [room]); 24 | 25 | return isRecording; 26 | } 27 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Assets.xcassets/Colors/BackgroundBrandStronger.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.227", 9 | "green" : "0.012", 10 | "red" : "0.024" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "1.000", 27 | "green" : "1.000", 28 | "red" : "1.000" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Assets.xcassets/Colors/BackgroundPrimaryWeaker.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "1.000", 9 | "green" : "0.894", 10 | "red" : "0.800" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "1.000", 27 | "green" : "1.000", 28 | "red" : "1.000" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Assets.xcassets/Colors/BackgroundPrimaryWeakest.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "1.000", 9 | "green" : "0.957", 10 | "red" : "0.921" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "1.000", 27 | "green" : "1.000", 28 | "red" : "1.000" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Assets.xcassets/Colors/BackgroundSuccess.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "display-p3", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.365", 9 | "green" : "0.679", 10 | "red" : "0.318" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "1.000", 27 | "green" : "1.000", 28 | "red" : "1.000" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Managers/HostControls/HostControlsManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (C) 2022 Twilio, Inc. 3 | // 4 | 5 | import Foundation 6 | 7 | class HostControlsManager: ObservableObject { 8 | private var roomManager: RoomManager! 9 | private var api: API! 10 | 11 | func configure(roomManager: RoomManager, api: API) { 12 | self.roomManager = roomManager 13 | self.api = api 14 | } 15 | 16 | func muteSpeaker(identity: String) { 17 | let message = RoomMessage(messageType: .mute, toParticipantIdentity: identity) 18 | roomManager.localParticipant.sendMessage(message) 19 | } 20 | 21 | func removeSpeaker(identity: String) { 22 | guard let roomName = roomManager.roomName else { 23 | return 24 | } 25 | 26 | let request = RemoveSpeakerRequest(roomName: roomName, userIdentity: identity) 27 | api.request(request) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /apps/web/src/icons/SpeakerMenuIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function SpeakerMenuIcon() { 4 | return ( 5 | 6 | 7 | 13 | 14 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Views/Stream/LiveBadge.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (C) 2021 Twilio, Inc. 3 | // 4 | 5 | import SwiftUI 6 | 7 | struct LiveBadge: View { 8 | var body: some View { 9 | HStack(spacing: 4) { 10 | Image(systemName: "dot.radiowaves.left.and.right") 11 | .resizable() 12 | .aspectRatio(contentMode: .fit) 13 | .frame(height: 12) 14 | Text("Live") 15 | .fixedSize() 16 | .font(.system(size: 13)) 17 | } 18 | .padding(.vertical, 4) 19 | .padding(.horizontal, 8) 20 | .foregroundColor(.black) 21 | .background(Color.backgroundLiveBadge) 22 | .cornerRadius(2) 23 | } 24 | } 25 | 26 | struct LiveBadge_Previews: PreviewProvider { 27 | static var previews: some View { 28 | LiveBadge() 29 | .previewLayout(.sizeThatFits) 30 | .padding() 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Views/Stream/StreamToolbar.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (C) 2021 Twilio, Inc. 3 | // 4 | 5 | import SwiftUI 6 | 7 | struct StreamToolbar: View where Content: View { 8 | private let content: () -> Content 9 | 10 | init(@ViewBuilder content: @escaping () -> Content) { 11 | self.content = content 12 | } 13 | 14 | var body: some View { 15 | HStack(spacing: 0) { 16 | Spacer() 17 | content() 18 | Spacer() 19 | } 20 | .background(Color.background) 21 | } 22 | } 23 | 24 | struct StreamToolbar_Previews: PreviewProvider { 25 | static var previews: some View { 26 | StreamToolbar { 27 | StreamToolbarButton(image: Image(systemName: "arrow.left"), role: .destructive) 28 | StreamToolbarButton(image: Image(systemName: "mic.slash.fill")) 29 | } 30 | .previewLayout(.sizeThatFits) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /apps/web/src/icons/StartRecordingIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function StartRecordingIcon() { 4 | return ( 5 | 6 | 7 | 8 | 12 | 13 | 14 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /apps/web/src/icons/RightArrowIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function RightArrowIcon() { 4 | return ( 5 | 6 | 10 | 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /apps/web/src/hooks/useRoomState/useRoomState.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import useVideoContext from '../useVideoContext/useVideoContext'; 3 | 4 | type RoomStateType = 'disconnected' | 'connected' | 'reconnecting'; 5 | 6 | export default function useRoomState() { 7 | const { room } = useVideoContext(); 8 | const [state, setState] = useState('disconnected'); 9 | 10 | useEffect(() => { 11 | if (room) { 12 | const setRoomState = () => setState(room.state as RoomStateType); 13 | setRoomState(); 14 | room 15 | .on('disconnected', setRoomState) 16 | .on('reconnected', setRoomState) 17 | .on('reconnecting', setRoomState); 18 | return () => { 19 | room 20 | .off('disconnected', setRoomState) 21 | .off('reconnected', setRoomState) 22 | .off('reconnecting', setRoomState); 23 | }; 24 | } 25 | }, [room]); 26 | 27 | return state; 28 | } 29 | -------------------------------------------------------------------------------- /apps/web/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | import { configure } from 'enzyme'; 2 | import Adapter from 'enzyme-adapter-react-16'; 3 | import 'isomorphic-fetch'; 4 | 5 | configure({ adapter: new Adapter() }); 6 | 7 | // Mocks the Fullscreen API. This is needed for ToggleFullScreenButton.test.tsx. 8 | Object.defineProperty(document, 'fullscreenEnabled', { value: true, writable: true }); 9 | 10 | class LocalStorage { 11 | store = {} as { [key: string]: string }; 12 | 13 | getItem(key: string) { 14 | return this.store[key]; 15 | } 16 | 17 | setItem(key: string, value: string) { 18 | this.store[key] = value; 19 | } 20 | 21 | clear() { 22 | this.store = {} as { [key: string]: string }; 23 | } 24 | } 25 | 26 | Object.defineProperty(window, 'localStorage', { value: new LocalStorage() }); 27 | 28 | // This is to suppress the "Platform browser has already been set." warnings from the video-processors library 29 | jest.mock('@twilio/video-processors', () => ({})); 30 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Views/Stream/OffscreenSpeakersView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (C) 2021 Twilio, Inc. 3 | // 4 | 5 | import SwiftUI 6 | 7 | struct OffscreenSpeakersView: View { 8 | @EnvironmentObject var viewModel: SpeakerGridViewModel 9 | 10 | var body: some View { 11 | ZStack { 12 | Color.backgroundBrand 13 | .cornerRadius(4) 14 | Text("+ \(viewModel.offscreenSpeakers.count) more") 15 | .font(.system(size: 13, weight: .bold)) 16 | .foregroundColor(.white) 17 | .padding(17) 18 | } 19 | } 20 | } 21 | 22 | struct OffscreenSpeakersView_Previews: PreviewProvider { 23 | static var previews: some View { 24 | OffscreenSpeakersView() 25 | .environmentObject(SpeakerGridViewModel.stub(offscreenSpeakerCount: 10)) 26 | .previewLayout(.sizeThatFits) 27 | .fixedSize(horizontal: false, vertical: true) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /apps/web/src/hooks/useIsTrackEnabled/useIsTrackEnabled.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import { LocalAudioTrack, LocalVideoTrack, RemoteAudioTrack, RemoteVideoTrack } from 'twilio-video'; 3 | 4 | type TrackType = LocalAudioTrack | LocalVideoTrack | RemoteAudioTrack | RemoteVideoTrack | undefined; 5 | 6 | export default function useIsTrackEnabled(track: TrackType) { 7 | const [isEnabled, setIsEnabled] = useState(track ? track.isEnabled : false); 8 | 9 | useEffect(() => { 10 | setIsEnabled(track ? track.isEnabled : false); 11 | 12 | if (track) { 13 | const setEnabled = () => setIsEnabled(true); 14 | const setDisabled = () => setIsEnabled(false); 15 | track.on('enabled', setEnabled); 16 | track.on('disabled', setDisabled); 17 | return () => { 18 | track.off('enabled', setEnabled); 19 | track.off('disabled', setDisabled); 20 | }; 21 | } 22 | }, [track]); 23 | 24 | return isEnabled; 25 | } 26 | -------------------------------------------------------------------------------- /apps/web/src/components/ChatWindow/ChatWindowHeader/ChatWindowHeader.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | 4 | import CloseIcon from '../../../icons/CloseIcon'; 5 | import ChatWindowHeader from './ChatWindowHeader'; 6 | import useChatContext from '../../../hooks/useChatContext/useChatContext'; 7 | 8 | jest.mock('../../../hooks/useChatContext/useChatContext'); 9 | 10 | const mockUseChatContext = useChatContext as jest.Mock; 11 | 12 | const mockToggleChatWindow = jest.fn(); 13 | mockUseChatContext.mockImplementation(() => ({ setIsChatWindowOpen: mockToggleChatWindow })); 14 | 15 | describe('the CloseChatWindowHeader component', () => { 16 | it('should close the chat window when "X" is clicked on', () => { 17 | const wrapper = shallow(); 18 | wrapper 19 | .find(CloseIcon) 20 | .parent() 21 | .simulate('click'); 22 | expect(mockToggleChatWindow).toHaveBeenCalledWith(false); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /apps/web/src/components/ChatWindow/MessageList/MessageInfo/MessageInfo.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { makeStyles, createStyles } from '@material-ui/core/styles'; 3 | 4 | const useStyles = makeStyles(() => 5 | createStyles({ 6 | messageInfoContainer: { 7 | display: 'flex', 8 | justifyContent: 'space-between', 9 | alignItems: 'center', 10 | padding: '1.425em 0 0.083em', 11 | fontSize: '12px', 12 | color: '#606B85', 13 | }, 14 | }) 15 | ); 16 | 17 | interface MessageInfoProps { 18 | author: string; 19 | dateCreated: string; 20 | isLocalParticipant: boolean; 21 | } 22 | 23 | export default function MessageInfo({ author, dateCreated, isLocalParticipant }: MessageInfoProps) { 24 | const classes = useStyles(); 25 | 26 | return ( 27 |
28 |
{isLocalParticipant ? `${author} (You)` : author}
29 |
{dateCreated}
30 |
31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /apps/web/src/icons/BackArrowIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function BackArrowIcon() { 4 | return ( 5 | 6 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Helpers/LiveVideoError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (C) 2021 Twilio, Inc. 3 | // 4 | 5 | import Foundation 6 | 7 | enum LiveVideoError: Error { 8 | case backendError(message: String) 9 | case passcodeIncorrect 10 | case speakerMovedToViewersByHost 11 | case streamEndedByHost 12 | case syncClientConnectionFatalError 13 | case syncTokenExpired 14 | } 15 | 16 | extension LiveVideoError: LocalizedError { 17 | var errorDescription: String? { 18 | switch self { 19 | case let .backendError(message): return message 20 | case .passcodeIncorrect: return "Passcode incorrect." 21 | case .speakerMovedToViewersByHost: return "Speaker moved to viewers by host." 22 | case .streamEndedByHost: return "Event ended by host." 23 | case .syncClientConnectionFatalError: return "Sync client connection fatal error." 24 | case .syncTokenExpired: return "Sync token expired." 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /apps/web/src/icons/CloseIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function CloseIcon() { 4 | return ( 5 | 6 | 12 | 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Managers/AppSettings/TwilioEnvironment.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (C) 2022 Twilio, Inc. 3 | // 4 | 5 | import Foundation 6 | 7 | enum TwilioEnvironment: String, CaseIterable, Identifiable { 8 | case prod 9 | case stage 10 | case dev 11 | 12 | var id: Self { 13 | self 14 | } 15 | 16 | var domain: String { 17 | switch self { 18 | case .prod: return "twil.io" 19 | case .stage: return "stage.twil.io" 20 | case .dev: return "dev.twil.io" 21 | } 22 | } 23 | 24 | var videoEnvironment: String { 25 | switch self { 26 | case .prod: return "Production" 27 | case .stage: return "Staging" 28 | case .dev: return "Development" 29 | } 30 | } 31 | 32 | var region: String? { 33 | switch self { 34 | case .prod: return nil 35 | case .stage: return "stage-us1" 36 | case .dev: return "dev-us1" 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /apps/web/src/hooks/useDevices/useDevices.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import { getDeviceInfo } from '../../utils'; 3 | 4 | // This returns the type of the value that is returned by a promise resolution 5 | type ThenArg = T extends PromiseLike ? U : never; 6 | 7 | export default function useDevices() { 8 | const [deviceInfo, setDeviceInfo] = useState>>({ 9 | audioInputDevices: [], 10 | videoInputDevices: [], 11 | audioOutputDevices: [], 12 | hasAudioInputDevices: false, 13 | hasVideoInputDevices: false, 14 | }); 15 | 16 | useEffect(() => { 17 | const getDevices = () => getDeviceInfo().then(devices => setDeviceInfo(devices)); 18 | navigator.mediaDevices.addEventListener('devicechange', getDevices); 19 | getDevices(); 20 | 21 | return () => { 22 | navigator.mediaDevices.removeEventListener('devicechange', getDevices); 23 | }; 24 | }, []); 25 | 26 | return deviceInfo; 27 | } 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Environment (please complete the following information):** 27 | - Platform: [e.g. iOS, Web] Please include all OS or Browser details if applicable. 28 | - App Version: [e.g. 0.1.0] 29 | - Twilio Video SDK Version: [e.g 2.1.0] 30 | - Twilio Live Player SDK Version: [e.g 2.1.0] 31 | - Node.js version: [e.g. 12.14.1] 32 | 33 | **Additional context** 34 | Add any other context about the problem here. 35 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Views/Style/ProgressHUD.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (C) 2021 Twilio, Inc. 3 | // 4 | 5 | import SwiftUI 6 | 7 | struct ProgressHUD: View { 8 | var title: String? 9 | 10 | var body: some View { 11 | ZStack { 12 | Color.backgroundBrandStronger 13 | .opacity(0.8) 14 | VStack(spacing: 40) { 15 | ProgressView() 16 | .progressViewStyle(CircularProgressViewStyle(tint: .green)) 17 | .scaleEffect(2) 18 | 19 | if let title = title { 20 | Text(title) 21 | .foregroundColor(.white) 22 | .font(.system(size: 24, weight: .bold)) 23 | } 24 | } 25 | } 26 | .ignoresSafeArea() 27 | } 28 | } 29 | 30 | struct ProgressHUD_Previews: PreviewProvider { 31 | static var previews: some View { 32 | ProgressHUD(title: "Title") 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /apps/web/src/components/UnsupportedBrowserWarning/UnsupportedBrowserWarning.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import UnsupportedBrowserWarning from './UnsupportedBrowserWarning'; 3 | import Video from 'twilio-video'; 4 | import { shallow } from 'enzyme'; 5 | 6 | describe('the UnsupportedBrowserWarning component', () => { 7 | it('should render correctly when isSupported is false', () => { 8 | // @ts-ignore 9 | Video.isSupported = false; 10 | const wrapper = shallow( 11 | 12 | Is supported 13 | 14 | ); 15 | expect(wrapper).toMatchSnapshot(); 16 | }); 17 | 18 | it('should render children when isSupported is true', () => { 19 | // @ts-ignore 20 | Video.isSupported = true; 21 | const wrapper = shallow( 22 | 23 | Is supported 24 | 25 | ); 26 | expect(wrapper.text()).toBe('Is supported'); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | **Contributing to Twilio** 4 | 5 | > All third party contributors acknowledge that any contributions they provide will be made under the same open source license that the open source project is provided under. 6 | 7 | - [ ] I acknowledge that all my contributions will be made under the project's license. 8 | 9 | ## Pull Request Details 10 | 11 | ### JIRA link(s): 12 | 13 | - [VIDEO-0000](https://issues.corp.twilio.com/browse/VIDEO-0000) 14 | 15 | ### Description 16 | 17 | A description of what this PR does. 18 | 19 | ## Burndown 20 | 21 | ### Before review 22 | * [ ] Added unit tests if necessary 23 | * [ ] Updated affected documentation 24 | * [ ] Verified locally with all effected platforms 25 | * [ ] Manually sanity tested running locally 26 | * [ ] Included screenshot as PR comment (if needed) 27 | * [ ] Ready for review 28 | 29 | ### Before merge 30 | * [ ] Got one or more +1s 31 | * [ ] Re-tested if necessary -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Managers/Auth/PasscodeComponents.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (C) 2021 Twilio, Inc. 3 | // 4 | 5 | struct PasscodeComponents { 6 | let passcode: String 7 | let appID: String 8 | let serverlessID: String 9 | private let shortPasscodeLength = 6 10 | private let appIDLength = 4 11 | private let serverlessIDMinLength = 4 12 | 13 | init(string: String) throws { 14 | guard string.count >= shortPasscodeLength + appIDLength + serverlessIDMinLength else { 15 | throw LiveVideoError.passcodeIncorrect 16 | } 17 | 18 | passcode = string 19 | 20 | let appIDStartIndex = string.index(string.startIndex, offsetBy: shortPasscodeLength) 21 | let appIDEndIndex = string.index(appIDStartIndex, offsetBy: appIDLength - 1) 22 | appID = String(string[appIDStartIndex...appIDEndIndex]) 23 | 24 | let serverlessIDStartIndex = string.index(after: appIDEndIndex) 25 | serverlessID = String(string[serverlessIDStartIndex...]) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /apps/web/src/hooks/usePublicationIsTrackEnabled/usePublicationIsTrackEnabled.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import { LocalTrackPublication, RemoteTrackPublication } from 'twilio-video'; 3 | 4 | type PublicationType = LocalTrackPublication | RemoteTrackPublication; 5 | 6 | export default function usePublicationIsTrackEnabled(publication?: PublicationType) { 7 | const [isEnabled, setIsEnabled] = useState(publication ? publication.isTrackEnabled : false); 8 | 9 | useEffect(() => { 10 | setIsEnabled(publication ? publication.isTrackEnabled : false); 11 | 12 | if (publication) { 13 | const setEnabled = () => setIsEnabled(true); 14 | const setDisabled = () => setIsEnabled(false); 15 | publication.on('trackEnabled', setEnabled); 16 | publication.on('trackDisabled', setDisabled); 17 | return () => { 18 | publication.off('trackEnabled', setEnabled); 19 | publication.off('trackDisabled', setDisabled); 20 | }; 21 | } 22 | }, [publication]); 23 | 24 | return isEnabled; 25 | } 26 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Views/Chat/ChatBubbleView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (C) 2022 Twilio, Inc. 3 | // 4 | 5 | import SwiftUI 6 | 7 | struct ChatBubbleView: View { 8 | let messageBody: String 9 | let isAuthorYou: Bool 10 | 11 | var body: some View { 12 | HStack { 13 | Text(messageBody) 14 | .padding(10) 15 | .background(isAuthorYou ? Color.backgroundPrimaryWeaker : Color.backgroundStrong) 16 | .cornerRadius(20) 17 | Spacer() 18 | } 19 | } 20 | } 21 | 22 | struct ChatBubbleView_Previews: PreviewProvider { 23 | static var previews: some View { 24 | Group { 25 | ChatBubbleView(messageBody: "Message that I sent", isAuthorYou: true) 26 | ChatBubbleView(messageBody: "Message that someone else sent", isAuthorYou: false) 27 | ChatBubbleView(messageBody: "Message that is really long and does not fit on a single line", isAuthorYou: true) 28 | } 29 | .previewLayout(.sizeThatFits) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /apps/web/src/state/useActiveSinkId/useActiveSinkId.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from 'react'; 2 | import { SELECTED_AUDIO_OUTPUT_KEY } from '../../constants'; 3 | import useDevices from '../../hooks/useDevices/useDevices'; 4 | 5 | export default function useActiveSinkId() { 6 | const { audioOutputDevices } = useDevices(); 7 | const [activeSinkId, _setActiveSinkId] = useState('default'); 8 | 9 | const setActiveSinkId = useCallback((sinkId: string) => { 10 | window.localStorage.setItem(SELECTED_AUDIO_OUTPUT_KEY, sinkId); 11 | _setActiveSinkId(sinkId); 12 | }, []); 13 | 14 | useEffect(() => { 15 | const selectedSinkId = window.localStorage.getItem(SELECTED_AUDIO_OUTPUT_KEY); 16 | const hasSelectedAudioOutputDevice = audioOutputDevices.some( 17 | device => selectedSinkId && device.deviceId === selectedSinkId 18 | ); 19 | if (hasSelectedAudioOutputDevice) { 20 | _setActiveSinkId(selectedSinkId!); 21 | } 22 | }, [audioOutputDevices]); 23 | 24 | return [activeSinkId, setActiveSinkId] as const; 25 | } 26 | -------------------------------------------------------------------------------- /apps/web/.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | **Contributing to Twilio** 4 | 5 | > All third party contributors acknowledge that any contributions they provide will be made under the same open source license that the open source project is provided under. 6 | 7 | - [ ] I acknowledge that all my contributions will be made under the project's license. 8 | 9 | ## Pull Request Details 10 | 11 | ### JIRA link(s): 12 | 13 | - [AHOYAPPS-0000](https://issues.corp.twilio.com/browse/AHOYAPPS-0000) 14 | 15 | ### Description 16 | 17 | A description of what this PR does. 18 | 19 | ## Burndown 20 | 21 | ### Before review 22 | * [ ] Updated CHANGELOG.md if necessary 23 | * [ ] Added unit tests if necessary 24 | * [ ] Updated affected documentation 25 | * [ ] Verified locally with `npm test` 26 | * [ ] Manually sanity tested running locally 27 | * [ ] Included screenshot as PR comment (if needed) 28 | * [ ] Ready for review 29 | 30 | ### Before merge 31 | * [ ] Got one or more +1s 32 | * [ ] Re-tested if necessary -------------------------------------------------------------------------------- /apps/web/src/components/AudioTrack/AudioTrack.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | import { AudioTrack as IAudioTrack } from 'twilio-video'; 3 | import { useAppState } from '../../state'; 4 | 5 | interface AudioTrackProps { 6 | track: IAudioTrack; 7 | } 8 | 9 | export default function AudioTrack({ track }: AudioTrackProps) { 10 | const { activeSinkId } = useAppState(); 11 | const audioEl = useRef(); 12 | 13 | useEffect(() => { 14 | audioEl.current = track.attach(); 15 | audioEl.current.setAttribute('data-cy-audio-track-name', track.name); 16 | document.body.appendChild(audioEl.current); 17 | return () => 18 | track.detach().forEach(el => { 19 | el.remove(); 20 | 21 | // This addresses a Chrome issue where the number of WebMediaPlayers is limited. 22 | // See: https://github.com/twilio/twilio-video.js/issues/1528 23 | el.srcObject = null; 24 | }); 25 | }, [track]); 26 | 27 | useEffect(() => { 28 | audioEl.current?.setSinkId?.(activeSinkId); 29 | }, [activeSinkId]); 30 | 31 | return null; 32 | } 33 | -------------------------------------------------------------------------------- /apps/web/src/components/NetworkQualityLevel/NetworkQualityLevel.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import renderer from 'react-test-renderer'; 3 | import NetworkQualityLevel from './NetworkQualityLevel'; 4 | 5 | describe('the NetworkQualityLevel component', () => { 6 | it('should render correctly for level 5', () => { 7 | const mockParticipant = { networkQualityLevel: 5, on: () => {} } as any; 8 | const tree = renderer.create().toJSON(); 9 | expect(tree).toMatchSnapshot(); 10 | }); 11 | 12 | it('should render correctly for level 3', () => { 13 | const mockParticipant = { networkQualityLevel: 3, on: () => {} } as any; 14 | const tree = renderer.create().toJSON(); 15 | expect(tree).toMatchSnapshot(); 16 | }); 17 | 18 | it('should render correctly for level 0', () => { 19 | const mockParticipant = { networkQualityLevel: 0, on: () => {} } as any; 20 | const tree = renderer.create().toJSON(); 21 | expect(tree).toMatchSnapshot(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /apps/web/src/__mocks__/twilio-video.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | 3 | class MockRoom extends EventEmitter { 4 | state = 'connected'; 5 | disconnect = jest.fn(); 6 | localParticipant = { 7 | publishTrack: jest.fn(), 8 | videoTracks: [{ setPriority: jest.fn() }], 9 | }; 10 | } 11 | 12 | const mockRoom = new MockRoom(); 13 | 14 | class MockTrack extends EventEmitter { 15 | kind = ''; 16 | stop = jest.fn(); 17 | mediaStreamTrack = { getSettings: () => ({ deviceId: 'mockDeviceId' }) }; 18 | 19 | constructor(kind: string) { 20 | super(); 21 | this.kind = kind; 22 | } 23 | } 24 | 25 | const twilioVideo = { 26 | connect: jest.fn(() => Promise.resolve(mockRoom)), 27 | createLocalTracks: jest.fn( 28 | // Here we use setTimeout so we can control when this function resolves with jest.runAllTimers() 29 | () => new Promise(resolve => setTimeout(() => resolve([new MockTrack('video'), new MockTrack('audio')]))) 30 | ), 31 | createLocalVideoTrack: jest.fn(() => new Promise(resolve => setTimeout(() => resolve(new MockTrack('video'))))), 32 | }; 33 | 34 | export { mockRoom }; 35 | export default twilioVideo; 36 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Views/Chat/ChatView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (C) 2022 Twilio, Inc. 3 | // 4 | 5 | import SwiftUI 6 | 7 | struct ChatView: View { 8 | @Environment(\.presentationMode) var presentationMode 9 | 10 | var body: some View { 11 | NavigationView { 12 | VStack(spacing: 0) { 13 | ChatMessageList() 14 | Divider() 15 | ChatInputBar() 16 | } 17 | .navigationTitle("Chat") 18 | .navigationBarTitleDisplayMode(.inline) 19 | .toolbar { 20 | ToolbarItem(placement: .confirmationAction) { 21 | Button("Done") { 22 | presentationMode.wrappedValue.dismiss() 23 | } 24 | } 25 | } 26 | } 27 | } 28 | } 29 | 30 | struct ChatView_Previews: PreviewProvider { 31 | static var previews: some View { 32 | ChatView() 33 | .environmentObject(ChatManager.stub(messages: [.stub(author: "Bob"), .stub(author: "Alice")])) 34 | .environmentObject(AuthManager.stub(userIdentity: "Bob")) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /apps/web/src/hooks/useMediaStreamTrack/useMediaStreamTrack.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import { AudioTrack, VideoTrack } from 'twilio-video'; 3 | 4 | /* 5 | * This hook allows components to reliably use the 'mediaStreamTrack' property of 6 | * an AudioTrack or a VideoTrack. Whenever 'localTrack.restart(...)' is called, it 7 | * will replace the mediaStreamTrack property of the localTrack, but the localTrack 8 | * object will stay the same. Therefore this hook is needed in order for components 9 | * to rerender in response to the mediaStreamTrack changing. 10 | */ 11 | export default function useMediaStreamTrack(track?: AudioTrack | VideoTrack) { 12 | const [mediaStreamTrack, setMediaStreamTrack] = useState(track?.mediaStreamTrack); 13 | 14 | useEffect(() => { 15 | setMediaStreamTrack(track?.mediaStreamTrack); 16 | 17 | if (track) { 18 | const handleStarted = () => setMediaStreamTrack(track.mediaStreamTrack); 19 | track.on('started', handleStarted); 20 | return () => { 21 | track.off('started', handleStarted); 22 | }; 23 | } 24 | }, [track]); 25 | 26 | return mediaStreamTrack; 27 | } 28 | -------------------------------------------------------------------------------- /apps/web/src/components/Buttons/ToggleAudioButton/ToggleAudioButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Button from '@material-ui/core/Button'; 4 | import MicIcon from '../../../icons/MicIcon'; 5 | import MicOffIcon from '../../../icons/MicOffIcon'; 6 | 7 | import useLocalAudioToggle from '../../../hooks/useLocalAudioToggle/useLocalAudioToggle'; 8 | import useVideoContext from '../../../hooks/useVideoContext/useVideoContext'; 9 | 10 | export default function ToggleAudioButton(props: { disabled?: boolean; className?: string }) { 11 | const [isAudioEnabled, toggleAudioEnabled] = useLocalAudioToggle(); 12 | const { localTracks } = useVideoContext(); 13 | const hasAudioTrack = localTracks.some(track => track.kind === 'audio'); 14 | 15 | return ( 16 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /apps/web/src/hooks/useHeight/useHeight.test.tsx: -------------------------------------------------------------------------------- 1 | import { act, renderHook } from '@testing-library/react-hooks'; 2 | 3 | import useHeight from './useHeight'; 4 | 5 | describe('the useHeight hook', () => { 6 | it('should return window.innerHeight', () => { 7 | // @ts-ignore 8 | window.innerHeight = 100; 9 | const { result } = renderHook(useHeight); 10 | expect(result.current).toBe('100px'); 11 | 12 | act(() => { 13 | // @ts-ignore 14 | window.innerHeight = 150; 15 | window.dispatchEvent(new Event('resize')); 16 | }); 17 | 18 | expect(result.current).toBe('150px'); 19 | }); 20 | 21 | it('should take window.visualViewport.scale into account', () => { 22 | // @ts-ignore 23 | window.innerHeight = 100; 24 | 25 | // @ts-ignore 26 | window.visualViewport = { 27 | scale: 2, 28 | }; 29 | 30 | const { result } = renderHook(useHeight); 31 | expect(result.current).toBe('200px'); 32 | 33 | act(() => { 34 | // @ts-ignore 35 | window.innerHeight = 150; 36 | window.dispatchEvent(new Event('resize')); 37 | }); 38 | 39 | expect(result.current).toBe('300px'); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /apps/web/src/state/settings/settingsReducer.test.ts: -------------------------------------------------------------------------------- 1 | import { settingsReducer, initialSettings } from './settingsReducer'; 2 | 3 | describe('the settingsReducer', () => { 4 | it('should set a setting from the name/value pair provided', () => { 5 | const result = settingsReducer(initialSettings, { name: 'clientTrackSwitchOffControl', value: 'auto' }); 6 | expect(result).toEqual({ 7 | bandwidthProfileMode: 'collaboration', 8 | dominantSpeakerPriority: 'standard', 9 | maxAudioBitrate: '16000', 10 | trackSwitchOffMode: undefined, 11 | clientTrackSwitchOffControl: 'auto', 12 | contentPreferencesMode: 'auto', 13 | }); 14 | }); 15 | 16 | it('should set undefined when the value is "default"', () => { 17 | const result = settingsReducer(initialSettings, { name: 'bandwidthProfileMode', value: 'default' }); 18 | expect(result).toEqual({ 19 | bandwidthProfileMode: undefined, 20 | dominantSpeakerPriority: 'standard', 21 | maxAudioBitrate: '16000', 22 | clientTrackSwitchOffControl: 'auto', 23 | contentPreferencesMode: 'auto', 24 | trackSwitchOffMode: undefined, 25 | }); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Views/Chat/ChatHeaderView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (C) 2022 Twilio, Inc. 3 | // 4 | 5 | import SwiftUI 6 | 7 | struct ChatHeaderView: View { 8 | let author: String 9 | let isAuthorYou: Bool 10 | let date: Date 11 | 12 | var body: some View { 13 | HStack { 14 | Text("\(author)\(isAuthorYou ? " (You)" : "")") 15 | .lineLimit(1) 16 | Spacer() 17 | Text(date, style: .time) 18 | } 19 | .foregroundColor(.textWeak) 20 | } 21 | } 22 | 23 | struct ChatHeaderView_Previews: PreviewProvider { 24 | static var previews: some View { 25 | Group { 26 | ChatHeaderView(author: "Alice", isAuthorYou: true, date: Date()) 27 | .previewDisplayName("You") 28 | ChatHeaderView(author: "Alice", isAuthorYou: false, date: Date()) 29 | .previewDisplayName("Not you") 30 | ChatHeaderView(author: "A really long name that does not fit on one line", isAuthorYou: false, date: Date()) 31 | .previewDisplayName("Long name") 32 | } 33 | .previewLayout(.sizeThatFits) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /apps/web/src/icons/ScreenShareIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function ScreenShareIcon() { 4 | return ( 5 | 6 | 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "twilio-live-interactive-video", 3 | "version": "1.1.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "postinstall": "npm install --prefix apps/web", 8 | "test": "true", 9 | "preserverless:deploy": "npm run build --prefix apps/web", 10 | "serverless:deploy": "node serverless/scripts/deploy.js", 11 | "serverless:list": "node serverless/scripts/list.js", 12 | "serverless:remove": "node serverless/scripts/remove.js", 13 | "develop:web": "npm run start --prefix apps/web" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/twilio/twilio-live-interactive-video.git" 18 | }, 19 | "author": "", 20 | "license": "ISC", 21 | "dependencies": { 22 | "@twilio-labs/serverless-api": "^5.4.3", 23 | "axios": "^0.21.4", 24 | "cli-ux": "^5.6.3", 25 | "commander": "^8.2.0", 26 | "dotenv": "^10.0.0", 27 | "nanoid": "^3.1.28", 28 | "twilio": "^3.71.1" 29 | }, 30 | "bugs": { 31 | "url": "https://github.com/twilio/twilio-live-interactive-video/issues" 32 | }, 33 | "homepage": "https://github.com/twilio/twilio-live-interactive-video#readme" 34 | } 35 | -------------------------------------------------------------------------------- /apps/web/src/icons/ParticipantIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function ParticipantIcon() { 4 | return ( 5 | 6 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Views/Stream/PresentationLayout/PresentationStatusView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (C) 2022 Twilio, Inc. 3 | // 4 | 5 | import SwiftUI 6 | 7 | struct PresentationStatusView: View { 8 | let presenterDisplayName: String 9 | 10 | var body: some View { 11 | ZStack { 12 | Color.backgroundBrand 13 | Text(presenterDisplayName + " is presenting.") 14 | .foregroundColor(.white) 15 | .font(.system(size: 13, weight: .bold)) 16 | .multilineTextAlignment(.center) 17 | .padding(8) 18 | } 19 | .cornerRadius(4) 20 | .fixedSize(horizontal: false, vertical: true) 21 | } 22 | } 23 | 24 | struct PresentationStatusView_Previews: PreviewProvider { 25 | static var previews: some View { 26 | Group { 27 | PresentationStatusView(presenterDisplayName: "Alice") 28 | .previewDisplayName("Short name") 29 | PresentationStatusView(presenterDisplayName: "Someone with a long name that doesn't fit on one line") 30 | .previewDisplayName("Long name") 31 | } 32 | .previewLayout(.sizeThatFits) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /apps/web/src/components/ParticipantInfo/ParticipantConnectionIndicator/ParticipantConnectionIndicator.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import clsx from 'clsx'; 3 | import { makeStyles } from '@material-ui/core/styles'; 4 | import { Participant } from 'twilio-video'; 5 | import useParticipantIsReconnecting from '../../../hooks/useParticipantIsReconnecting/useParticipantIsReconnecting'; 6 | import { Tooltip } from '@material-ui/core'; 7 | 8 | const useStyles = makeStyles({ 9 | indicator: { 10 | width: '10px', 11 | height: '10px', 12 | borderRadius: '100%', 13 | background: '#0c0', 14 | display: 'inline-block', 15 | marginRight: '3px', 16 | }, 17 | isReconnecting: { 18 | background: '#ffb100', 19 | }, 20 | }); 21 | 22 | export default function ParticipantConnectionIndicator({ participant }: { participant: Participant }) { 23 | const isReconnecting = useParticipantIsReconnecting(participant); 24 | const classes = useStyles(); 25 | return ( 26 | 27 | 28 | 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /apps/web/src/hooks/useViewersMap/useViewersMap.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import useSyncContext from '../useSyncContext/useSyncContext'; 3 | 4 | export function useViewersMap() { 5 | const { viewersMap } = useSyncContext(); 6 | const [viewers, setViewers] = useState([]); 7 | 8 | useEffect(() => { 9 | if (viewersMap) { 10 | // Sets the list on load. Limiting to first 100 speakers 11 | viewersMap.getItems({ pageSize: 100 }).then(paginator => { 12 | setViewers(paginator.items.map(item => item.key)); 13 | }); 14 | 15 | const handleItemAdded = (args: any) => { 16 | setViewers(prevViewers => [...prevViewers, args.item.key]); 17 | }; 18 | 19 | const handleItemRemoved = (args: any) => { 20 | setViewers(prevViewers => prevViewers.filter(i => i !== args.key)); 21 | }; 22 | 23 | viewersMap.on('itemAdded', handleItemAdded); 24 | viewersMap.on('itemRemoved', handleItemRemoved); 25 | 26 | return () => { 27 | viewersMap.off('itemAdded', handleItemAdded); 28 | viewersMap.off('itemRemoved', handleItemRemoved); 29 | }; 30 | } 31 | }, [viewersMap]); 32 | 33 | return viewers; 34 | } 35 | -------------------------------------------------------------------------------- /serverless/functions/delete-stream.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.handler = async function (context, event, callback) { 4 | const authHandler = require(Runtime.getAssets()['/auth.js'].path); 5 | authHandler(context, event, callback); 6 | 7 | const common = require(Runtime.getAssets()['/common.js'].path); 8 | const { axiosClient, response } = common(context, event, callback); 9 | 10 | const client = context.getTwilioClient(); 11 | const backendStrageSyncClient = client.sync.services(context.BACKEND_STORAGE_SYNC_SERVICE_SID); 12 | 13 | const { stream_name } = event; 14 | 15 | try { 16 | // End the video room which will cause everything else to be cleaned up in the rooms webhook 17 | await client.video.rooms(stream_name).update({ status: 'completed' }); 18 | 19 | console.log('deleted: ', stream_name); 20 | } catch (e) { 21 | console.log(e); 22 | response.setStatusCode(500); 23 | response.setBody({ 24 | error: { 25 | message: 'error deleting stream', 26 | explanation: e.message, 27 | }, 28 | }); 29 | return callback(null, response); 30 | } 31 | 32 | response.setBody({ 33 | deleted: true, 34 | }); 35 | 36 | callback(null, response); 37 | }; 38 | -------------------------------------------------------------------------------- /serverless/middleware/auth.private.js: -------------------------------------------------------------------------------- 1 | /* global Twilio */ 2 | 'use strict'; 3 | 4 | module.exports = async (context, event, callback) => { 5 | const { PASSCODE, APP_EXPIRY, DOMAIN_NAME } = context; 6 | 7 | const passcode = event.request.headers.authorization; 8 | const [, appID, serverlessID] = DOMAIN_NAME.match(/-?(\d*)-(\d+)(?:-\w+)?(?:\.\w+)?\.twil\.io$/); 9 | 10 | let response = new Twilio.Response(); 11 | response.appendHeader('Content-Type', 'application/json'); 12 | 13 | if (Date.now() > APP_EXPIRY) { 14 | response.setStatusCode(401); 15 | response.setBody({ 16 | error: { 17 | message: 'passcode expired', 18 | explanation: 19 | 'The passcode used to validate application users has expired. Re-deploy the application to refresh the passcode.', 20 | }, 21 | }); 22 | return callback(null, response); 23 | } 24 | 25 | if (PASSCODE + appID + serverlessID !== passcode) { 26 | response.setStatusCode(401); 27 | response.setBody({ 28 | error: { 29 | message: `passcode incorrect`, 30 | explanation: 'The passcode used to validate application users is incorrect.', 31 | }, 32 | }); 33 | return callback(null, response); 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/fastlane/README.md: -------------------------------------------------------------------------------- 1 | fastlane documentation 2 | ================ 3 | # Installation 4 | 5 | Make sure you have the latest version of the Xcode command line tools installed: 6 | 7 | ``` 8 | xcode-select --install 9 | ``` 10 | 11 | Install _fastlane_ using 12 | ``` 13 | [sudo] gem install fastlane -NV 14 | ``` 15 | or alternatively using `brew install fastlane` 16 | 17 | # Available Actions 18 | ## iOS 19 | ### ios tests 20 | ``` 21 | fastlane ios tests 22 | ``` 23 | Tests 24 | ### ios ci_match_install 25 | ``` 26 | fastlane ios ci_match_install 27 | ``` 28 | 29 | ### ios match_install 30 | ``` 31 | fastlane ios match_install 32 | ``` 33 | Install existing match certs and profiles without updating/overwriting 34 | ### ios match_update 35 | ``` 36 | fastlane ios match_update 37 | ``` 38 | Update and overwrite match certs and profiles if needed - destructive and may require other devs to match_install 39 | 40 | ---- 41 | 42 | This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run. 43 | More information about fastlane can be found on [fastlane.tools](https://fastlane.tools). 44 | The documentation of fastlane can be found on [docs.fastlane.tools](https://docs.fastlane.tools). 45 | -------------------------------------------------------------------------------- /apps/web/src/icons/SendMessageIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function ScreenShareIcon() { 4 | return ( 5 | 6 | 12 | 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /apps/web/src/components/IntroContainer/UserMenu/UserMenu.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react'; 2 | import { makeStyles, Link } from '@material-ui/core'; 3 | import { useAppState } from '../../../state'; 4 | import useVideoContext from '../../../hooks/useVideoContext/useVideoContext'; 5 | 6 | const useStyles = makeStyles({ 7 | userContainer: { 8 | position: 'absolute', 9 | top: 0, 10 | right: 0, 11 | margin: '1em', 12 | display: 'flex', 13 | alignItems: 'center', 14 | }, 15 | userButton: { 16 | color: 'white', 17 | }, 18 | logoutLink: { 19 | color: 'white', 20 | cursor: 'pointer', 21 | padding: '10px 20px', 22 | }, 23 | }); 24 | 25 | const UserMenu: React.FC = () => { 26 | const classes = useStyles(); 27 | const { signOut } = useAppState(); 28 | const { localTracks } = useVideoContext(); 29 | 30 | const handleSignOut = useCallback(() => { 31 | localTracks.forEach(track => track.stop()); 32 | signOut?.(); 33 | }, [localTracks, signOut]); 34 | 35 | return ( 36 |
37 | 38 | Logout 39 | 40 |
41 | ); 42 | }; 43 | 44 | export default UserMenu; 45 | -------------------------------------------------------------------------------- /apps/web/src/hooks/useIsTrackSwitchedOff/useIsTrackSwitchedOff.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import { LocalVideoTrack, RemoteVideoTrack } from 'twilio-video'; 3 | 4 | type TrackType = RemoteVideoTrack | LocalVideoTrack | undefined | null; 5 | 6 | // The 'switchedOff' event is emitted when there is not enough bandwidth to support 7 | // a track. See: https://www.twilio.com/docs/video/tutorials/using-bandwidth-profile-api#understanding-track-switch-offs 8 | 9 | export default function useIsTrackSwitchedOff(track: TrackType) { 10 | const [isSwitchedOff, setIsSwitchedOff] = useState(track && track.isSwitchedOff); 11 | 12 | useEffect(() => { 13 | // Reset the value if the 'track' variable changes 14 | setIsSwitchedOff(track && track.isSwitchedOff); 15 | 16 | if (track) { 17 | const handleSwitchedOff = () => setIsSwitchedOff(true); 18 | const handleSwitchedOn = () => setIsSwitchedOff(false); 19 | track.on('switchedOff', handleSwitchedOff); 20 | track.on('switchedOn', handleSwitchedOn); 21 | return () => { 22 | track.off('switchedOff', handleSwitchedOff); 23 | track.off('switchedOn', handleSwitchedOn); 24 | }; 25 | } 26 | }, [track]); 27 | 28 | return !!isSwitchedOff; 29 | } 30 | -------------------------------------------------------------------------------- /apps/web/src/icons/VideoOffIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function VideoOffIcon() { 4 | return ( 5 | 6 | 7 | 12 | 18 | 19 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /apps/web/src/components/ViewersList/RaisedHand/RaisedHand.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import clsx from 'clsx'; 3 | import { makeStyles } from '@material-ui/core/styles'; 4 | import { Typography } from '@material-ui/core'; 5 | 6 | const useStyles = makeStyles({ 7 | container: { 8 | display: 'flex', 9 | justifyContent: 'space-between', 10 | }, 11 | invite: { 12 | fontWeight: 'bold', 13 | color: '#0263E0', 14 | cursor: 'pointer', 15 | }, 16 | hide: { 17 | visibility: 'hidden', 18 | }, 19 | }); 20 | 21 | interface RaisedHandProps { 22 | name: string; 23 | handleInvite: (handleInviteIdentity: string) => void; 24 | isHost: boolean; 25 | isLocalViewer: boolean; 26 | } 27 | 28 | export function RaisedHand({ name, handleInvite, isHost, isLocalViewer }: RaisedHandProps) { 29 | const classes = useStyles(); 30 | 31 | return ( 32 |
33 | {isLocalViewer ? `${name} (You)` : name} 👋 34 | handleInvite(name)} 38 | > 39 | Invite to speak 40 | 41 |
42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Views/Style/FormStack.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (C) 2021 Twilio, Inc. 3 | // 4 | 5 | import SwiftUI 6 | 7 | struct FormStack: View where Content: View { 8 | private let spacing: CGFloat 9 | private let content: () -> Content 10 | 11 | init(spacing: CGFloat = 30, @ViewBuilder content: @escaping () -> Content) { 12 | self.spacing = spacing 13 | self.content = content 14 | } 15 | 16 | var body: some View { 17 | ZStack { 18 | Color.background.ignoresSafeArea() 19 | ScrollView(.vertical) { 20 | VStack(alignment: .leading, spacing: spacing) { 21 | content() 22 | Spacer() 23 | } 24 | .padding(.top, 20) 25 | .padding([.horizontal, .bottom], 40) 26 | } 27 | } 28 | } 29 | } 30 | 31 | struct FormView_Previews: PreviewProvider { 32 | static var previews: some View { 33 | FormStack { 34 | TextField("Text field", text: .constant("")) 35 | .textFieldStyle(FormTextFieldStyle()) 36 | Button("Button") { 37 | 38 | } 39 | .buttonStyle(PrimaryButtonStyle()) 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /apps/web/src/icons/AvatarIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function AvatarIcon() { 4 | return ( 5 | 6 | 7 | 8 | 13 | 14 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /apps/web/src/components/MobileTopMenuBar/MobileTopMenuBar.tsx: -------------------------------------------------------------------------------- 1 | import { Grid, makeStyles, Theme, Typography } from '@material-ui/core'; 2 | import React from 'react'; 3 | import useVideoContext from '../../hooks/useVideoContext/useVideoContext'; 4 | import Menu from '../MenuBar/Menu/Menu'; 5 | 6 | const useStyles = makeStyles((theme: Theme) => ({ 7 | container: { 8 | background: 'white', 9 | paddingLeft: '1em', 10 | display: 'none', 11 | height: `${theme.mobileTopBarHeight}px`, 12 | [theme.breakpoints.down('sm')]: { 13 | display: 'flex', 14 | }, 15 | }, 16 | settingsButton: { 17 | [theme.breakpoints.down('sm')]: { 18 | height: '28px', 19 | minWidth: '28px', 20 | border: '1px solid rgb(136, 140, 142)', 21 | padding: 0, 22 | margin: '0 1em', 23 | }, 24 | }, 25 | })); 26 | 27 | export default function MobileTopMenuBar() { 28 | const classes = useStyles(); 29 | const { room } = useVideoContext(); 30 | 31 | return ( 32 | 33 | {room!.name} 34 |
35 | 36 |
37 |
38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /apps/web/src/hooks/useMainParticipant/useMainParticipant.tsx: -------------------------------------------------------------------------------- 1 | import useVideoContext from '../useVideoContext/useVideoContext'; 2 | import useDominantSpeaker from '../useDominantSpeaker/useDominantSpeaker'; 3 | import useParticipants from '../useParticipants/useParticipants'; 4 | import useScreenShareParticipant from '../useScreenShareParticipant/useScreenShareParticipant'; 5 | import useSelectedParticipant from '../../components/VideoProvider/useSelectedParticipant/useSelectedParticipant'; 6 | 7 | export default function useMainParticipant() { 8 | const [selectedParticipant] = useSelectedParticipant(); 9 | const screenShareParticipant = useScreenShareParticipant(); 10 | const dominantSpeaker = useDominantSpeaker(); 11 | const participants = useParticipants(); 12 | const { room } = useVideoContext(); 13 | const localParticipant = room?.localParticipant; 14 | const remoteScreenShareParticipant = screenShareParticipant !== localParticipant ? screenShareParticipant : null; 15 | 16 | // The participant that is returned is displayed in the main video area. Changing the order of the following 17 | // variables will change the how the main speaker is determined. 18 | return selectedParticipant || remoteScreenShareParticipant || dominantSpeaker || participants[0] || localParticipant; 19 | } 20 | -------------------------------------------------------------------------------- /apps/web/src/hooks/usePublications/usePublications.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { LocalTrackPublication, Participant, RemoteTrackPublication } from 'twilio-video'; 3 | 4 | type TrackPublication = LocalTrackPublication | RemoteTrackPublication; 5 | 6 | export default function usePublications(participant: Participant) { 7 | const [publications, setPublications] = useState([]); 8 | 9 | useEffect(() => { 10 | // Reset the publications when the 'participant' variable changes. 11 | setPublications(Array.from(participant.tracks.values()) as TrackPublication[]); 12 | 13 | const publicationAdded = (publication: TrackPublication) => 14 | setPublications(prevPublications => [...prevPublications, publication]); 15 | const publicationRemoved = (publication: TrackPublication) => 16 | setPublications(prevPublications => prevPublications.filter(p => p !== publication)); 17 | 18 | participant.on('trackPublished', publicationAdded); 19 | participant.on('trackUnpublished', publicationRemoved); 20 | return () => { 21 | participant.off('trackPublished', publicationAdded); 22 | participant.off('trackUnpublished', publicationRemoved); 23 | }; 24 | }, [participant]); 25 | 26 | return publications; 27 | } 28 | -------------------------------------------------------------------------------- /apps/web/src/icons/CreateEventIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function CreateEventIcon() { 4 | return ( 5 | 6 | 7 | 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /apps/web/src/components/VideoProvider/useRestartAudioTrackOnDeviceChange/useRestartAudioTrackOnDeviceChange.ts: -------------------------------------------------------------------------------- 1 | import { LocalAudioTrack, LocalVideoTrack } from 'twilio-video'; 2 | import { useEffect } from 'react'; 3 | 4 | /* 5 | * If a user has published an audio track from an external audio input device and 6 | * disconnects the device, the published audio track will be stopped and the user 7 | * will no longer be heard by other participants. 8 | * 9 | * To prevent this issue, this hook will re-acquire a mediaStreamTrack from the system's 10 | * default audio device when it detects that the published audio device has been disconnected. 11 | */ 12 | 13 | export default function useRestartAudioTrackOnDeviceChange(localTracks: (LocalAudioTrack | LocalVideoTrack)[]) { 14 | const audioTrack = localTracks.find(track => track.kind === 'audio'); 15 | 16 | useEffect(() => { 17 | const handleDeviceChange = () => { 18 | if (audioTrack?.mediaStreamTrack.readyState === 'ended') { 19 | audioTrack.restart({}); 20 | } 21 | }; 22 | 23 | navigator.mediaDevices.addEventListener('devicechange', handleDeviceChange); 24 | 25 | return () => { 26 | navigator.mediaDevices.removeEventListener('devicechange', handleDeviceChange); 27 | }; 28 | }, [audioTrack]); 29 | } 30 | -------------------------------------------------------------------------------- /apps/web/src/components/Snackbar/Snackbar.test.tsx: -------------------------------------------------------------------------------- 1 | import { shallow } from 'enzyme'; 2 | import React from 'react'; 3 | import { SnackbarImpl } from './Snackbar'; 4 | 5 | describe('the Snackbar component', () => { 6 | it('should render correctly with "warning" variant', () => { 7 | const wrapper = shallow( 8 | {}} /> 9 | ); 10 | expect(wrapper).toMatchSnapshot(); 11 | }); 12 | 13 | it('should render correctly with "error" variant', () => { 14 | const wrapper = shallow( 15 | {}} /> 16 | ); 17 | expect(wrapper).toMatchSnapshot(); 18 | }); 19 | 20 | it('should render correctly with "info" variant', () => { 21 | const wrapper = shallow( 22 | {}} /> 23 | ); 24 | expect(wrapper).toMatchSnapshot(); 25 | }); 26 | 27 | it('should render correctly with no handleClose function provided', () => { 28 | const wrapper = shallow(); 29 | expect(wrapper).toMatchSnapshot(); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /apps/web/src/hooks/useRaisedHandsMap/useRaisedHandsMap.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import useSyncContext from '../useSyncContext/useSyncContext'; 3 | 4 | export function useRaisedHandsMap() { 5 | const { raisedHandsMap } = useSyncContext(); 6 | const [raisedHands, setRaisedHands] = useState([]); 7 | 8 | useEffect(() => { 9 | if (raisedHandsMap) { 10 | // Sets the list on load. Limiting to first 100 viewers who are raising their hand 11 | raisedHandsMap.getItems({ pageSize: 100 }).then(paginator => { 12 | setRaisedHands(paginator.items.map(item => item.key)); 13 | }); 14 | 15 | const handleItemAdded = (args: any) => { 16 | setRaisedHands(prevRaisedHands => [...prevRaisedHands, args.item.key]); 17 | }; 18 | 19 | const handleItemRemoved = (args: any) => { 20 | setRaisedHands(prevRaisedHands => prevRaisedHands.filter(i => i !== args.key)); 21 | }; 22 | 23 | raisedHandsMap.on('itemAdded', handleItemAdded); 24 | raisedHandsMap.on('itemRemoved', handleItemRemoved); 25 | 26 | return () => { 27 | raisedHandsMap.off('itemAdded', handleItemAdded); 28 | raisedHandsMap.on('itemRemoved', handleItemRemoved); 29 | }; 30 | } 31 | }, [raisedHandsMap]); 32 | 33 | return raisedHands; 34 | } 35 | -------------------------------------------------------------------------------- /apps/web/src/components/ChatWindow/ChatWindowHeader/ChatWindowHeader.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { makeStyles, createStyles } from '@material-ui/core/styles'; 3 | import CloseIcon from '../../../icons/CloseIcon'; 4 | 5 | import useChatContext from '../../../hooks/useChatContext/useChatContext'; 6 | 7 | const useStyles = makeStyles(() => 8 | createStyles({ 9 | container: { 10 | height: '56px', 11 | background: '#F4F4F6', 12 | borderBottom: '1px solid #E4E7E9', 13 | display: 'flex', 14 | justifyContent: 'space-between', 15 | alignItems: 'center', 16 | padding: '0 1em', 17 | }, 18 | text: { 19 | fontWeight: 'bold', 20 | }, 21 | closeChatWindow: { 22 | cursor: 'pointer', 23 | display: 'flex', 24 | background: 'transparent', 25 | border: '0', 26 | padding: '0.4em', 27 | }, 28 | }) 29 | ); 30 | 31 | export default function ChatWindowHeader() { 32 | const classes = useStyles(); 33 | const { setIsChatWindowOpen } = useChatContext(); 34 | 35 | return ( 36 |
37 |
Chat
38 | 41 |
42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /apps/web/src/components/Participant/Participant.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ParticipantInfo from '../ParticipantInfo/ParticipantInfo'; 3 | import ParticipantTracks from '../ParticipantTracks/ParticipantTracks'; 4 | import { Participant as IParticipant } from 'twilio-video'; 5 | 6 | interface ParticipantProps { 7 | participant: IParticipant; 8 | videoOnly?: boolean; 9 | enableScreenShare?: boolean; 10 | onClick?: () => void; 11 | isSelected?: boolean; 12 | isLocalParticipant?: boolean; 13 | hideParticipant?: boolean; 14 | isHost?: boolean; 15 | } 16 | 17 | export default function Participant({ 18 | participant, 19 | videoOnly, 20 | enableScreenShare, 21 | onClick, 22 | isSelected, 23 | isLocalParticipant, 24 | hideParticipant, 25 | isHost, 26 | }: ParticipantProps) { 27 | return ( 28 | 36 | 42 | 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /apps/web/src/components/BackgroundSelectionDialog/BackgroundSelectionHeader/BackgroundSelectionHeader.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { makeStyles, createStyles } from '@material-ui/core/styles'; 3 | import CloseIcon from '../../../icons/CloseIcon'; 4 | 5 | const useStyles = makeStyles(() => 6 | createStyles({ 7 | container: { 8 | minHeight: '56px', 9 | background: '#F4F4F6', 10 | borderBottom: '1px solid #E4E7E9', 11 | display: 'flex', 12 | justifyContent: 'space-between', 13 | alignItems: 'center', 14 | padding: '0 1em', 15 | }, 16 | text: { 17 | fontWeight: 'bold', 18 | }, 19 | closeBackgroundSelection: { 20 | cursor: 'pointer', 21 | display: 'flex', 22 | background: 'transparent', 23 | border: '0', 24 | padding: '0.4em', 25 | }, 26 | }) 27 | ); 28 | 29 | interface BackgroundSelectionHeaderProps { 30 | onClose: () => void; 31 | } 32 | 33 | export default function BackgroundSelectionHeader({ onClose }: BackgroundSelectionHeaderProps) { 34 | const classes = useStyles(); 35 | return ( 36 |
37 |
Backgrounds
38 | 41 |
42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Views/Settings/SignInSettingsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (C) 2022 Twilio, Inc. 3 | // 4 | 5 | import SwiftUI 6 | 7 | struct SignInSettingsView: View { 8 | @Binding var environment: TwilioEnvironment 9 | @Binding var isPresented: Bool 10 | 11 | var body: some View { 12 | NavigationView { 13 | Form { 14 | Section(footer: Text("Environment is used by Twilio employees for internal testing only.")) { 15 | Picker("Environment", selection: $environment) { 16 | ForEach(TwilioEnvironment.allCases) { 17 | Text($0.rawValue.capitalized) 18 | } 19 | } 20 | } 21 | } 22 | .navigationTitle("Settings") 23 | .navigationBarTitleDisplayMode(.inline) 24 | .toolbar { 25 | ToolbarItem(placement: .confirmationAction) { 26 | Button("Done") { 27 | isPresented = false 28 | } 29 | } 30 | } 31 | } 32 | } 33 | } 34 | 35 | struct SignInSettingsView_Previews: PreviewProvider { 36 | static var previews: some View { 37 | SignInSettingsView(environment: .constant(.prod), isPresented: .constant(true)) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Managers/API/CreateOrJoinStreamRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (C) 2021 Twilio, Inc. 3 | // 4 | 5 | import Foundation 6 | 7 | struct CreateOrJoinStreamRequest: APIRequest { 8 | struct Parameters: Encodable { 9 | let userIdentity: String 10 | let streamName: String 11 | } 12 | 13 | struct Response: Decodable { 14 | struct SyncObjectNames: Decodable { 15 | let speakersMap: String 16 | let viewersMap: String 17 | let raisedHandsMap: String 18 | let userDocument: String? 19 | } 20 | 21 | let token: String 22 | let roomSid: String 23 | let chatEnabled: Bool 24 | let syncObjectNames: SyncObjectNames 25 | } 26 | 27 | let path: String 28 | let parameters: Parameters 29 | let responseType = Response.self 30 | 31 | init(userIdentity: String, streamName: String, role: StreamConfig.Role) { 32 | parameters = Parameters(userIdentity: userIdentity, streamName: streamName) 33 | path = role.path 34 | } 35 | } 36 | 37 | private extension StreamConfig.Role { 38 | var path: String { 39 | switch self { 40 | case .host: return "create-stream" 41 | case .speaker: return "join-stream-as-speaker" 42 | case .viewer: return "join-stream-as-viewer" 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Views/Chat/ChatInputBar.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (C) 2022 Twilio, Inc. 3 | // 4 | 5 | import SwiftUI 6 | 7 | struct ChatInputBar: View { 8 | @EnvironmentObject var chatManager: ChatManager 9 | @State var messageBody = "" 10 | 11 | var body: some View { 12 | HStack { 13 | TextField("Write a message...", text: $messageBody) 14 | 15 | Button { 16 | chatManager.sendMessage(messageBody) 17 | messageBody = "" 18 | } label: { 19 | Image(systemName: "paperplane") 20 | .resizable() 21 | .scaledToFit() 22 | .padding(10) 23 | .foregroundColor(.white) 24 | .background(Color.accentColor) 25 | .cornerRadius(4) 26 | .frame(height: 44) 27 | } 28 | .disabled(messageBody.isEmpty) 29 | } 30 | .padding(8) 31 | } 32 | } 33 | 34 | struct ChatInputBar_Previews: PreviewProvider { 35 | static var previews: some View { 36 | Group { 37 | ChatInputBar() 38 | .previewDisplayName("Empty") 39 | ChatInputBar(messageBody: "Hello") 40 | .previewDisplayName("Not empty") 41 | } 42 | .previewLayout(.sizeThatFits) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /apps/web/src/components/Buttons/ToggleVideoButton/ToggleVideoButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useRef } from 'react'; 2 | 3 | import Button from '@material-ui/core/Button'; 4 | import VideoOffIcon from '../../../icons/VideoOffIcon'; 5 | import VideoOnIcon from '../../../icons/VideoOnIcon'; 6 | 7 | import useDevices from '../../../hooks/useDevices/useDevices'; 8 | import useLocalVideoToggle from '../../../hooks/useLocalVideoToggle/useLocalVideoToggle'; 9 | 10 | export default function ToggleVideoButton(props: { disabled?: boolean; className?: string }) { 11 | const [isVideoEnabled, toggleVideoEnabled] = useLocalVideoToggle(); 12 | const lastClickTimeRef = useRef(0); 13 | const { hasVideoInputDevices } = useDevices(); 14 | 15 | const toggleVideo = useCallback(() => { 16 | if (Date.now() - lastClickTimeRef.current > 500) { 17 | lastClickTimeRef.current = Date.now(); 18 | toggleVideoEnabled(); 19 | } 20 | }, [toggleVideoEnabled]); 21 | 22 | return ( 23 | 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /apps/web/src/components/ParticipantWindow/ParticipantWindow.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { makeStyles, createStyles, Theme } from '@material-ui/core/styles'; 3 | import clsx from 'clsx'; 4 | import { useAppState } from '../../state'; 5 | import ParticipantWindowHeader from './ParticipantWindowHeader/ParticipantWindowHeader'; 6 | import ViewersList from '../ViewersList/ViewersList'; 7 | import SpeakersList from '../SpeakersList/SpeakersList'; 8 | 9 | const useStyles = makeStyles((theme: Theme) => 10 | createStyles({ 11 | participantWindowContainer: { 12 | background: '#FFFFFF', 13 | zIndex: 9, 14 | display: 'flex', 15 | flexDirection: 'column', 16 | borderLeft: '1px solid #E4E7E9', 17 | [theme.breakpoints.down('sm')]: { 18 | position: 'fixed', 19 | top: 0, 20 | left: 0, 21 | bottom: 0, 22 | right: 0, 23 | zIndex: 100, 24 | }, 25 | }, 26 | hide: { 27 | display: 'none', 28 | }, 29 | }) 30 | ); 31 | 32 | export default function ParticipantWindow() { 33 | const classes = useStyles(); 34 | const { appState } = useAppState(); 35 | 36 | return ( 37 | 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /apps/web/src/icons/SpeakerIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function SpeakerIcon() { 4 | return ( 5 | 6 | 7 | 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /apps/web/src/icons/MicOffIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function MicOffIcon() { 4 | return ( 5 | 6 | 7 | 12 | 17 | 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /apps/web/src/types.ts: -------------------------------------------------------------------------------- 1 | import { LocalVideoTrack, RemoteVideoTrack, TwilioError } from 'twilio-video'; 2 | 3 | declare module 'twilio-video' { 4 | // These help to create union types between Local and Remote VideoTracks 5 | interface LocalVideoTrack { 6 | isSwitchedOff: undefined; 7 | setPriority: undefined; 8 | } 9 | 10 | interface Room { 11 | on(event: 'setPreventAutomaticJoinStreamAsViewer', listener: () => void): this; 12 | } 13 | } 14 | 15 | declare global { 16 | interface Window { 17 | visualViewport?: { 18 | scale: number; 19 | }; 20 | } 21 | 22 | interface MediaDevices { 23 | getDisplayMedia(constraints: MediaStreamConstraints): Promise; 24 | } 25 | 26 | interface HTMLMediaElement { 27 | setSinkId?(sinkId: string): Promise; 28 | } 29 | 30 | // Helps create a union type with TwilioError 31 | interface Error { 32 | code: undefined; 33 | } 34 | } 35 | 36 | export type Callback = (...args: any[]) => void; 37 | 38 | export type ErrorCallback = (error: TwilioError | Error) => void; 39 | 40 | export type IVideoTrack = LocalVideoTrack | RemoteVideoTrack; 41 | 42 | export type RoomType = 'group' | 'group-small' | 'peer-to-peer' | 'go'; 43 | 44 | export type RecordingRule = { 45 | type: 'include' | 'exclude'; 46 | all?: boolean; 47 | kind?: 'audio' | 'video'; 48 | publisher?: string; 49 | }; 50 | 51 | export type RecordingRules = RecordingRule[]; 52 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Views/Stream/StreamStatusView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (C) 2021 Twilio, Inc. 3 | // 4 | 5 | import SwiftUI 6 | 7 | struct StreamStatusView: View { 8 | let streamName: String 9 | @Binding var streamState: StreamManager.State 10 | 11 | var body: some View { 12 | HStack { 13 | if streamState == .connected { 14 | LiveBadge() 15 | } 16 | 17 | Spacer(minLength: 20) 18 | Text(streamName) 19 | .foregroundColor(.white) 20 | .font(.system(size: 16)) 21 | .lineLimit(1) 22 | } 23 | .background(Color.backgroundBrandStronger) 24 | } 25 | } 26 | 27 | struct StreamStatusView_Previews: PreviewProvider { 28 | static var previews: some View { 29 | Group { 30 | StreamStatusView(streamName: "Room name", streamState: .constant(.connecting)) 31 | .previewDisplayName("Loading") 32 | StreamStatusView(streamName: "Short room name", streamState: .constant(.connected)) 33 | .previewDisplayName("Short Room Name") 34 | StreamStatusView( 35 | streamName: "A very long room name that doesn't fit completely", 36 | streamState: .constant(.connected) 37 | ) 38 | .previewDisplayName("Long Room Name") 39 | } 40 | .previewLayout(.sizeThatFits) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /apps/web/src/components/ParticipantWindow/ParticipantWindowHeader/ParticipantWindowHeader.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { makeStyles, createStyles } from '@material-ui/core/styles'; 3 | import CloseIcon from '../../../icons/CloseIcon'; 4 | import { useAppState } from '../../../state'; 5 | 6 | const useStyles = makeStyles(() => 7 | createStyles({ 8 | container: { 9 | height: '56px', 10 | background: '#F4F4F6', 11 | borderBottom: '1px solid #E4E7E9', 12 | display: 'flex', 13 | flexShrink: 0, 14 | justifyContent: 'space-between', 15 | alignItems: 'center', 16 | padding: '0 1em', 17 | }, 18 | text: { 19 | fontWeight: 'bold', 20 | }, 21 | closeParticipantWindow: { 22 | cursor: 'pointer', 23 | display: 'flex', 24 | background: 'transparent', 25 | border: '0', 26 | padding: '0.4em', 27 | }, 28 | }) 29 | ); 30 | 31 | export default function ParticipantWindowHeader() { 32 | const classes = useStyles(); 33 | const { appDispatch } = useAppState(); 34 | 35 | return ( 36 |
37 |
Participants
38 | 44 |
45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /apps/web/src/hooks/useParticipantNetworkQualityLevel/useParticipantNetworkQualityLevel.test.tsx: -------------------------------------------------------------------------------- 1 | import { act, renderHook } from '@testing-library/react-hooks'; 2 | import EventEmitter from 'events'; 3 | import useParticipantNetworkQualityLevel from './useParticipantNetworkQualityLevel'; 4 | 5 | describe('the useParticipantNetworkQualityLevel hook', () => { 6 | let mockParticipant: any; 7 | 8 | beforeEach(() => { 9 | mockParticipant = new EventEmitter(); 10 | }); 11 | 12 | it('should return mockParticipant.networkQualityLevel by default', () => { 13 | mockParticipant.networkQualityLevel = 4; 14 | const { result } = renderHook(() => useParticipantNetworkQualityLevel(mockParticipant)); 15 | expect(result.current).toBe(4); 16 | }); 17 | 18 | it('should return respond to "networkQualityLevelChanged" events', async () => { 19 | mockParticipant.networkQualityLevel = 4; 20 | const { result } = renderHook(() => useParticipantNetworkQualityLevel(mockParticipant)); 21 | act(() => { 22 | mockParticipant.emit('networkQualityLevelChanged', 3); 23 | }); 24 | expect(result.current).toBe(3); 25 | }); 26 | 27 | it('should clean up listeners on unmount', () => { 28 | mockParticipant.networkQualityLevel = 4; 29 | const { unmount } = renderHook(() => useParticipantNetworkQualityLevel(mockParticipant)); 30 | unmount(); 31 | expect(mockParticipant.listenerCount('networkQualityLevelChanged')).toBe(0); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /apps/web/src/components/DeviceSelectionDialog/AudioOutputList/AudioOutputList.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { FormControl, MenuItem, Typography, Select } from '@material-ui/core'; 3 | import { useAppState } from '../../../state'; 4 | import useDevices from '../../../hooks/useDevices/useDevices'; 5 | 6 | export default function AudioOutputList() { 7 | const { audioOutputDevices } = useDevices(); 8 | const { activeSinkId, setActiveSinkId } = useAppState(); 9 | const activeOutputLabel = audioOutputDevices.find(device => device.deviceId === activeSinkId)?.label; 10 | 11 | return ( 12 |
13 | {audioOutputDevices.length > 1 ? ( 14 | 15 | 16 | Audio Output 17 | 18 | 25 | 26 | ) : ( 27 | <> 28 | Audio Output 29 | {activeOutputLabel || 'System Default Audio Output'} 30 | 31 | )} 32 |
33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /apps/web/src/components/ParticipantInfo/PinIcon/PinIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Tooltip from '@material-ui/core/Tooltip'; 3 | import SvgIcon from '@material-ui/core/SvgIcon'; 4 | 5 | export default function PinIcon() { 6 | return ( 7 | 8 | 14 | 20 | 21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /apps/web/src/components/Publication/Publication.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import useTrack from '../../hooks/useTrack/useTrack'; 3 | import AudioTrack from '../AudioTrack/AudioTrack'; 4 | import VideoTrack from '../VideoTrack/VideoTrack'; 5 | import DataTrack from '../DataTrack/DataTrack'; 6 | 7 | import { LocalTrackPublication, Participant, RemoteTrackPublication, Track } from 'twilio-video'; 8 | 9 | interface PublicationProps { 10 | publication: LocalTrackPublication | RemoteTrackPublication; 11 | participant: Participant; 12 | isLocalParticipant?: boolean; 13 | videoOnly?: boolean; 14 | videoPriority?: Track.Priority | null; 15 | } 16 | 17 | export default function Publication({ publication, isLocalParticipant, videoOnly, videoPriority }: PublicationProps) { 18 | const track = useTrack(publication); 19 | 20 | if (!track) return null; 21 | 22 | switch (track.kind) { 23 | case 'video': 24 | return ( 25 | 30 | ); 31 | case 'audio': 32 | return videoOnly ? null : ; 33 | case 'data': 34 | // We use videoOnly here because, like audio tracks, we only ever want one data track rendered at a time. 35 | return videoOnly ? null : ; 36 | default: 37 | return null; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /apps/web/src/hooks/useMediaStreamTrack/useMediaStreamTrack.test.ts: -------------------------------------------------------------------------------- 1 | import { act, renderHook } from '@testing-library/react-hooks'; 2 | import EventEmitter from 'events'; 3 | import useMediaStreamTrack from './useMediaStreamTrack'; 4 | 5 | describe('the useMediaStreamTrack hook', () => { 6 | let mockTrack: any; 7 | 8 | beforeEach(() => { 9 | mockTrack = new EventEmitter(); 10 | mockTrack.mediaStreamTrack = 'mockMediaStreamTrack'; 11 | }); 12 | 13 | it('should return undefined when track is undefined', () => { 14 | const { result } = renderHook(() => useMediaStreamTrack(undefined)); 15 | expect(result.current).toBe(undefined); 16 | }); 17 | 18 | it('should return mockTrack.mediaStreamTrack by default', () => { 19 | const { result } = renderHook(() => useMediaStreamTrack(mockTrack)); 20 | expect(result.current).toBe('mockMediaStreamTrack'); 21 | }); 22 | 23 | it('should respond to "started" events', async () => { 24 | const { result } = renderHook(() => useMediaStreamTrack(mockTrack)); 25 | act(() => { 26 | mockTrack.mediaStreamTrack = 'anotherMockMediaStreamTrack'; 27 | mockTrack.emit('started'); 28 | }); 29 | expect(result.current).toBe('anotherMockMediaStreamTrack'); 30 | }); 31 | 32 | it('should clean up listeners on unmount', () => { 33 | const { unmount } = renderHook(() => useMediaStreamTrack(mockTrack)); 34 | unmount(); 35 | expect(mockTrack.listenerCount('started')).toBe(0); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Managers/SpeakerSettings/SpeakerSettingsManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (C) 2021 Twilio, Inc. 3 | // 4 | 5 | import Combine 6 | import Foundation 7 | 8 | class SpeakerSettingsManager: ObservableObject { 9 | @Published var isMicOn = false { 10 | didSet { 11 | guard oldValue != isMicOn else { return } 12 | 13 | roomManager.localParticipant.isMicOn = isMicOn 14 | } 15 | } 16 | @Published var isCameraOn = false { 17 | didSet { 18 | guard oldValue != isCameraOn else { return } 19 | 20 | roomManager.localParticipant.isCameraOn = isCameraOn 21 | } 22 | } 23 | private var roomManager: RoomManager! 24 | private var subscriptions = Set() 25 | 26 | func configure(roomManager: RoomManager) { 27 | self.roomManager = roomManager 28 | 29 | roomManager.localParticipant.changePublisher 30 | .sink { [weak self] participant in 31 | self?.isMicOn = participant.isMicOn 32 | self?.isCameraOn = participant.isCameraOn 33 | } 34 | .store(in: &subscriptions) 35 | 36 | roomManager.messagePublisher 37 | .filter { $0.messageType == .mute && $0.toParticipantIdentity == roomManager.localParticipant.identity } 38 | .sink { [weak self] _ in self?.isMicOn = false } 39 | .store(in: &subscriptions) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /apps/web/src/components/Snackbar/SnackbarProvider.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { SnackbarImpl } from './Snackbar'; 3 | import { useSnackbar, SnackbarContent, SnackbarProvider as Provider } from 'notistack'; 4 | 5 | interface SnackbarProps { 6 | id: string | number; 7 | headline: string; 8 | message: string | React.ReactNode; 9 | variant?: 'error' | 'warning' | 'info'; 10 | } 11 | 12 | export interface SnackbarMessage { 13 | headline: string; 14 | message: string | React.ReactNode; 15 | variant?: 'error' | 'warning' | 'info'; 16 | } 17 | 18 | export const Snackbar = React.forwardRef(({ id, headline, message, variant }, ref) => { 19 | const { closeSnackbar } = useSnackbar(); 20 | const handleClose = () => { 21 | closeSnackbar(id); 22 | }; 23 | 24 | return ( 25 | 26 | 27 | 28 | ); 29 | }); 30 | 31 | export function SnackbarProvider({ children }: { children: React.ReactChild }) { 32 | return ( 33 | ( 39 | 40 | )} 41 | > 42 | {children} 43 | 44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /serverless/functions/send-speaker-invite.js: -------------------------------------------------------------------------------- 1 | /* global Twilio Runtime */ 2 | 'use strict'; 3 | 4 | module.exports.handler = async (context, event, callback) => { 5 | const authHandler = require(Runtime.getAssets()['/auth.js'].path); 6 | authHandler(context, event, callback); 7 | 8 | const { user_identity, room_sid } = event; 9 | 10 | const common = require(Runtime.getAssets()['/common.js'].path); 11 | const { getStreamMapItem } = common(context, event, callback); 12 | 13 | let response = new Twilio.Response(); 14 | response.appendHeader('Content-Type', 'application/json'); 15 | 16 | const client = context.getTwilioClient(); 17 | 18 | try { 19 | // Set speaker_invite to true 20 | const streamMapItem = await getStreamMapItem(room_sid); 21 | const streamSyncClient = await client.sync.services(streamMapItem.data.sync_service_sid); 22 | const doc = await streamSyncClient.documents(`user-${user_identity}`).fetch(); 23 | await streamSyncClient.documents(doc.sid).update({ data: { ...doc.data, speaker_invite: true } }); 24 | } catch (e) { 25 | console.error(e); 26 | response.setStatusCode(500); 27 | response.setBody({ 28 | error: { 29 | message: 'error updating user document', 30 | explanation: e.message, 31 | }, 32 | }); 33 | return callback(null, response); 34 | } 35 | 36 | response.setStatusCode(200); 37 | response.setBody({ 38 | sent: true, 39 | }); 40 | 41 | return callback(null, response); 42 | }; 43 | -------------------------------------------------------------------------------- /serverless/middleware/common.private.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const axios = require('axios'); 4 | 5 | module.exports = (context, event, callback) => { 6 | let response = new Twilio.Response(); 7 | response.appendHeader('Content-Type', 'application/json'); 8 | 9 | const client = context.getTwilioClient(); 10 | const region = client.region; 11 | 12 | const axiosClient = axios.create({ 13 | headers: { 14 | Authorization: 'Basic ' + Buffer.from(`${context.ACCOUNT_SID}:${context.AUTH_TOKEN}`, 'utf8').toString('base64'), 15 | 'content-type': 'application/x-www-form-urlencoded;charset=utf-8', 16 | }, 17 | baseURL: 18 | region === 'dev' || region === 'stage' ? `https://media.${region}.twilio.com/v1` : 'https://media.twilio.com/v1', 19 | }); 20 | 21 | async function getPlaybackGrant(playerStreamerSid) { 22 | const playbackGrant = await axiosClient(`PlayerStreamers/${playerStreamerSid}/PlaybackGrant`, { 23 | method: 'post', 24 | data: `AccessControlAllowOrigin=*`, 25 | }); 26 | return playbackGrant.data.grant; 27 | } 28 | 29 | async function getStreamMapItem(roomSid) { 30 | const backendStorageSyncClient = await client.sync.services(context.BACKEND_STORAGE_SYNC_SERVICE_SID); 31 | const mapItem = await backendStorageSyncClient.syncMaps('streams').syncMapItems(roomSid).fetch(); 32 | return mapItem; 33 | } 34 | 35 | return { 36 | axiosClient, 37 | response, 38 | getPlaybackGrant, 39 | getStreamMapItem 40 | }; 41 | }; 42 | -------------------------------------------------------------------------------- /serverless/scripts/list.js: -------------------------------------------------------------------------------- 1 | const constants = require('../constants'); 2 | require('dotenv').config(); 3 | const client = require('twilio')(process.env.ACCOUNT_SID, process.env.AUTH_TOKEN); 4 | 5 | async function findApp() { 6 | const services = await client.serverless.services.list(); 7 | return services.find((service) => service.friendlyName.includes(constants.SERVICE_NAME)); 8 | } 9 | 10 | async function getAppInfo() { 11 | const app = await findApp(); 12 | if (!app) return null; 13 | 14 | const appInstance = client.serverless.services(app.sid); 15 | const [environment] = await appInstance.environments.list(); 16 | const variables = await appInstance.environments(environment.sid).variables.list(); 17 | const expiryVar = variables.find((v) => v.key === 'APP_EXPIRY'); 18 | const expiryDate = new Date(Number(expiryVar.value)).toString(); 19 | const passcode = variables.find((v) => v.key === 'PASSCODE').value; 20 | const [, appID, serverlessID] = environment.domainName.match(/-?(\d*)-(\d+)(?:-\w+)?(?:\.\w+)?\.twil\.io$/); 21 | const fullPasscode = `${passcode}${appID}${serverlessID}`; 22 | 23 | console.log(`App deployed to: https://${environment.domainName}?passcode=${fullPasscode}`); 24 | console.log(`Passcode: ${fullPasscode.replace(/(\d{3})(\d{3})(\d{4})(\d{4})/, '$1 $2 $3 $4')}`); 25 | console.log(`This URL is for demo purposes only. It will expire on ${expiryDate}`); 26 | } 27 | 28 | if (require.main === module) { 29 | getAppInfo(); 30 | } else { 31 | module.exports = getAppInfo; 32 | } 33 | -------------------------------------------------------------------------------- /apps/web/src/components/ErrorDialog/ErrorDialog.tsx: -------------------------------------------------------------------------------- 1 | import React, { PropsWithChildren } from 'react'; 2 | import Button from '@material-ui/core/Button'; 3 | import Dialog from '@material-ui/core/Dialog'; 4 | import DialogContent from '@material-ui/core/DialogContent'; 5 | import DialogActions from '@material-ui/core/DialogActions'; 6 | import DialogTitle from '@material-ui/core/DialogTitle'; 7 | import DialogContentText from '@material-ui/core/DialogContentText'; 8 | import enhanceMessage from './enhanceMessage'; 9 | import { TwilioError } from 'twilio-video'; 10 | 11 | interface ErrorDialogProps { 12 | dismissError: Function; 13 | error: TwilioError | Error | null; 14 | } 15 | 16 | function ErrorDialog({ dismissError, error }: PropsWithChildren) { 17 | const { message, code } = error || {}; 18 | const enhancedMessage = enhanceMessage(message, code); 19 | 20 | return ( 21 | dismissError()} fullWidth={true} maxWidth="xs"> 22 | ERROR 23 | 24 | {enhancedMessage} 25 | {Boolean(code) && ( 26 |
27 |             Error Code: {code}
28 |           
29 | )} 30 |
31 | 32 | 35 | 36 |
37 | ); 38 | } 39 | 40 | export default ErrorDialog; 41 | -------------------------------------------------------------------------------- /apps/web/src/hooks/useLocalVideoToggle/useLocalVideoToggle.tsx: -------------------------------------------------------------------------------- 1 | import { LocalVideoTrack } from 'twilio-video'; 2 | import { useCallback, useState } from 'react'; 3 | import useVideoContext from '../useVideoContext/useVideoContext'; 4 | 5 | export default function useLocalVideoToggle() { 6 | const { room, localTracks, getLocalVideoTrack, removeLocalVideoTrack, onError } = useVideoContext(); 7 | const localParticipant = room?.localParticipant; 8 | const videoTrack = localTracks.find(track => track.name.includes('camera')) as LocalVideoTrack; 9 | const [isPublishing, setIspublishing] = useState(false); 10 | 11 | const toggleVideoEnabled = useCallback(() => { 12 | if (!isPublishing) { 13 | if (videoTrack) { 14 | const localTrackPublication = localParticipant?.unpublishTrack(videoTrack); 15 | // TODO: remove when SDK implements this event. See: https://issues.corp.twilio.com/browse/JSDK-2592 16 | localParticipant?.emit('trackUnpublished', localTrackPublication); 17 | removeLocalVideoTrack(); 18 | } else { 19 | setIspublishing(true); 20 | getLocalVideoTrack() 21 | .then((track: LocalVideoTrack) => localParticipant?.publishTrack(track, { priority: 'low' })) 22 | .catch(onError) 23 | .finally(() => { 24 | setIspublishing(false); 25 | }); 26 | } 27 | } 28 | }, [videoTrack, localParticipant, getLocalVideoTrack, isPublishing, onError, removeLocalVideoTrack]); 29 | 30 | return [!!videoTrack, toggleVideoEnabled] as const; 31 | } 32 | -------------------------------------------------------------------------------- /apps/web/src/icons/WarningIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function WarningIcon() { 4 | return ( 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideoUITests/LiveVideoUITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (C) 2021 Twilio, Inc. 3 | // 4 | 5 | import XCTest 6 | 7 | class LiveVideoUITests: XCTestCase { 8 | 9 | override func setUpWithError() throws { 10 | // Put setup code here. This method is called before the invocation of each test method in the class. 11 | 12 | // In UI tests it is usually best to stop immediately when a failure occurs. 13 | continueAfterFailure = false 14 | 15 | // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. 16 | } 17 | 18 | override func tearDownWithError() throws { 19 | // Put teardown code here. This method is called after the invocation of each test method in the class. 20 | } 21 | 22 | func testExample() throws { 23 | // UI tests must launch the application that they test. 24 | let app = XCUIApplication() 25 | app.launch() 26 | 27 | // Use recording to get started writing UI tests. 28 | // Use XCTAssert and related functions to verify your tests produce the correct results. 29 | } 30 | 31 | func testLaunchPerformance() throws { 32 | if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) { 33 | // This measures how long it takes to launch your application. 34 | measure(metrics: [XCTApplicationLaunchMetric()]) { 35 | XCUIApplication().launch() 36 | } 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /apps/web/src/hooks/useSpeakersMap/useSpeakersMap.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { SyncMapItem } from 'twilio-sync'; 3 | import useSyncContext from '../useSyncContext/useSyncContext'; 4 | 5 | interface Speaker extends SyncMapItem { 6 | data: { 7 | host: boolean; 8 | }; 9 | } 10 | 11 | export function useSpeakersMap() { 12 | const { speakersMap } = useSyncContext(); 13 | const [speakers, setSpeakers] = useState([]); 14 | const [host, setHost] = useState(); 15 | 16 | useEffect(() => { 17 | if (speakersMap) { 18 | // Sets the list on load. Limiting to first 100 speakers 19 | speakersMap.getItems({ pageSize: 100 }).then(paginator => { 20 | setSpeakers(paginator.items.map(item => item.key)); 21 | const hostItem = paginator.items.find(speaker => (speaker as Speaker).data.host); 22 | setHost(hostItem?.key); 23 | }); 24 | 25 | const handleItemAdded = (args: any) => { 26 | setSpeakers(prevSpeakers => [...prevSpeakers, args.item.key]); 27 | }; 28 | 29 | const handleItemRemoved = (args: any) => { 30 | setSpeakers(prevSpeakers => prevSpeakers.filter(i => i !== args.key)); 31 | }; 32 | 33 | speakersMap.on('itemAdded', handleItemAdded); 34 | speakersMap.on('itemRemoved', handleItemRemoved); 35 | 36 | return () => { 37 | speakersMap.off('itemAdded', handleItemAdded); 38 | speakersMap.off('itemRemoved', handleItemRemoved); 39 | }; 40 | } 41 | }, [speakersMap]); 42 | 43 | return { 44 | speakers, 45 | host, 46 | }; 47 | } 48 | -------------------------------------------------------------------------------- /apps/web/src/icons/BackgroundIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function BackgroundIcon() { 4 | return ( 5 | 6 | 12 | 18 | 24 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /apps/ios/LiveVideo/LiveVideo/Views/Home/EnvironmentBadge.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (C) 2022 Twilio, Inc. 3 | // 4 | 5 | import SwiftUI 6 | 7 | struct EnvironmentBadge: View { 8 | @EnvironmentObject var appSettingsManager: AppSettingsManager 9 | 10 | var body: some View { 11 | HStack { 12 | Spacer() 13 | Text(appSettingsManager.environment.rawValue.uppercased()) 14 | .font(.title2.bold()) 15 | .padding(7) 16 | .foregroundColor(.white) 17 | .background(appSettingsManager.environment.backgroundColor) 18 | .cornerRadius(6) 19 | Spacer() 20 | } 21 | } 22 | } 23 | 24 | private extension TwilioEnvironment { 25 | var backgroundColor: Color { 26 | switch self { 27 | case .prod: return .red 28 | case .stage: return .orange 29 | case .dev: return .green 30 | } 31 | } 32 | } 33 | 34 | struct EnvironmentBadge_Previews: PreviewProvider { 35 | static var previews: some View { 36 | ForEach(TwilioEnvironment.allCases, id: \.self) { environment in 37 | EnvironmentBadge() 38 | .environmentObject(AppSettingsManager.stub(environment: environment)) 39 | .previewLayout(.sizeThatFits) 40 | } 41 | } 42 | } 43 | 44 | extension AppSettingsManager { 45 | static func stub(environment: TwilioEnvironment = .prod) -> AppSettingsManager { 46 | let appSettingsManager = AppSettingsManager() 47 | appSettingsManager.environment = environment 48 | return appSettingsManager 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /apps/web/scripts/build.js: -------------------------------------------------------------------------------- 1 | const { spawn } = require('child_process'); 2 | const { Transform } = require('stream'); 3 | const stripColor = require('strip-color'); 4 | const path = require('path'); 5 | require('dotenv').config({ path: path.join(__dirname, '../../../.env') }); 6 | 7 | // This text that is generated by react-scripts doesn't work for 8 | // this app (because it also needs a token server) so we are filtering 9 | // it out in this script. 10 | const TEXT_TO_EXCLUDE = ` 11 | The project was built assuming it is hosted at /. 12 | You can control this with the homepage field in your package.json. 13 | 14 | The build folder is ready to be deployed. 15 | You may serve it with a static server: 16 | 17 | npm install -g serve 18 | serve -s build 19 | 20 | Find out more about deployment here: 21 | 22 | https://cra.link/deployment 23 | 24 | `; 25 | 26 | class Filter extends Transform { 27 | constructor() { 28 | super({ 29 | readableObjectMode: true, 30 | writableObjectMode: true, 31 | }); 32 | } 33 | 34 | _transform(chunk, _, next) { 35 | if (TEXT_TO_EXCLUDE.includes(stripColor(chunk.toString()))) { 36 | next(); 37 | } else { 38 | next(null, chunk); 39 | } 40 | } 41 | } 42 | 43 | // Colors normally don't work when using spawn(), so here we re-enable colors. 44 | process.env.FORCE_COLOR = require('supports-color').stdout.level; 45 | 46 | const buildProcess = spawn('node', [require.resolve('react-scripts/scripts/build')]); 47 | 48 | buildProcess.stderr.pipe(process.stderr); 49 | buildProcess.stdout.pipe(new Filter()).pipe(process.stdout); 50 | buildProcess.on('exit', process.exit); 51 | -------------------------------------------------------------------------------- /apps/web/src/hooks/useTrack/useTrack.test.tsx: -------------------------------------------------------------------------------- 1 | import { act, renderHook } from '@testing-library/react-hooks'; 2 | import EventEmitter from 'events'; 3 | import useTrack from './useTrack'; 4 | 5 | describe('the useTrack hook', () => { 6 | let mockPublication: any; 7 | 8 | beforeEach(() => { 9 | mockPublication = new EventEmitter(); 10 | }); 11 | 12 | it('should return mockPublication.track by default', () => { 13 | mockPublication.track = 'mockTrack'; 14 | const { result } = renderHook(() => useTrack(mockPublication)); 15 | expect(result.current).toBe('mockTrack'); 16 | }); 17 | 18 | it('should return respond to "subscribed" events', async () => { 19 | mockPublication.track = 'mockTrack'; 20 | const { result } = renderHook(() => useTrack(mockPublication)); 21 | act(() => { 22 | mockPublication.emit('subscribed', 'newMockTrack'); 23 | }); 24 | expect(result.current).toBe('newMockTrack'); 25 | }); 26 | 27 | it('should return respond to "unsubscribed" events', async () => { 28 | mockPublication.track = 'mockTrack'; 29 | const { result } = renderHook(() => useTrack(mockPublication)); 30 | act(() => { 31 | mockPublication.emit('unsubscribed'); 32 | }); 33 | expect(result.current).toBe(null); 34 | }); 35 | 36 | it('should clean up listeners on unmount', () => { 37 | mockPublication.track = 'mockTrack'; 38 | const { unmount } = renderHook(() => useTrack(mockPublication)); 39 | unmount(); 40 | expect(mockPublication.listenerCount('subscribed')).toBe(0); 41 | expect(mockPublication.listenerCount('unsubscribed')).toBe(0); 42 | }); 43 | }); 44 | --------------------------------------------------------------------------------