Add autoplay error notification

This commit is contained in:
Jerko Steiner 2019-11-17 10:45:46 -03:00
parent 0a40e7202a
commit 67d9177a91
18 changed files with 156 additions and 131 deletions

View File

@ -21,12 +21,10 @@ const initialize = (): InitializeAction => ({
export const init = (): ThunkResult<Promise<void>> => export const init = (): ThunkResult<Promise<void>> =>
async (dispatch, getState) => { async (dispatch, getState) => {
const socket = await dispatch(connect()) const socket = await dispatch(connect())
// const stream = await dispatch(getCameraStream())
dispatch(SocketActions.handshake({ dispatch(SocketActions.handshake({
socket, socket,
roomName: callId, roomName: callId,
// stream,
})) }))
dispatch(initialize()) dispatch(initialize())
@ -45,18 +43,3 @@ export const connect = () => (dispatch: Dispatch) => {
}) })
}) })
} }
// export const getCameraStream = () => async (dispatch: Dispatch) => {
// try {
// const stream = await getUserMedia({
// video: { facingMode: 'user' },
// audio: true,
// })
// dispatch(StreamActions.addStream({ stream, userId: constants.ME }))
// return stream
// } catch (err) {
// dispatch(
// NotifyActions.alert('Could not get access to microphone & camera'))
// return
// }
// }

View File

