diff --git a/src/client/actions/MediaActions.ts b/src/client/actions/MediaActions.ts new file mode 100644 index 0000000..5253a42 --- /dev/null +++ b/src/client/actions/MediaActions.ts @@ -0,0 +1,106 @@ +import { makeAction, AsyncAction } from '../async' +import { MEDIA_AUDIO_CONSTRAINT_SET, MEDIA_VIDEO_CONSTRAINT_SET, MEDIA_ENUMERATE, MEDIA_STREAM } from '../constants' + +export interface MediaDevice { + id: string + name: string + type: 'audioinput' | 'videoinput' +} + +export const enumerateDevices = makeAction(MEDIA_ENUMERATE, async () => { + const devices = await navigator.mediaDevices.enumerateDevices() + + return devices + .filter( + device => device.kind === 'audioinput' || device.kind === 'videoinput') + .map(device => ({ + id: device.deviceId, + type: device.kind, + name: device.label, + }) as MediaDevice) + +}) + +export type FacingMode = 'user' | 'environment' + +export interface DeviceConstraint { + deviceId: string +} + +export interface FacingConstraint { + facingMode: FacingMode | { exact: FacingMode } +} + +export type VideoConstraint = DeviceConstraint | boolean | FacingConstraint +export type AudioConstraint = DeviceConstraint | boolean + +export interface GetMediaConstraints { + video: VideoConstraint + audio: AudioConstraint +} + +declare global { + interface Navigator { + webkitGetUserMedia?: typeof navigator.getUserMedia + mozGetUserMedia?: typeof navigator.getUserMedia + } +} + +async function getUserMedia( + constraints: MediaStreamConstraints, +): Promise { + if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) { + return navigator.mediaDevices.getUserMedia(constraints) + } + + const _getUserMedia: typeof navigator.getUserMedia = + navigator.getUserMedia || + navigator.webkitGetUserMedia || + navigator.mozGetUserMedia + + return new Promise((resolve, reject) => { + _getUserMedia.call(navigator, constraints, resolve, reject) + }) +} + +export interface MediaVideoConstraintAction { + type: 'MEDIA_VIDEO_CONSTRAINT_SET' + payload: VideoConstraint +} + +export interface MediaAudioConstraintAction { + type: 'MEDIA_AUDIO_CONSTRAINT_SET' + payload: AudioConstraint +} + +export function setVideoConstraint( + payload: VideoConstraint, +): MediaVideoConstraintAction { + return { + type: MEDIA_VIDEO_CONSTRAINT_SET, + payload, + } +} + +export function setAudioConstraint( + payload: AudioConstraint, +): MediaAudioConstraintAction { + return { + type: MEDIA_AUDIO_CONSTRAINT_SET, + payload, + } +} + +export const getMediaStream = makeAction( + MEDIA_STREAM, + async (constraints: GetMediaConstraints) => getUserMedia(constraints), +) + +export type MediaEnumerateAction = AsyncAction<'MEDIA_ENUMERATE', MediaDevice[]> +export type MediaStreamAction = AsyncAction<'MEDIA_STREAM', MediaStream> + +export type MediaAction = + MediaVideoConstraintAction | + MediaAudioConstraintAction | + MediaEnumerateAction | + MediaStreamAction diff --git a/src/client/constants.ts b/src/client/constants.ts index e433276..8ba071f 100644 --- a/src/client/constants.ts +++ b/src/client/constants.ts @@ -15,6 +15,11 @@ export const NOTIFY_CLEAR = 'NOTIFY_CLEAR' export const MESSAGE_ADD = 'MESSAGE_ADD' +export const MEDIA_ENUMERATE = 'MEDIA_ENUMERATE' +export const MEDIA_STREAM = 'MEDIA_STREAM' +export const MEDIA_VIDEO_CONSTRAINT_SET = 'MEDIA_VIDEO_CONSTRAINT_SET' +export const MEDIA_AUDIO_CONSTRAINT_SET = 'MEDIA_AUDIO_CONSTRAINT_SET' + export const PEER_ADD = 'PEER_ADD' export const PEER_REMOVE = 'PEER_REMOVE' export const PEERS_DESTROY = 'PEERS_DESTROY' diff --git a/src/client/reducers/index.ts b/src/client/reducers/index.ts index ab8d83b..835325a 100644 --- a/src/client/reducers/index.ts +++ b/src/client/reducers/index.ts @@ -3,6 +3,7 @@ import alerts from './alerts' import notifications from './notifications' import messages from './messages' import peers from './peers' +import media from './media' import streams from './streams' import { combineReducers } from 'redux' @@ -11,6 +12,7 @@ export default combineReducers({ alerts, notifications, messages, + media, peers, streams, }) diff --git a/src/client/reducers/media.test.ts b/src/client/reducers/media.test.ts new file mode 100644 index 0000000..b2c4cfe --- /dev/null +++ b/src/client/reducers/media.test.ts @@ -0,0 +1,132 @@ +import * as MediaActions from '../actions/MediaActions' +import { MEDIA_ENUMERATE, MEDIA_VIDEO_CONSTRAINT_SET, MEDIA_AUDIO_CONSTRAINT_SET, MEDIA_STREAM } from '../constants' +import { createStore, Store } from '../store' + +describe('media', () => { + + let store: Store + beforeEach(() => { + store = createStore(); + (navigator as any).mediaDevices = {} + }) + + afterEach(() => { + delete (navigator as any).mediaDevices + }) + + function toJSON(this: MediaDeviceInfo) { + return JSON.stringify(this) + } + + describe(MEDIA_ENUMERATE, () => { + beforeEach(() => { + navigator.mediaDevices.enumerateDevices = async () => { + const result: MediaDeviceInfo[] = [{ + deviceId: 'abcdef1', + groupId: 'group1', + kind: 'videoinput', + label: 'Video Input', + toJSON, + }, { + deviceId: 'abcdef2', + groupId: 'group1', + kind: 'audioinput', + label: 'Audio Input', + toJSON, + }, { + deviceId: 'abcdef3', + groupId: 'group2', + kind: 'audiooutput', + label: 'Audio Output', + toJSON, + }] + return result + } + }) + + it('retrieves a list of audioinput/videoinput devices', async () => { + await store.dispatch(MediaActions.enumerateDevices()) + expect(store.getState().media.devices).toEqual([{ + id: 'abcdef1', + name: 'Video Input', + type: 'videoinput', + }, { + id: 'abcdef2', + name: 'Audio Input', + type: 'audioinput', + }]) + }) + + it('handles errors', async () => { + delete navigator.mediaDevices.enumerateDevices + try { + await store.dispatch(MediaActions.enumerateDevices()) + } catch (err) { + // do nothing + } + expect(store.getState().media.devices).toEqual([]) + expect(store.getState().media.error).toBeTruthy() + }) + }) + + describe(MEDIA_VIDEO_CONSTRAINT_SET, () => { + it('sets constraints for video device', () => { + expect(store.getState().media.video).toEqual({ facingMode: 'user' }) + const constraint: MediaActions.VideoConstraint = true + store.dispatch(MediaActions.setVideoConstraint(constraint)) + expect(store.getState().media.video).toEqual(constraint) + }) + }) + + describe(MEDIA_AUDIO_CONSTRAINT_SET, () => { + it('sets constraints for audio device', () => { + expect(store.getState().media.audio).toBe(true) + const constraint: MediaActions.AudioConstraint = { deviceId: 'abcd' } + store.dispatch(MediaActions.setAudioConstraint(constraint)) + expect(store.getState().media.audio).toEqual(constraint) + }) + }) + + describe(MEDIA_STREAM, () => { + const stream: MediaStream = {} as MediaStream + describe('using navigator.mediaDevices.getUserMedia', () => { + + beforeEach(() => { + navigator.mediaDevices.getUserMedia = async () => stream + }) + + it('returns a promise with media stream', async () => { + const promise = MediaActions.getMediaStream({ + audio: true, + video: true, + }) + expect(promise.type).toBe('MEDIA_STREAM') + expect(promise.status).toBe('pending') + expect(await promise).toBe(stream) + }) + }); + + ['getUserMedia', 'mozGetUserMedia', 'webkitGetUserMedia'].forEach(item => { + describe('compatibility: navigator.' + item, () => { + beforeEach(() => { + const getUserMedia: typeof navigator.getUserMedia = + (constraint, onSuccess, onError) => onSuccess(stream); + (navigator as any)[item] = getUserMedia + }) + afterEach(() => { + delete (navigator as any)[item] + }) + it('returns a promise with media stream' + item, async () => { + const promise = MediaActions.getMediaStream({ + audio: true, + video: true, + }) + expect(promise.type).toBe('MEDIA_STREAM') + expect(promise.status).toBe('pending') + expect(await promise).toBe(stream) + }) + }) + }) + }) + +}) diff --git a/src/client/reducers/media.ts b/src/client/reducers/media.ts new file mode 100644 index 0000000..0600e92 --- /dev/null +++ b/src/client/reducers/media.ts @@ -0,0 +1,65 @@ +import { MediaDevice, AudioConstraint, VideoConstraint, MediaAction, MediaEnumerateAction } from '../actions/MediaActions' +import { MEDIA_ENUMERATE, MEDIA_AUDIO_CONSTRAINT_SET, MEDIA_VIDEO_CONSTRAINT_SET } from '../constants' + +export interface MediaState { + devices: MediaDevice[] + video: VideoConstraint + audio: AudioConstraint + loading: boolean + error: string +} + +const defaultState: MediaState = { + devices: [], + video: { facingMode: 'user'}, + audio: true, + loading: false, + error: '', +} + +export function handleEnumerate( + state: MediaState, + action: MediaEnumerateAction, +): MediaState { + switch (action.status) { + case 'resolved': + return { + ...state, + loading: false, + devices: action.payload, + } + case 'pending': + return { + ...state, + loading: true, + } + case 'rejected': + return { + ...state, + loading: false, + error: 'Could not retrieve media devices', + } + } +} + +export default function media( + state = defaultState, + action: MediaAction, +): MediaState { + switch (action.type) { + case MEDIA_ENUMERATE: + return handleEnumerate(state, action) + case MEDIA_AUDIO_CONSTRAINT_SET: + return { + ...state, + audio: action.payload, + } + case MEDIA_VIDEO_CONSTRAINT_SET: + return { + ...state, + video: action.payload, + } + default: + return state + } +}