Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(MediaSettings): preferred media devices selection πŸŽ€πŸ“Ή #11936

Merged
merged 5 commits into from
Apr 11, 2024
Merged
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
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')}
`)
Comment on lines +48 to +55
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do not forget to remove the debug log :P

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's needed to list devices in the console (so user can pass some information to admin, e.g)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Then it should not be log

}

/**
* 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

Check warning on line 66 in src/services/mediaDevicePreferences.ts

View workflow job for this annotation

GitHub Actions / NPM lint

Missing JSDoc @return type
*/
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

Check warning on line 79 in src/services/mediaDevicePreferences.ts

View workflow job for this annotation

GitHub Actions / NPM lint

Missing JSDoc @return type
*/
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)
Antreesy marked this conversation as resolved.
Show resolved Hide resolved
*
* @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)

Check warning on line 108 in src/services/mediaDevicePreferences.ts

View workflow job for this annotation

GitHub Actions / NPM lint

Missing JSDoc @return type
*/
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)
Antreesy marked this conversation as resolved.
Show resolved Hide resolved
*
* @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)

Check warning on line 152 in src/services/mediaDevicePreferences.ts

View workflow job for this annotation

GitHub Actions / NPM lint

Missing JSDoc @return type
*/
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
Loading