Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 15 additions & 5 deletions app/components/App/BackgroundNotifier.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import neutralSoundFileURL from '../../assets/sounds/background_statement_neutra
import refuteSoundFileURL from '../../assets/sounds/background_statement_refute.mp3'
import { useFocusedStatement } from '../../contexts/FocusedStatementContext'
import { useUserPreferences } from '../../contexts/UserPreferencesContext'
import { useVideoPlayback } from '../../contexts/VideoPlaybackContext'
import { isStatementConfirmed } from '../../lib/statements_utils'

const confirmAudioFile = new Audio(confirmSoundFileURL)
Expand All @@ -27,6 +28,7 @@ const setFavicon = (value) => {
const BackgroundNotifier = () => {
const { statement } = useFocusedStatement()
const { enableSoundOnBackgroundFocus: soundEnabled } = useUserPreferences()
const { volume } = useVideoPlayback()
const prevFocusedStatementIdRef = useRef(-1)
const prevSoundEnabledRef = useRef(soundEnabled)

Expand All @@ -37,6 +39,14 @@ const BackgroundNotifier = () => {
setFavicon(null)
}, [])

const playNotificationSound = useCallback(
(audioFile) => {
audioFile.volume = volume
audioFile.play()
},
[volume],
)

// Initialize Tinycon options
useEffect(() => {
Tinycon.setOptions({
Expand All @@ -59,7 +69,7 @@ const BackgroundNotifier = () => {
useEffect(() => {
// Play a sound when enabling setting
if (!prevSoundEnabledRef.current && soundEnabled) {
neutralAudioFile.play()
playNotificationSound(neutralAudioFile)
prevSoundEnabledRef.current = soundEnabled
return
}
Expand All @@ -80,18 +90,18 @@ const BackgroundNotifier = () => {
if (soundEnabled) {
const confirmed = isStatementConfirmed(comments)
if (confirmed === null) {
neutralAudioFile.play()
playNotificationSound(neutralAudioFile)
} else if (confirmed) {
confirmAudioFile.play()
playNotificationSound(confirmAudioFile)
} else {
refuteAudioFile.play()
playNotificationSound(refuteAudioFile)
}
}
}

// Always update the ref at the end
prevFocusedStatementIdRef.current = focusedStatementId
}, [focusedStatementId, soundEnabled, comments, setFavicon])
}, [focusedStatementId, soundEnabled, comments, playNotificationSound])

return null
}
Expand Down
15 changes: 13 additions & 2 deletions app/components/VideoDebate/VideoDebatePlayer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,24 @@ import React, { useEffect, useRef } from 'react'
import ReactPlayer from 'react-player'

import { useVideoPlayback } from '../../contexts/VideoPlaybackContext'
import { getReactPlayerVolume } from '../../lib/player_volume'

/**
* A player component with local state for position/playing.
* Updates position when playing and seeks to position when requested.
*/
const VideoDebatePlayer = ({ url }) => {
const { forcedPosition, isPlaying, setPosition, setPlaying } = useVideoPlayback()
const { forcedPosition, isPlaying, setPosition, setPlaying, setVolume } = useVideoPlayback()
const playerRef = useRef(null)
const prevForcedPositionRef = useRef(null)

const updateVolumeFromPlayer = () => {
const volume = getReactPlayerVolume(playerRef.current)
if (volume !== null) {
setVolume(volume)
}
}

useEffect(() => {
if (
playerRef.current &&
Expand All @@ -32,7 +40,10 @@ const VideoDebatePlayer = ({ url }) => {
playing={isPlaying}
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onProgress={({ playedSeconds }) => setPosition(playedSeconds)}
onProgress={({ playedSeconds }) => {
setPosition(playedSeconds)
updateVolumeFromPlayer()
}}
width=""
height=""
controls
Expand Down
31 changes: 30 additions & 1 deletion app/contexts/VideoPlaybackContext.jsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import React, { createContext, useCallback, useContext, useMemo, useReducer, useRef } from 'react'

import { DEFAULT_PLAYER_VOLUME, normalizePlayerVolume } from '../lib/player_volume'

const initialState = {
position: 0,
isPlaying: false,
volume: DEFAULT_PLAYER_VOLUME,
forcedPosition: { requestId: null, time: 0 },
}

Expand All @@ -18,6 +21,11 @@ const playbackReducer = (state, action) => {
...state,
isPlaying: action.payload,
}
case 'SET_VOLUME':
return {
...state,
volume: action.payload,
}
case 'FORCE_POSITION':
return {
...state,
Expand Down Expand Up @@ -56,6 +64,16 @@ export const VideoPlaybackProvider = ({ children, onUpdatePosition }) => {
dispatch({ type: 'SET_PLAYING', payload: isPlaying })
}, [])

const setVolume = useCallback(
(volume) => {
const normalizedVolume = normalizePlayerVolume(volume)
if (normalizedVolume !== null && normalizedVolume !== state.volume) {
dispatch({ type: 'SET_VOLUME', payload: normalizedVolume })
}
},
[state.volume],
)

const forcePosition = useCallback((time) => {
dispatch({ type: 'FORCE_POSITION', payload: time })
}, [])
Expand All @@ -64,12 +82,23 @@ export const VideoPlaybackProvider = ({ children, onUpdatePosition }) => {
() => ({
position: state.position,
isPlaying: state.isPlaying,
volume: state.volume,
forcedPosition: state.forcedPosition,
setPosition,
setPlaying,
setVolume,
forcePosition,
}),
[state.position, state.isPlaying, state.forcedPosition, setPosition, setPlaying, forcePosition],
[
state.position,
state.isPlaying,
state.volume,
state.forcedPosition,
setPosition,
setPlaying,
setVolume,
forcePosition,
],
)

return <VideoPlaybackContext.Provider value={value}>{children}</VideoPlaybackContext.Provider>
Expand Down
78 changes: 78 additions & 0 deletions app/lib/__tests__/player_volume.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import {
getReactPlayerVolume,
normalizePercentagePlayerVolume,
normalizePlayerVolume,
} from '../player_volume'

describe('normalizePlayerVolume', () => {
it('keeps html media volumes in the 0-1 range', () => {
expect(normalizePlayerVolume(0)).toBe(0)
expect(normalizePlayerVolume(0.35)).toBe(0.35)
expect(normalizePlayerVolume(1)).toBe(1)
})

it('ignores invalid values and clamps out-of-range values', () => {
expect(normalizePlayerVolume(undefined)).toBe(null)
expect(normalizePlayerVolume('quiet')).toBe(null)
expect(normalizePlayerVolume(-0.5)).toBe(0)
expect(normalizePlayerVolume(1.5)).toBe(1)
})
})

describe('normalizePercentagePlayerVolume', () => {
it('converts player volumes in the 0-100 range', () => {
expect(normalizePercentagePlayerVolume(1)).toBe(0.01)
expect(normalizePercentagePlayerVolume(35)).toBe(0.35)
expect(normalizePercentagePlayerVolume(100)).toBe(1)
})

it('ignores invalid values and clamps out-of-range values', () => {
expect(normalizePercentagePlayerVolume(undefined)).toBe(null)
expect(normalizePercentagePlayerVolume('quiet')).toBe(null)
expect(normalizePercentagePlayerVolume(-50)).toBe(0)
expect(normalizePercentagePlayerVolume(150)).toBe(1)
})
})

describe('getReactPlayerVolume', () => {
it('reads YouTube-style getVolume players', () => {
const player = { getInternalPlayer: () => ({ getVolume: () => 42 }) }

expect(getReactPlayerVolume(player)).toBe(0.42)
})

it('treats low YouTube-style getVolume values as percentages', () => {
const player = { getInternalPlayer: () => ({ getVolume: () => 1 }) }

expect(getReactPlayerVolume(player)).toBe(0.01)
})

it('reads html media volume properties', () => {
const player = { getInternalPlayer: () => ({ volume: 0.65 }) }

expect(getReactPlayerVolume(player)).toBe(0.65)
})

it('honors muted player state', () => {
expect(
getReactPlayerVolume({
getInternalPlayer: () => ({ getVolume: () => 42, isMuted: () => true }),
}),
).toBe(0)
expect(getReactPlayerVolume({ getInternalPlayer: () => ({ muted: true, volume: 0.65 }) })).toBe(
0,
)
})

it('returns null when volume cannot be read', () => {
expect(getReactPlayerVolume(null)).toBe(null)
expect(getReactPlayerVolume({ getInternalPlayer: () => ({}) })).toBe(null)
expect(
getReactPlayerVolume({
getInternalPlayer: () => {
throw new Error('not ready')
},
}),
).toBe(null)
})
})
54 changes: 54 additions & 0 deletions app/lib/player_volume.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
export const DEFAULT_PLAYER_VOLUME = 1

const clampNormalizedVolume = (volume) => Math.max(0, Math.min(1, volume))

export const normalizePlayerVolume = (volume) => {
const numericVolume = Number(volume)
if (!Number.isFinite(numericVolume)) {
return null
}

return clampNormalizedVolume(numericVolume)
}

export const normalizePercentagePlayerVolume = (volume) => {
const numericVolume = Number(volume)
if (!Number.isFinite(numericVolume)) {
return null
}

return clampNormalizedVolume(numericVolume / 100)
}

const isPlayerMuted = (internalPlayer) => {
if (typeof internalPlayer.isMuted === 'function') {
return internalPlayer.isMuted()
}

return internalPlayer.muted === true
}

export const getReactPlayerVolume = (player) => {
try {
const internalPlayer = player?.getInternalPlayer?.()
if (!internalPlayer) {
return null
}

if (isPlayerMuted(internalPlayer)) {
return 0
}

if (typeof internalPlayer.getVolume === 'function') {
return normalizePercentagePlayerVolume(internalPlayer.getVolume())
}

if (typeof internalPlayer.volume !== 'undefined') {
return normalizePlayerVolume(internalPlayer.volume)
}
} catch {
return null
}

return null
}