@ -103,6 +103,18 @@ export function setMediaVisible(visible: boolean): MediaVisibleAction {
} }
} }
export const play = makeAction('MEDIA_PLAY', async () => {
const promises = Array
.from(document.querySelectorAll('video'))
.filter(video => {
console.log('video', video.paused, video)
return video.paused
})
.map(video => video.play())
await Promise.all(promises)
})
export const getMediaStream = makeAction( export const getMediaStream = makeAction(
MEDIA_STREAM, MEDIA_STREAM,
async (constraints: GetMediaConstraints) => getUserMedia(constraints), async (constraints: GetMediaConstraints) => getUserMedia(constraints),
@ -110,10 +122,12 @@ export const getMediaStream = makeAction(
export type MediaEnumerateAction = AsyncAction<'MEDIA_ENUMERATE', MediaDevice[]> export type MediaEnumerateAction = AsyncAction<'MEDIA_ENUMERATE', MediaDevice[]>
export type MediaStreamAction = AsyncAction<'MEDIA_STREAM', MediaStream> export type MediaStreamAction = AsyncAction<'MEDIA_STREAM', MediaStream>
export type MediaPlayAction = AsyncAction<'MEDIA_PLAY', void>
export type MediaAction = export type MediaAction =
MediaVideoConstraintAction | MediaVideoConstraintAction |
MediaAudioConstraintAction | MediaAudioConstraintAction |
MediaEnumerateAction | MediaEnumerateAction |
MediaStreamAction | MediaStreamAction |
MediaVisibleAction MediaVisibleAction |
MediaPlayAction

View File

@ -5,7 +5,6 @@ import * as PeerActions from './PeerActions'
import Peer from 'simple-peer' import Peer from 'simple-peer'
import { EventEmitter } from 'events' import { EventEmitter } from 'events'
import { createStore, Store, GetState } from '../store' import { createStore, Store, GetState } from '../store'
import { play } from '../window'
import { Dispatch } from 'redux' import { Dispatch } from 'redux'
import { ClientSocket } from '../socket' import { ClientSocket } from '../socket'
@ -33,8 +32,7 @@ describe('PeerActions', () => {
user = { id: 'user2' } user = { id: 'user2' }
socket = createSocket() socket = createSocket()
instances = (Peer as any).instances = []; instances = (Peer as any).instances = [];
(Peer as unknown as jest.Mock).mockClear(); (Peer as unknown as jest.Mock).mockClear()
(play as jest.Mock).mockClear()
stream = { stream: true } as unknown as MediaStream stream = { stream: true } as unknown as MediaStream
PeerMock = Peer as unknown as jest.Mock<Peer.Instance> PeerMock = Peer as unknown as jest.Mock<Peer.Instance>
}) })
@ -87,8 +85,8 @@ describe('PeerActions', () => {
describe('connect', () => { describe('connect', () => {
beforeEach(() => peer.emit('connect')) beforeEach(() => peer.emit('connect'))
it('dispatches "play" action', () => { it('dispatches peer connection established message', () => {
expect((play as jest.Mock).mock.calls.length).toBe(1) // TODO
}) })
}) })

View File

@ -5,7 +5,7 @@ import * as constants from '../constants'
import Peer, { SignalData } from 'simple-peer' import Peer, { SignalData } from 'simple-peer'
import forEach from 'lodash/forEach' import forEach from 'lodash/forEach'
import _debug from 'debug' import _debug from 'debug'
import { play, iceServers } from '../window' import { iceServers } from '../window'
import { Dispatch, GetState } from '../store' import { Dispatch, GetState } from '../store'
import { ClientSocket } from '../socket' import { ClientSocket } from '../socket'
@ -50,10 +50,19 @@ class PeerHandler {
socket.emit('signal', payload) socket.emit('signal', payload)
} }
handleConnect = () => { handleConnect = () => {
const { dispatch, user } = this const { dispatch, user, getState } = this
debug('peer: %s, connect', user.id) debug('peer: %s, connect', user.id)
dispatch(NotifyActions.warning('Peer connection established')) dispatch(NotifyActions.warning('Peer connection established'))
play()
const state = getState()
const peer = state.peers[user.id]
const localStream = state.streams[constants.ME]
if (localStream && localStream.stream) {
// If the local user pressed join call before this peer has joined the
// call, now is the time to share local media stream with the peer since
// we no longer automatically send the stream to the peer.
peer.addStream(localStream.stream)
}
} }
handleStream = (stream: MediaStream) => { handleStream = (stream: MediaStream) => {
const { user, dispatch } = this const { user, dispatch } = this

View File

@ -22,6 +22,7 @@ export interface AppProps {
messages: Message[] messages: Message[]
messagesCount: number messagesCount: number
peers: Record<string, Peer.Instance> peers: Record<string, Peer.Instance>
play: () => void
sendMessage: (message: TextMessage) => void sendMessage: (message: TextMessage) => void
streams: Record<string, AddStreamPayload> streams: Record<string, AddStreamPayload>
onSendFile: (file: File) => void onSendFile: (file: File) => void
@ -66,6 +67,7 @@ export default class App extends React.PureComponent<AppProps, AppState> {
messages, messages,
messagesCount, messagesCount,
onSendFile, onSendFile,
play,
peers, peers,
sendMessage, sendMessage,
toggleActive, toggleActive,
@ -97,6 +99,7 @@ export default class App extends React.PureComponent<AppProps, AppState> {
videos={videos} videos={videos}
active={active === constants.ME} active={active === constants.ME}
onClick={toggleActive} onClick={toggleActive}
play={play}
stream={streams[constants.ME]} stream={streams[constants.ME]}
userId={constants.ME} userId={constants.ME}
muted muted
@ -108,6 +111,7 @@ export default class App extends React.PureComponent<AppProps, AppState> {
active={userId === active} active={userId === active}
key={userId} key={userId}
onClick={toggleActive} onClick={toggleActive}
play={play}
stream={streams[userId]} stream={streams[userId]}
userId={userId} userId={userId}
videos={videos} videos={videos}

View File

@ -53,7 +53,7 @@ describe('Media', () => {
} }
}) })
it('tries to retrieve audio/video media stream', async () => { it('tries to retrieve audio/video media stream', async () => {
const node = await render() const node = (await render()).querySelector('.media')!
expect(node.tagName).toBe('FORM') expect(node.tagName).toBe('FORM')
TestUtils.Simulate.submit(node) TestUtils.Simulate.submit(node)
expect(promise).toBeDefined() expect(promise).toBeDefined()

View File

@ -1,6 +1,6 @@
import React from 'react' import React from 'react'
import { connect } from 'react-redux' import { connect } from 'react-redux'
import { AudioConstraint, MediaDevice, setAudioConstraint, setVideoConstraint, VideoConstraint, getMediaStream, enumerateDevices } from '../actions/MediaActions' import { AudioConstraint, MediaDevice, setAudioConstraint, setVideoConstraint, VideoConstraint, getMediaStream, enumerateDevices, play } from '../actions/MediaActions'
import { MediaState } from '../reducers/media' import { MediaState } from '../reducers/media'
import { State } from '../store' import { State } from '../store'
@ -9,6 +9,7 @@ export type MediaProps = MediaState & {
onSetVideoConstraint: typeof setVideoConstraint onSetVideoConstraint: typeof setVideoConstraint
onSetAudioConstraint: typeof setAudioConstraint onSetAudioConstraint: typeof setAudioConstraint
getMediaStream: typeof getMediaStream getMediaStream: typeof getMediaStream
play: typeof play
} }
function mapStateToProps(state: State) { function mapStateToProps(state: State) {
@ -22,11 +23,12 @@ const mapDispatchToProps = {
onSetVideoConstraint: setVideoConstraint, onSetVideoConstraint: setVideoConstraint,
onSetAudioConstraint: setAudioConstraint, onSetAudioConstraint: setAudioConstraint,
getMediaStream, getMediaStream,
play,
} }
const c = connect(mapStateToProps, mapDispatchToProps) const c = connect(mapStateToProps, mapDispatchToProps)
export const Media = c(React.memo(function Media(props: MediaProps) { export const MediaForm = React.memo(function MediaForm(props: MediaProps) {
if (!props.visible) { if (!props.visible) {
return null return null
} }
@ -88,6 +90,34 @@ export const Media = c(React.memo(function Media(props: MediaProps) {
</button> </button>
</form> </form>
) )
})
export interface AutoplayProps {
play: () => void
}
export const AutoplayMessage = React.memo(
function Autoplay(props: AutoplayProps) {
return (
<div className='autoplay'>
The browser has blocked video autoplay on this page.
To continue with your call, please press the play button:
&nbsp;
<button className='button' onClick={props.play}>
Play
</button>
</div>
)
},
)
export const Media = c(React.memo(function Media(props: MediaProps) {
return (
<div className='media-container'>
{props.autoplayError && <AutoplayMessage play={props.play} />}
<MediaForm {...props} />
</div>
)
})) }))
interface OptionsProps { interface OptionsProps {

View File

@ -12,6 +12,8 @@ describe('components/Video', () => {
stream: null | AddStreamPayload stream: null | AddStreamPayload
} }
const play = jest.fn()
class VideoWrapper extends React.PureComponent<VideoProps, VideoState> { class VideoWrapper extends React.PureComponent<VideoProps, VideoState> {
ref = React.createRef<Video>() ref = React.createRef<Video>()
@ -26,6 +28,7 @@ describe('components/Video', () => {
active={this.props.active} active={this.props.active}
stream={this.state.stream || this.props.stream} stream={this.state.stream || this.props.stream}
onClick={this.props.onClick} onClick={this.props.onClick}
play={this.props.play}
userId="test" userId="test"
muted={this.props.muted} muted={this.props.muted}
mirrored={this.props.mirrored} mirrored={this.props.mirrored}
@ -65,6 +68,7 @@ describe('components/Video', () => {
active={flags.active} active={flags.active}
stream={{ stream: mediaStream, url, userId: 'test' }} stream={{ stream: mediaStream, url, userId: 'test' }}
onClick={onClick} onClick={onClick}
play={play}
userId="test" userId="test"
muted={flags.muted} muted={flags.muted}
mirrored={flags.mirrored} mirrored={flags.mirrored}

View File

@ -11,6 +11,7 @@ export interface VideoProps {
userId: string userId: string
muted: boolean muted: boolean
mirrored: boolean mirrored: boolean
play: () => void
} }
export default class Video extends React.PureComponent<VideoProps> { export default class Video extends React.PureComponent<VideoProps> {
@ -22,13 +23,9 @@ export default class Video extends React.PureComponent<VideoProps> {
} }
handleClick: ReactEventHandler<HTMLVideoElement> = e => { handleClick: ReactEventHandler<HTMLVideoElement> = e => {
const { onClick, userId } = this.props const { onClick, userId } = this.props
this.play(e) this.props.play()
onClick(userId) onClick(userId)
} }
play: ReactEventHandler<HTMLVideoElement> = e => {
e.preventDefault();
(e.target as HTMLVideoElement).play()
}
componentDidMount () { componentDidMount () {
this.componentDidUpdate() this.componentDidUpdate()
} }
@ -55,7 +52,10 @@ export default class Video extends React.PureComponent<VideoProps> {
id={`video-${socket.id}`} id={`video-${socket.id}`}
autoPlay autoPlay
onClick={this.handleClick} onClick={this.handleClick}
onLoadedMetadata={this.play} onLoadedMetadata={() => {
console.log('onLoadedMetadata')
this.props.play()
}}
playsInline playsInline
ref={this.videoRef} ref={this.videoRef}
muted={muted} muted={muted}

View File

@ -19,6 +19,7 @@ export const MEDIA_ENUMERATE = 'MEDIA_ENUMERATE'
export const MEDIA_STREAM = 'MEDIA_STREAM' export const MEDIA_STREAM = 'MEDIA_STREAM'
export const MEDIA_VIDEO_CONSTRAINT_SET = 'MEDIA_VIDEO_CONSTRAINT_SET' export const MEDIA_VIDEO_CONSTRAINT_SET = 'MEDIA_VIDEO_CONSTRAINT_SET'
export const MEDIA_AUDIO_CONSTRAINT_SET = 'MEDIA_AUDIO_CONSTRAINT_SET' export const MEDIA_AUDIO_CONSTRAINT_SET = 'MEDIA_AUDIO_CONSTRAINT_SET'
export const MEDIA_PLAY = 'MEDIA_PLAY'
export const MEDIA_VISIBLE_SET = 'MEDIA_VISIBLE_SET' export const MEDIA_VISIBLE_SET = 'MEDIA_VISIBLE_SET'
export const PEER_ADD = 'PEER_ADD' export const PEER_ADD = 'PEER_ADD'

View File

@ -112,7 +112,8 @@ describe('App', () => {
dispatchSpy.mockReset() dispatchSpy.mockReset()
const video = node.querySelector('video')! const video = node.querySelector('video')!
TestUtils.Simulate.click(video) TestUtils.Simulate.click(video)
expect(dispatchSpy.mock.calls).toEqual([[{ expect(dispatchSpy.mock.calls[0][0].type).toBe(constants.MEDIA_PLAY)
expect(dispatchSpy.mock.calls.slice(1)).toEqual([[{
type: constants.ACTIVE_TOGGLE, type: constants.ACTIVE_TOGGLE,
payload: { userId: constants.ME }, payload: { userId: constants.ME },
}]]) }]])

View File

@ -1,10 +1,10 @@
import * as CallActions from '../actions/CallActions'
import * as NotifyActions from '../actions/NotifyActions'
import * as PeerActions from '../actions/PeerActions'
import * as StreamActions from '../actions/StreamActions'
import App from '../components/App'
import { bindActionCreators, Dispatch } from 'redux'
import { connect } from 'react-redux' import { connect } from 'react-redux'
import { init } from '../actions/CallActions'
import { play } from '../actions/MediaActions'
import { dismissAlert } from '../actions/NotifyActions'
import { sendFile, sendMessage } from '../actions/PeerActions'
import { toggleActive } from '../actions/StreamActions'
import App from '../components/App'
import { State } from '../store' import { State } from '../store'
function mapStateToProps (state: State) { function mapStateToProps (state: State) {
@ -19,14 +19,13 @@ function mapStateToProps (state: State) {
} }
} }
function mapDispatchToProps (dispatch: Dispatch) { const mapDispatchToProps = {
return { toggleActive,
toggleActive: bindActionCreators(StreamActions.toggleActive, dispatch), sendMessage,
sendMessage: bindActionCreators(PeerActions.sendMessage, dispatch), dismissAlert: dismissAlert,
dismissAlert: bindActionCreators(NotifyActions.dismissAlert, dispatch), init,
init: bindActionCreators(CallActions.init, dispatch), onSendFile: sendFile,
onSendFile: bindActionCreators(PeerActions.sendFile, dispatch), play,
}
} }
export default connect(mapStateToProps, mapDispatchToProps)(App) export default connect(mapStateToProps, mapDispatchToProps)(App)

View File

@ -5,7 +5,6 @@ import React from 'react'
import ReactDOM from 'react-dom' import ReactDOM from 'react-dom'
import store from './store' import store from './store'
import { Provider } from 'react-redux' import { Provider } from 'react-redux'
import { play } from './window'
const component = ( const component = (
<Provider store={store}> <Provider store={store}>
@ -14,4 +13,3 @@ const component = (
) )
ReactDOM.render(component, document.getElementById('container')) ReactDOM.render(component, document.getElementById('container'))
play()

View File

@ -1,5 +1,5 @@
import { MediaDevice, AudioConstraint, VideoConstraint, MediaAction, MediaEnumerateAction, MediaStreamAction } from '../actions/MediaActions' import { MediaDevice, AudioConstraint, VideoConstraint, MediaAction, MediaEnumerateAction, MediaStreamAction, MediaPlayAction } from '../actions/MediaActions'
import { MEDIA_ENUMERATE, MEDIA_AUDIO_CONSTRAINT_SET, MEDIA_VIDEO_CONSTRAINT_SET, MEDIA_VISIBLE_SET, MEDIA_STREAM } from '../constants' import { MEDIA_ENUMERATE, MEDIA_AUDIO_CONSTRAINT_SET, MEDIA_VIDEO_CONSTRAINT_SET, MEDIA_VISIBLE_SET, MEDIA_STREAM, MEDIA_PLAY } from '../constants'
export interface MediaState { export interface MediaState {
devices: MediaDevice[] devices: MediaDevice[]
@ -7,6 +7,7 @@ export interface MediaState {
audio: AudioConstraint audio: AudioConstraint
loading: boolean loading: boolean
error: string error: string
autoplayError: boolean
visible: boolean visible: boolean
} }
@ -16,6 +17,7 @@ const defaultState: MediaState = {
audio: true, audio: true,
loading: false, loading: false,
error: '', error: '',
autoplayError: false,
visible: true, visible: true,
} }
@ -65,6 +67,27 @@ export function handleMediaStream(
} }
} }
export function handlePlay(
state: MediaState,
action: MediaPlayAction,
): MediaState {
switch (action.status) {
case 'pending':
case 'resolved':
return {
...state,
autoplayError: false,
}
case 'rejected':
return {
...state,
autoplayError: true,
}
default:
return state
}
}
export default function media( export default function media(
state = defaultState, state = defaultState,
action: MediaAction, action: MediaAction,
@ -89,6 +112,8 @@ export default function media(
} }
case MEDIA_STREAM: case MEDIA_STREAM:
return handleMediaStream(state, action) return handleMediaStream(state, action)
case MEDIA_PLAY:
return handlePlay(state, action)
default: default:
return state return state
} }

View File

@ -1,39 +1,7 @@
import { createObjectURL, play, revokeObjectURL, valueOf } from './window' import { createObjectURL, revokeObjectURL, valueOf } from './window'
describe('window', () => { describe('window', () => {
describe('play', () => {
let v1: HTMLVideoElement & { play: jest.Mock }
let v2: HTMLVideoElement & { play: jest.Mock }
beforeEach(() => {
v1 = window.document.createElement('video') as any
v2 = window.document.createElement('video') as any
window.document.body.appendChild(v1)
window.document.body.appendChild(v2)
v1.play = jest.fn()
v2.play = jest.fn()
})
afterEach(() => {
window.document.body.removeChild(v1)
window.document.body.removeChild(v2)
})
it('gets all videos and plays them', () => {
play()
expect(v1.play.mock.calls.length).toBe(1)
expect(v2.play.mock.calls.length).toBe(1)
})
it('does not fail on error', () => {
v1.play.mockImplementation(() => { throw new Error('test') })
play()
expect(v1.play.mock.calls.length).toBe(1)
expect(v2.play.mock.calls.length).toBe(1)
})
})
describe('navigator', () => { describe('navigator', () => {
it('exposes window.navigator', () => { it('exposes window.navigator', () => {

View File

@ -1,23 +1,7 @@
import _debug from 'debug'
const debug = _debug('peercalls')
export const createObjectURL = (object: unknown) => export const createObjectURL = (object: unknown) =>
window.URL.createObjectURL(object) window.URL.createObjectURL(object)
export const revokeObjectURL = (url: string) => window.URL.revokeObjectURL(url) export const revokeObjectURL = (url: string) => window.URL.revokeObjectURL(url)
export function play () {
const videos = window.document.querySelectorAll('video')
Array.prototype.forEach.call(videos, (video, index) => {
debug('playing video: %s', index)
try {
video.play()
} catch (e) {
debug('error playing video: %s', e.name)
}
})
}
export const valueOf = (id: string) => { export const valueOf = (id: string) => {
const el = window.document.getElementById(id) as HTMLInputElement const el = window.document.getElementById(id) as HTMLInputElement
return el && el.value return el && el.value

View File

@ -1,4 +1,21 @@
form.media { .media-container .autoplay {
background-color: rgba(0, 0, 0, 0.3);
left: 0;
right: 0;
top: 0;
z-index: 1000;
position: absolute;
color: $color-warning;
text-align: center;
padding: 0.5rem;
button {
padding: 0.5rem;
@include button($color-primary, $color-warning);
}
}
.media-container form.media {
max-width: 440px; max-width: 440px;
text-align: center; text-align: center;
position: absolute; position: absolute;
@ -29,13 +46,5 @@ form.media {
button { button {
@include button($color-primary, $color-warning); @include button($color-primary, $color-warning);
padding: 1rem; padding: 1rem;
&:hover {
@include button($color-primary, darken($color-warning, 5%));
}
&:active {
@include button($color-primary, darken($color-warning, 10%));
}
} }
} }

View File

@ -49,7 +49,7 @@ body.call {
border: 0; border: 0;
} }
@mixin button($color-fg, $color-bg) { @mixin button-style($color-fg, $color-bg) {
background-color: $color-bg; background-color: $color-bg;
border: none; border: none;
border-bottom: 2px solid darken($color-bg, 10%); border-bottom: 2px solid darken($color-bg, 10%);
@ -60,6 +60,22 @@ body.call {
text-shadow: 0 0 0.35rem rgba(0, 0, 0, 0.6); text-shadow: 0 0 0.35rem rgba(0, 0, 0, 0.6);
} }
@mixin button($color-fg, $color-bg) {
@include button-style($color-fg, $color-bg);
cursor: pointer;
&:hover {
outline: none;
@include button-style($color-fg, darken($color-bg, 5%));
}
&:active {
outline: none;
transform: translate(0px, 1px);
@include button-style($color-fg, darken($color-bg, 10%));
}
}
#form { #form {
padding-top: 50px; padding-top: 50px;
text-align: center; text-align: center;
@ -86,24 +102,6 @@ body.call {
padding: 1rem 1rem; padding: 1rem 1rem;
} }
input:hover {
@include button($color-primary, darken($color-warning, 5%));
}
input:active {
transform: translate(0px, 1px);
@include button($color-primary, darken($color-warning, 10%));
}
input:active,
input:focus {
outline: none;
}
input[type="submit"] {
cursor: pointer;
}
::-webkit-input-placeholder { ::-webkit-input-placeholder {
color: $color-fg; color: $color-fg;
text-align: center; text-align: center;