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>> =>
async (dispatch, getState) => {
const socket = await dispatch(connect())
// const stream = await dispatch(getCameraStream())
dispatch(SocketActions.handshake({
socket,
roomName: callId,
// stream,
}))
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(
MEDIA_STREAM,
async (constraints: GetMediaConstraints) => getUserMedia(constraints),
@ -110,10 +122,12 @@ export const getMediaStream = makeAction(
export type MediaEnumerateAction = AsyncAction<'MEDIA_ENUMERATE', MediaDevice[]>
export type MediaStreamAction = AsyncAction<'MEDIA_STREAM', MediaStream>
export type MediaPlayAction = AsyncAction<'MEDIA_PLAY', void>
export type MediaAction =
MediaVideoConstraintAction |
MediaAudioConstraintAction |
MediaEnumerateAction |
MediaStreamAction |
MediaVisibleAction
MediaVisibleAction |
MediaPlayAction

View File

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

View File

@ -5,7 +5,7 @@ import * as constants from '../constants'
import Peer, { SignalData } from 'simple-peer'
import forEach from 'lodash/forEach'
import _debug from 'debug'
import { play, iceServers } from '../window'
import { iceServers } from '../window'
import { Dispatch, GetState } from '../store'
import { ClientSocket } from '../socket'
@ -50,10 +50,19 @@ class PeerHandler {
socket.emit('signal', payload)
}
handleConnect = () => {
const { dispatch, user } = this
const { dispatch, user, getState } = this
debug('peer: %s, connect', user.id)
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) => {
const { user, dispatch } = this

View File

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

View File

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

View File

@ -1,6 +1,6 @@
import React from 'react'
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 { State } from '../store'
@ -9,6 +9,7 @@ export type MediaProps = MediaState & {
onSetVideoConstraint: typeof setVideoConstraint
onSetAudioConstraint: typeof setAudioConstraint
getMediaStream: typeof getMediaStream
play: typeof play
}
function mapStateToProps(state: State) {
@ -22,11 +23,12 @@ const mapDispatchToProps = {
onSetVideoConstraint: setVideoConstraint,
onSetAudioConstraint: setAudioConstraint,
getMediaStream,
play,
}
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) {
return null
}
@ -88,6 +90,34 @@ export const Media = c(React.memo(function Media(props: MediaProps) {
</button>
</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 {

View File

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

View File

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

View File

@ -19,6 +19,7 @@ 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 MEDIA_PLAY = 'MEDIA_PLAY'
export const MEDIA_VISIBLE_SET = 'MEDIA_VISIBLE_SET'
export const PEER_ADD = 'PEER_ADD'

View File

@ -112,7 +112,8 @@ describe('App', () => {
dispatchSpy.mockReset()
const video = node.querySelector('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,
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 { 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'
function mapStateToProps (state: State) {
@ -19,14 +19,13 @@ function mapStateToProps (state: State) {
}
}
function mapDispatchToProps (dispatch: Dispatch) {
return {
toggleActive: bindActionCreators(StreamActions.toggleActive, dispatch),
sendMessage: bindActionCreators(PeerActions.sendMessage, dispatch),
dismissAlert: bindActionCreators(NotifyActions.dismissAlert, dispatch),
init: bindActionCreators(CallActions.init, dispatch),
onSendFile: bindActionCreators(PeerActions.sendFile, dispatch),
}
const mapDispatchToProps = {
toggleActive,
sendMessage,
dismissAlert: dismissAlert,
init,
onSendFile: sendFile,
play,
}
export default connect(mapStateToProps, mapDispatchToProps)(App)

View File

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

View File

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

View File

@ -1,39 +1,7 @@
import { createObjectURL, play, revokeObjectURL, valueOf } from './window'
import { createObjectURL, revokeObjectURL, valueOf } from './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', () => {
it('exposes window.navigator', () => {

View File

@ -1,23 +1,7 @@
import _debug from 'debug'
const debug = _debug('peercalls')
export const createObjectURL = (object: unknown) =>
window.URL.createObjectURL(object)
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) => {
const el = window.document.getElementById(id) as HTMLInputElement
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;
text-align: center;
position: absolute;
@ -29,13 +46,5 @@ form.media {
button {
@include button($color-primary, $color-warning);
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;
}
@mixin button($color-fg, $color-bg) {
@mixin button-style($color-fg, $color-bg) {
background-color: $color-bg;
border: none;
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);
}
@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 {
padding-top: 50px;
text-align: center;
@ -86,24 +102,6 @@ body.call {
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 {
color: $color-fg;
text-align: center;