Skip to content

Commit

Permalink
Merge pull request #11936 from nextcloud/fix/9902/preffered-media-device
Browse files Browse the repository at this point in the history
feat(MediaSettings): preferred media devices selection 🎤📹
  • Loading branch information
Antreesy authored Apr 11, 2024
2 parents 7adbb1f + 1898fd9 commit f458624
Show file tree
Hide file tree
Showing 9 changed files with 325 additions and 14 deletions.
2 changes: 1 addition & 1 deletion src/components/CallView/shared/LocalAudioControlButton.vue
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ export default {
methods: {
toggleAudio() {
if (!this.model.attributes.audioAvailable) {
emit('show-settings', {})
emit('talk:media-settings:show')
return
}

Expand Down
2 changes: 1 addition & 1 deletion src/components/CallView/shared/LocalVideoControlButton.vue
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ export default {
}

if (!this.model.attributes.videoAvailable) {
emit('show-settings', {})
emit('talk:media-settings:show')
return
}

Expand Down
13 changes: 12 additions & 1 deletion src/components/MediaSettings/MediaDevicesSelector.vue
Original file line number Diff line number Diff line change
Expand Up @@ -37,22 +37,33 @@
:clearable="false"
:placeholder="deviceSelectorPlaceholder"
:disabled="!enabled || !deviceOptionsAvailable" />

<NcButton type="tertiary-no-background"
:title="t('spreed', 'Refresh devices list')"
:aria-lebel="t('spreed', 'Refresh devices list')"
@click="$emit('refresh')">
<RefreshIcon :size="20" />
</NcButton>
</div>
</template>

<script>
import Microphone from 'vue-material-design-icons/Microphone.vue'
import RefreshIcon from 'vue-material-design-icons/Refresh.vue'
import VideoIcon from 'vue-material-design-icons/Video.vue'

import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js'

export default {

name: 'MediaDevicesSelector',

components: {
NcButton,
NcSelect,
Microphone,
RefreshIcon,
VideoIcon,
},

Expand All @@ -77,7 +88,7 @@ export default {
},
},

emits: ['update:deviceId'],
emits: ['refresh', 'update:deviceId'],

data() {
return {
Expand Down
8 changes: 8 additions & 0 deletions src/components/MediaSettings/MediaSettings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -107,10 +107,12 @@
<MediaDevicesSelector kind="audioinput"
:devices="devices"
:device-id="audioInputId"
@refresh="updateDevices"
@update:deviceId="audioInputId = $event" />
<MediaDevicesSelector kind="videoinput"
:devices="devices"
:device-id="videoInputId"
@refresh="updateDevices"
@update:deviceId="videoInputId = $event" />
<MediaDevicesSpeakerTest />
</div>
Expand Down Expand Up @@ -282,6 +284,8 @@ export default {

const {
devices,
updateDevices,
updatePreferences,
currentVolume,
currentThreshold,
audioPreviewAvailable,
Expand All @@ -301,6 +305,8 @@ export default {
video,
// useDevices
devices,
updateDevices,
updatePreferences,
currentVolume,
currentThreshold,
audioPreviewAvailable,
Expand Down Expand Up @@ -549,6 +555,8 @@ export default {
if (this.videoDeviceStateChanged) {
emit('local-video-control-button:toggle-video')
}

this.updatePreferences()
this.closeModal()
},

Expand Down
4 changes: 4 additions & 0 deletions src/components/SettingsDialog/MediaDevicesPreview.vue
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
<MediaDevicesSelector kind="audioinput"
:devices="devices"
:device-id="audioInputId"
@refresh="updateDevices"
@update:deviceId="audioInputId = $event" />
<div class="preview preview-audio">
<div v-if="!audioPreviewAvailable"
Expand Down Expand Up @@ -51,6 +52,7 @@
<MediaDevicesSelector kind="videoinput"
:devices="devices"
:device-id="videoInputId"
@refresh="updateDevices"
@update:deviceId="videoInputId = $event" />
<div class="preview preview-video">
<div v-if="!videoPreviewAvailable"
Expand Down Expand Up @@ -107,6 +109,7 @@ export default {
const video = ref(null)
const {
devices,
updateDevices,
currentVolume,
currentThreshold,
audioPreviewAvailable,
Expand All @@ -122,6 +125,7 @@ export default {
return {
video,
devices,
updateDevices,
currentVolume,
currentThreshold,
audioPreviewAvailable,
Expand Down
20 changes: 20 additions & 0 deletions src/composables/useDevices.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ export function useDevices(video, initializeOnMounted) {
const videoTrackToStream = ref(null)
const mediaDevicesManager = reactive(mediaDevicesManagerInstance)

window.OCA.Talk.mediaDevicesManager = mediaDevicesManagerInstance

// Public refs
const currentVolume = ref(-100)
const currentThreshold = ref(-100)
Expand Down Expand Up @@ -161,6 +163,22 @@ export function useDevices(video, initializeOnMounted) {
updateVideoStream()
}

/**
* Force enumerate devices (audio and video)
* @public
*/
function updateDevices() {
mediaDevicesManager._updateDevices()
}

/**
* Update preference counters for devices (audio and video)
* @public
*/
function updatePreferences() {
mediaDevicesManager.updatePreferences()
}

/**
* Stop tracking device events (audio and video)
* @public
Expand Down Expand Up @@ -369,6 +387,7 @@ export function useDevices(video, initializeOnMounted) {

return {
devices,
updateDevices,
currentVolume,
currentThreshold,
audioPreviewAvailable,
Expand All @@ -382,6 +401,7 @@ export function useDevices(video, initializeOnMounted) {
videoStreamError,
// MediaSettings only
initializeDevices,
updatePreferences,
stopDevices,
virtualBackground,
}
Expand Down
202 changes: 202 additions & 0 deletions src/services/mediaDevicePreferences.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
/**
* @copyright Copyright (c) 2024 Maksim Sukharev <antreesy.web@gmail.com>
*
* @author Maksim Sukharev <antreesy.web@gmail.com>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/

/**
* List all registered devices in order of their preferences
* Show whether device is currently unplugged or selected, if information is available
*
* @param devices list of available devices
* @param audioInputId id of currently selected audio input
* @param videoInputId id of currently selected video input
* @param audioInputList list of registered audio devices in order of preference
* @param videoInputList list of registered video devices in order of preference
*/
function listMediaDevices(devices: MediaDeviceInfo[], audioInputId: string, videoInputId: string, audioInputList: MediaDeviceInfo[], videoInputList: MediaDeviceInfo[]): void {
const availableDevices = devices.map(device => device.deviceId).filter(id => id !== 'default')

const getDeviceString = (device: MediaDeviceInfo, index: number) => {
const isUnplugged = !availableDevices.includes(device.deviceId) ? ' (unplugged)' : ''
const isSelected = () => {
if (device.kind === 'audioinput') {
return device.deviceId === audioInputId ? ' (selected)' : ''
} else if (device.kind === 'videoinput') {
return device.deviceId === videoInputId ? ' (selected)' : ''
}
}
return ` ${index + 1}. ${device.label} | ${device.deviceId}` + isUnplugged + isSelected()
}

// eslint-disable-next-line no-console
console.log(`Media devices:
Audio input:
${audioInputList.map(getDeviceString).join('\n')}
Video input:
${videoInputList.map(getDeviceString).join('\n')}
`)
}

/**
* Get the first available device from the preference list.
*
* Returns id of device from the list / provided fallback id / 'default' id
*
* @param devices list of available devices
* @param inputList list of registered audio/video devices in order of preference
* @param [fallbackId] id of currently selected input
* @return first available (plugged) device id or fallback
*/
function getFirstAvailableMediaDevice(devices: MediaDeviceInfo[], inputList: MediaDeviceInfo[], fallbackId?: string): string | undefined {
const availableDevices = devices.map(device => device.deviceId).filter(id => id !== 'default')
return inputList.find(device => availableDevices.includes(device.deviceId))?.deviceId ?? fallbackId
}

/**
* Modify devices list.
*
* @param device device
* @param devicesList list of registered devices in order of preference
* @param promote whether device should be promoted (to be used in updateMediaDevicesPreferences)
* @return updated devices list
*/
function registerNewMediaDevice(device: MediaDeviceInfo, devicesList: MediaDeviceInfo[], promote: boolean = false): MediaDeviceInfo[] {
const newDevicesList = devicesList.slice()
console.debug('Registering new device:', device)

if (promote) {
newDevicesList.unshift(device)
} else {
newDevicesList.push(device)
}

return newDevicesList
}

/**
* Promote device in the preference list.
* Regardless if new unknown or registered device was selected, we promote it in the list:
* - if first item is plugged, then we prefer device out of all options and put it on the first place;
* - if first item is unplugged, then we don't consider it, and compare with the next in the list;
* - if second item is unplugged, compare with next in the list;
* - ...
* - if N-th item is plugged, then we prefer device to it and put it on the Nth place.
*
* Returns changed preference lists for audio / video devices (null, if it hasn't been changed)
*
* @param devices list of available devices
* @param inputList list of registered audio/video devices in order of preference
* @param inputId id of currently selected input
* @return updated devices list (null, if it has not been changed)
*/
function promoteMediaDevice(devices: MediaDeviceInfo[], inputList: MediaDeviceInfo[], inputId: string | null) {
const newInputList = inputList.slice()

// Get the index of the first plugged device
const availableDevices = devices.map(device => device.deviceId).filter(id => id !== 'default')
const firstPluggedIndex = newInputList.findIndex(device => availableDevices.includes(device.deviceId))
const insertPosition = firstPluggedIndex === -1 ? newInputList.length : firstPluggedIndex

// Get the index of the currently selected device
const currentDevicePosition = newInputList.findIndex(device => device.deviceId === inputId)

if (currentDevicePosition === insertPosition) {
// preferences list is unchanged
return null
}

let deviceToPromote = null
if (currentDevicePosition === -1 && inputId !== 'default' && inputId !== null) {
// If device was not registered in preferences list, get it from devices list
deviceToPromote = devices.find(device => device.deviceId === inputId)
} else if (currentDevicePosition > 0) {
// Otherwise extract it from preferences list
deviceToPromote = newInputList.splice(currentDevicePosition, 1)[0]
}

if (deviceToPromote) {
// Put the device at the new position
newInputList.splice(insertPosition, 0, deviceToPromote)
return newInputList
} else {
return null
}
}

/**
* Populate devices preferences. If device has not yet been registered in preference list, it will be added.
*
* Returns changed preference lists for audio / video devices (null, if it hasn't been changed)
*
* @param devices list of available devices
* @param audioInputList list of registered audio devices in order of preference
* @param videoInputList list of registered video devices in order of preference
* @return object with updated devices lists (null, if they have not been changed)
*/
function populateMediaDevicesPreferences(devices: MediaDeviceInfo[], audioInputList: MediaDeviceInfo[], videoInputList: MediaDeviceInfo[]) {
let newAudioInputList = null
let newVideoInputList = null

for (const device of devices) {
if (device.kind === 'audioinput') {
// Add to the list of known devices
if (device.deviceId !== 'default' && !audioInputList.some(input => input.deviceId === device.deviceId)) {
newAudioInputList = registerNewMediaDevice(device, audioInputList)
}
} else if (device.kind === 'videoinput') {
// Add to the list of known devices
if (device.deviceId !== 'default' && !videoInputList.some(input => input.deviceId === device.deviceId)) {
newVideoInputList = registerNewMediaDevice(device, videoInputList)
}
}
}

return {
newAudioInputList,
newVideoInputList,
}
}

/**
* Update devices preferences. Assuming that preferred devices were selected, should be called after applying the selection:
* so either with joining the call or changing device during the call
*
* Returns changed preference lists for audio / video devices (null, if it hasn't been changed)
*
* @param devices list of available devices
* @param audioInputId id of currently selected audio input
* @param videoInputId id of currently selected video input
* @param audioInputList list of registered audio devices in order of preference
* @param videoInputList list of registered video devices in order of preference
*/
function updateMediaDevicesPreferences(devices: MediaDeviceInfo[], audioInputId: string, videoInputId: string, audioInputList: MediaDeviceInfo[], videoInputList: MediaDeviceInfo[]) {
return {
newAudioInputList: promoteMediaDevice(devices, audioInputList, audioInputId),
newVideoInputList: promoteMediaDevice(devices, videoInputList, videoInputId),
}
}

export {
getFirstAvailableMediaDevice,
listMediaDevices,
populateMediaDevicesPreferences,
updateMediaDevicesPreferences,
}
2 changes: 1 addition & 1 deletion src/store/conversationsStore.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ jest.mock('../services/messagesService', () => ({
jest.mock('@nextcloud/event-bus')

jest.mock('../services/BrowserStorage.js', () => ({
getItem: jest.fn(),
getItem: jest.fn().mockReturnValue(null),
setItem: jest.fn(),
removeItem: jest.fn(),
}))
Expand Down
Loading

0 comments on commit f458624

Please sign in to comment.