diff --git a/src/BootstrapBlazor.Server/Components/Samples/AudioDevices.razor b/src/BootstrapBlazor.Server/Components/Samples/AudioDevices.razor new file mode 100644 index 00000000000..7462a9f529c --- /dev/null +++ b/src/BootstrapBlazor.Server/Components/Samples/AudioDevices.razor @@ -0,0 +1,29 @@ +@page "/audio-device" +@inject IStringLocalizer Localizer + +

@Localizer["AudioDeviceTitle"]

+ +

@Localizer["AudioDeviceIntro"]

+ +
[Inject, NotNull]
+private IAudioDevice? AudioDeviceService { get; set; }
+ + +
+
+
+ + + +
+
+
+ +
+
+ + +
+ diff --git a/src/BootstrapBlazor.Server/Components/Samples/AudioDevices.razor.cs b/src/BootstrapBlazor.Server/Components/Samples/AudioDevices.razor.cs new file mode 100644 index 00000000000..11d84ce3788 --- /dev/null +++ b/src/BootstrapBlazor.Server/Components/Samples/AudioDevices.razor.cs @@ -0,0 +1,74 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License +// See the LICENSE file in the project root for more information. +// Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone + +namespace BootstrapBlazor.Server.Components.Samples; + +/// +/// AudioDevice Component +/// +public partial class AudioDevices : IAsyncDisposable +{ + [Inject, NotNull] + private IAudioDevice? AudioDeviceService { get; set; } + + private readonly List _devices = []; + + private List _items = []; + + private string? _deviceId; + + private bool _isOpen = false; + + private async Task OnRequestDevice() + { + var devices = await AudioDeviceService.GetDevices(); + if (devices != null) + { + _devices.Clear(); + _devices.AddRange(devices); + _items = [.. _devices.Select(i => new SelectedItem(i.DeviceId, i.Label))]; + + _deviceId = _items.FirstOrDefault()?.Value; + } + } + + private async Task OnOpen() + { + if (!string.IsNullOrEmpty(_deviceId)) + { + var constraints = new MediaTrackConstraints + { + DeviceId = _deviceId, + Selector = ".bb-audio" + }; + _isOpen = await AudioDeviceService.Open(constraints); + } + } + + private async Task OnClose() + { + _isOpen = false; + await AudioDeviceService.Close(".bb-audio"); + } + + private async Task DisposeAsync(bool disposing) + { + if (disposing) + { + await OnClose(); + } + } + + /// + /// + /// + /// + /// + public async ValueTask DisposeAsync() + { + await DisposeAsync(true); + GC.SuppressFinalize(this); + } +} diff --git a/src/BootstrapBlazor.Server/Components/Samples/AudioDevices.razor.css b/src/BootstrapBlazor.Server/Components/Samples/AudioDevices.razor.css new file mode 100644 index 00000000000..71dcb65c089 --- /dev/null +++ b/src/BootstrapBlazor.Server/Components/Samples/AudioDevices.razor.css @@ -0,0 +1,10 @@ +.bb-actions { + display: flex; + flex-wrap: wrap; + gap: .5rem .5rem; +} + +.bb-audio { + margin-top: 1rem; + width: 100%; +} diff --git a/src/BootstrapBlazor.Server/Components/Samples/VideoDevices.razor b/src/BootstrapBlazor.Server/Components/Samples/VideoDevices.razor index 5b611f33b92..8cf435af1fc 100644 --- a/src/BootstrapBlazor.Server/Components/Samples/VideoDevices.razor +++ b/src/BootstrapBlazor.Server/Components/Samples/VideoDevices.razor @@ -6,7 +6,7 @@

@Localizer["VideoDeviceIntro"]

[Inject, NotNull]
-private IBluetooth? BluetoothService { get; set; }
+private IVideoDevice? VideoDeviceService { get; set; } VideoDeviceService.Apply(new MediaTrackConstraints() { Width = width, Height = height }); + private async Task OnApply(int width, int height) => await VideoDeviceService.Apply(new MediaTrackConstraints() { Width = width, Height = height }); private async Task DisposeAsync(bool disposing) { diff --git a/src/BootstrapBlazor.Server/Extensions/MenusLocalizerExtensions.cs b/src/BootstrapBlazor.Server/Extensions/MenusLocalizerExtensions.cs index f293b1a6dd3..d4c98de9bec 100644 --- a/src/BootstrapBlazor.Server/Extensions/MenusLocalizerExtensions.cs +++ b/src/BootstrapBlazor.Server/Extensions/MenusLocalizerExtensions.cs @@ -1551,7 +1551,13 @@ void AddServices(DemoMenuItem item) new() { IsNew = true, - Text = Localizer["VideoDevices"], + Text = Localizer["AudioDevice"], + Url = "audio-device" + }, + new() + { + IsNew = true, + Text = Localizer["VideoDevice"], Url = "video-device" }, new() diff --git a/src/BootstrapBlazor.Server/Locales/en-US.json b/src/BootstrapBlazor.Server/Locales/en-US.json index 3a496e0dbae..22785ecd4e6 100644 --- a/src/BootstrapBlazor.Server/Locales/en-US.json +++ b/src/BootstrapBlazor.Server/Locales/en-US.json @@ -4921,7 +4921,8 @@ "ShieldBadge": "ShieldBadge", "OtpInput": "OtpInput", "TotpService": "ITotpService", - "VideoDevices": "IVideoDevice" + "VideoDevice": "IVideoDevice", + "AudioDevice": "IAudioDevice" }, "BootstrapBlazor.Server.Components.Samples.Table.TablesHeader": { "TablesHeaderTitle": "Header grouping function", @@ -7128,5 +7129,14 @@ "VideoDeviceCloseText": "Close", "VideoDeviceCaptureText": "Capture", "VideoDeviceFlipText": "Flip" + }, + "BootstrapBlazor.Server.Components.Samples.AudioDevices": { + "AudioDeviceTitle": "IAudioDevice", + "AudioDeviceIntro": "Get audio equipment operation capabilities through this service", + "BaseUsageTitle": "Basic usage", + "BaseUsageIntro": "Perform different operations by calling different API methods", + "AudioDeviceRequestText": "List", + "AudioDeviceOpenText": "Record", + "AudioDeviceCloseText": "Stop" } } diff --git a/src/BootstrapBlazor.Server/Locales/zh-CN.json b/src/BootstrapBlazor.Server/Locales/zh-CN.json index d8dbccfe1fb..5e3cc128f15 100644 --- a/src/BootstrapBlazor.Server/Locales/zh-CN.json +++ b/src/BootstrapBlazor.Server/Locales/zh-CN.json @@ -4921,7 +4921,8 @@ "ShieldBadge": "徽章组件 ShieldBadge", "OtpInput": "验证码输入框 OtpInput", "TotpService": "时间密码验证服务 ITotpService", - "VideoDevices": "视频设备服务 IVideoDevice" + "VideoDevice": "视频设备服务 IVideoDevice", + "AudioDevice": "音频设备服务 IAudioDevice" }, "BootstrapBlazor.Server.Components.Samples.Table.TablesHeader": { "TablesHeaderTitle": "表头分组功能", @@ -7128,5 +7129,16 @@ "VideoDeviceCloseText": "关闭设备", "VideoDeviceCaptureText": "截图", "VideoDeviceFlipText": "翻转镜头" + }, + "BootstrapBlazor.Server.Components.Samples.AudioDevices": { + "AudioDeviceTitle": "IAudioDevice 音频设备服务", + "AudioDeviceIntro": "通过此服务获得音频设备操作能力", + "BaseUsageTitle": "基本用法", + "BaseUsageIntro": "通过调用不同的 api 方法进行不同操作", + "AudioDeviceRequestText": "枚举设备", + "AudioDeviceOpenText": "录音", + "AudioDeviceCloseText": "停止", + "AudioDevicePauseText": "暂停", + "AudioDeviceResumeText": "恢复" } } diff --git a/src/BootstrapBlazor.Server/docs.json b/src/BootstrapBlazor.Server/docs.json index c59c0d20386..5d6a7e2ad57 100644 --- a/src/BootstrapBlazor.Server/docs.json +++ b/src/BootstrapBlazor.Server/docs.json @@ -231,7 +231,8 @@ "shield-badge": "ShieldBadges", "opt-input": "OtpInputs", "otp-service": "OtpServices", - "video-device": "VideoDevices" + "video-device": "VideoDevices", + "audio-device": "AudioDevices" }, "video": { "table": "BV1ap4y1x7Qn?p=1", diff --git a/src/BootstrapBlazor/Extensions/BootstrapBlazorServiceCollectionExtensions.cs b/src/BootstrapBlazor/Extensions/BootstrapBlazorServiceCollectionExtensions.cs index 0d482e74430..362f8681147 100644 --- a/src/BootstrapBlazor/Extensions/BootstrapBlazorServiceCollectionExtensions.cs +++ b/src/BootstrapBlazor/Extensions/BootstrapBlazorServiceCollectionExtensions.cs @@ -88,6 +88,7 @@ public static IServiceCollection AddBootstrapBlazor(this IServiceCollection serv services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); + services.TryAddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/BootstrapBlazor/Services/MediaDevices/DefaultAudioDevice.cs b/src/BootstrapBlazor/Services/MediaDevices/DefaultAudioDevice.cs new file mode 100644 index 00000000000..05708cea6cc --- /dev/null +++ b/src/BootstrapBlazor/Services/MediaDevices/DefaultAudioDevice.cs @@ -0,0 +1,34 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License +// See the LICENSE file in the project root for more information. +// Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone + +namespace BootstrapBlazor.Components; + +class DefaultAudioDevice(IMediaDevices deviceService) : IAudioDevice +{ + /// + /// + /// + /// + public async Task?> GetDevices() + { + var ret = new List(); + var devices = await deviceService.EnumerateDevices(); + if (devices != null) + { + ret.AddRange(devices.Where(d => d.Kind == "audioinput")); + } + return ret; + } + + public Task Open(MediaTrackConstraints constraints) + { + return deviceService.Open("audio", constraints); + } + + public Task Close(string? selector) + { + return deviceService.Close(selector); + } +} diff --git a/src/BootstrapBlazor/Services/MediaDevices/DefaultMediaDevices.cs b/src/BootstrapBlazor/Services/MediaDevices/DefaultMediaDevices.cs index bd01b7a76c6..37e124abed0 100644 --- a/src/BootstrapBlazor/Services/MediaDevices/DefaultMediaDevices.cs +++ b/src/BootstrapBlazor/Services/MediaDevices/DefaultMediaDevices.cs @@ -21,16 +21,16 @@ private async Task LoadModule() return await module.InvokeAsync?>("enumerateDevices"); } - public async Task Open(MediaTrackConstraints constraints) + public async Task Open(string type, MediaTrackConstraints constraints) { var module = await LoadModule(); - return await module.InvokeAsync("open", constraints); + return await module.InvokeAsync("open", type, constraints); } - public async Task Close(string? videoSelector) + public async Task Close(string? selector) { var module = await LoadModule(); - return await module.InvokeAsync("close", videoSelector); + return await module.InvokeAsync("close", selector); } public async Task Capture() diff --git a/src/BootstrapBlazor/Services/MediaDevices/DefaultVideoDevice.cs b/src/BootstrapBlazor/Services/MediaDevices/DefaultVideoDevice.cs index cc1a2712592..b4af4b6de4a 100644 --- a/src/BootstrapBlazor/Services/MediaDevices/DefaultVideoDevice.cs +++ b/src/BootstrapBlazor/Services/MediaDevices/DefaultVideoDevice.cs @@ -24,12 +24,12 @@ class DefaultVideoDevice(IMediaDevices deviceService) : IVideoDevice public Task Open(MediaTrackConstraints constraints) { - return deviceService.Open(constraints); + return deviceService.Open("video", constraints); } - public Task Close(string? videoSelector) + public Task Close(string? selector) { - return deviceService.Close(videoSelector); + return deviceService.Close(selector); } public Task Capture() diff --git a/src/BootstrapBlazor/Services/MediaDevices/IAudioDevice.cs b/src/BootstrapBlazor/Services/MediaDevices/IAudioDevice.cs new file mode 100644 index 00000000000..dc8f9f68731 --- /dev/null +++ b/src/BootstrapBlazor/Services/MediaDevices/IAudioDevice.cs @@ -0,0 +1,32 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License +// See the LICENSE file in the project root for more information. +// Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone + +namespace BootstrapBlazor.Components; + +/// +/// Audio Media Device Interface +/// +public interface IAudioDevice +{ + /// + /// Gets the list of audio devices. + /// + /// + Task?> GetDevices(); + + /// + /// Opens the audio device with the specified constraints. + /// + /// + /// + Task Open(MediaTrackConstraints constraints); + + /// + /// Close the audio device with the specified selector. + /// + /// + /// + Task Close(string? selector); +} diff --git a/src/BootstrapBlazor/Services/MediaDevices/IMediaDevices.cs b/src/BootstrapBlazor/Services/MediaDevices/IMediaDevices.cs index 5e6ee28618b..8cd7345d6c4 100644 --- a/src/BootstrapBlazor/Services/MediaDevices/IMediaDevices.cs +++ b/src/BootstrapBlazor/Services/MediaDevices/IMediaDevices.cs @@ -19,16 +19,17 @@ public interface IMediaDevices /// /// The open() method of the MediaDevices interface creates a new MediaStream object and starts capturing media from the specified device. /// + /// video or audio /// /// - Task Open(MediaTrackConstraints constraints); + Task Open(string type, MediaTrackConstraints constraints); /// /// The close() method of the MediaDevices interface stops capturing media from the specified device and closes the MediaStream object. /// - /// + /// /// - Task Close(string? videoSelector); + Task Close(string? selector); /// /// The capture() method of the MediaDevices interface captures a still image from the specified video stream and saves it to the specified location. diff --git a/src/BootstrapBlazor/Services/MediaDevices/IVideoDevice.cs b/src/BootstrapBlazor/Services/MediaDevices/IVideoDevice.cs index e4b1916a0f2..c197c87bf37 100644 --- a/src/BootstrapBlazor/Services/MediaDevices/IVideoDevice.cs +++ b/src/BootstrapBlazor/Services/MediaDevices/IVideoDevice.cs @@ -26,9 +26,9 @@ public interface IVideoDevice /// /// Close the video device with the specified selector. /// - /// + /// /// - Task Close(string? videoSelector); + Task Close(string? selector); /// /// Capture a still image from the video stream. diff --git a/src/BootstrapBlazor/Services/MediaDevices/MediaTrackConstraints.cs b/src/BootstrapBlazor/Services/MediaDevices/MediaTrackConstraints.cs index 9e5c7b052e2..88c39f8d681 100644 --- a/src/BootstrapBlazor/Services/MediaDevices/MediaTrackConstraints.cs +++ b/src/BootstrapBlazor/Services/MediaDevices/MediaTrackConstraints.cs @@ -11,27 +11,27 @@ namespace BootstrapBlazor.Components; public class MediaTrackConstraints { /// - /// + /// /// public string DeviceId { get; set; } = ""; /// - /// + /// /// - public string? VideoSelector { get; set; } + public string? Selector { get; set; } /// - /// + /// /// public int? Width { get; set; } /// - /// + /// /// public int? Height { get; set; } /// - /// + /// /// public string? FacingMode { get; set; } } diff --git a/src/BootstrapBlazor/wwwroot/modules/media.js b/src/BootstrapBlazor/wwwroot/modules/media.js index b326799d398..53944134c3d 100644 --- a/src/BootstrapBlazor/wwwroot/modules/media.js +++ b/src/BootstrapBlazor/wwwroot/modules/media.js @@ -7,22 +7,43 @@ export async function enumerateDevices() { } else { await navigator.mediaDevices.getUserMedia({ video: true, audio: true }); - const devices = await navigator.mediaDevices.enumerateDevices(); - ret = devices; + ret = await navigator.mediaDevices.enumerateDevices(); } return ret; } -export async function open(options) { +export async function open(type, options) { + let ret = false; + if (type === "video") { + ret = await openVideoDevice(options); + } + else if (type === "audio") { + ret = await record(options); + } + return ret; +} + +export async function close(selector) { + const media = registerBootstrapBlazorModule("MediaDevices"); + let ret = false; + if (media.stream) { + ret = await closeVideoDevice(selector); + } + else { + ret = await stop(selector); + } + return ret; +} + +const openVideoDevice = async options => { const constrains = { video: { deviceId: options.deviceId ? { exact: options.deviceId } : null, facingMode: { ideal: options.facingMode || "environment" } - }, - audio: false + } } - const { videoSelector, width, height } = options; + const { selector, width, height } = options; if (width) { constrains.video.width = { ideal: width }; } @@ -36,8 +57,8 @@ export async function open(options) { const media = registerBootstrapBlazorModule("MediaDevices"); media.stream = stream; - if (videoSelector) { - const video = document.querySelector(videoSelector); + if (selector) { + const video = document.querySelector(selector); if (video) { video.srcObject = stream; } @@ -45,17 +66,17 @@ export async function open(options) { ret = true; } catch (err) { - console.error("Error accessing media devices.", err); + console.error("Error accessing video devices.", err); } return ret; } -export async function close(videoSelector) { +const closeVideoDevice = async selector => { let ret = false; try { - if (videoSelector) { - const video = document.querySelector(videoSelector); + if (selector) { + const video = document.querySelector(selector); if (video) { video.pause(); const stream = video.srcObject; @@ -72,7 +93,7 @@ export async function close(videoSelector) { ret = true; } catch (err) { - console.error("Error closing media devices.", err); + console.error("Error closing video devices.", err); } return ret; } @@ -89,19 +110,18 @@ export async function apply(options) { const settings = track.getSettings(); const { aspectRatio } = settings; if (options.width) { - settings.width = { - exact: options.width, - }; - settings.height = { - exact: Math.floor(options.width / aspectRatio) - }; + settings.width = { + exact: options.width, + }; + settings.height = { + exact: Math.floor(options.width / aspectRatio) + }; } if (options.facingMode) { settings.facingMode = { ideal: options.facingMode, } } - console.log(settings); await track.applyConstraints(settings); } } @@ -137,3 +157,68 @@ const closeStream = stream => { }); } } + +export async function record(options) { + const constrains = { + audio: { + deviceId: options.deviceId ? { exact: options.deviceId } : null + } + } + + let ret = false; + try { + const stream = await navigator.mediaDevices.getUserMedia(constrains); + const media = registerBootstrapBlazorModule("MediaDevices"); + const mediaRecorder = new MediaRecorder(stream); + + stop(); + media.recorder = mediaRecorder; + media.audioSelector = options.selector; + media.chunks = []; + + mediaRecorder.start(); + mediaRecorder.ondataavailable = function (e) { + media.chunks.push(e.data); + }; + mediaRecorder.onstop = function () { + if (media.audioSelector) { + const audio = document.querySelector(media.audioSelector); + if (audio) { + if (media.chunks && media.chunks.length > 0) { + const blob = new Blob(media.chunks, { type: media.recorder.mimeType }); + media.chunks = []; + audio.src = window.URL.createObjectURL(blob); + audio.classList.remove("d-none"); + audio.classList.remove("hidden"); + audio.removeAttribute("hidden"); + } + } + delete media.audioSelector; + delete media.recorder; + } + }; + ret = true; + } + catch (err) { + console.error("Error accessing audio devices.", err); + } + return ret; +} + +export async function stop(selector) { + let ret = false; + const media = registerBootstrapBlazorModule("MediaDevices"); + if (selector) { + media.audioSelector = selector; + } + if (media.recorder) { + if (media.recorder.state === "recording") { + media.recorder.stop(); + } + else { + delete media.recorder; + } + ret = true; + } + return ret; +} diff --git a/test/UnitTest/Services/AudioDeviceTest.cs b/test/UnitTest/Services/AudioDeviceTest.cs new file mode 100644 index 00000000000..e1a5136c397 --- /dev/null +++ b/test/UnitTest/Services/AudioDeviceTest.cs @@ -0,0 +1,44 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License +// See the LICENSE file in the project root for more information. +// Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone + +namespace UnitTest.Services; + +public class AudioDeviceTest : BootstrapBlazorTestBase +{ + [Fact] + public async Task GetDevices_Ok() + { + Context.JSInterop.Setup>("enumerateDevices").SetResult([ + new() { DeviceId = "test-device-video-id", GroupId = "test-groupd-id", Kind = "videoinput", Label="test-video" }, + new() { DeviceId = "test-device-audio-id", GroupId = "test-groupd-id", Kind = "audioinput", Label="test-audio" } + ]); + var service = Context.Services.GetRequiredService(); + var devices = await service.GetDevices(); + Assert.NotNull(devices); + Assert.Equal("test-device-audio-id", devices[0].DeviceId); + Assert.Equal("test-groupd-id", devices[0].GroupId); + Assert.Equal("audioinput", devices[0].Kind); + Assert.Equal("test-audio", devices[0].Label); + } + + [Fact] + public async Task Open_Ok() + { + Context.JSInterop.Setup("open", _ => true).SetResult(true); + Context.JSInterop.Setup("close", _ => true).SetResult(true); + + var service = Context.Services.GetRequiredService(); + var options = new MediaTrackConstraints() + { + DeviceId = "test-device-id", + Selector = ".bb-audio" + }; + var open = await service.Open(options); + Assert.True(open); + + var close = await service.Close(".bb-audio"); + Assert.True(close); + } +} diff --git a/test/UnitTest/Services/VideoDeviceTest.cs b/test/UnitTest/Services/VideoDeviceTest.cs index 267c9c4e1b6..500100ea64f 100644 --- a/test/UnitTest/Services/VideoDeviceTest.cs +++ b/test/UnitTest/Services/VideoDeviceTest.cs @@ -11,12 +11,13 @@ public class VideoDeviceTest : BootstrapBlazorTestBase public async Task GetDevices_Ok() { Context.JSInterop.Setup>("enumerateDevices").SetResult([ - new() { DeviceId = "test-device-id", GroupId = "test-groupd-id", Kind = "videoinput", Label="test-video" } + new() { DeviceId = "test-device-video-id", GroupId = "test-groupd-id", Kind = "videoinput", Label="test-video" }, + new() { DeviceId = "test-device-audio-id", GroupId = "test-groupd-id", Kind = "audioinput", Label="test-audio" } ]); var service = Context.Services.GetRequiredService(); var devices = await service.GetDevices(); Assert.NotNull(devices); - Assert.Equal("test-device-id", devices[0].DeviceId); + Assert.Equal("test-device-video-id", devices[0].DeviceId); Assert.Equal("test-groupd-id", devices[0].GroupId); Assert.Equal("videoinput", devices[0].Kind); Assert.Equal("test-video", devices[0].Label); @@ -37,7 +38,7 @@ public async Task Open_Ok() FacingMode = "user", Height = 640, Width = 480, - VideoSelector = ".bb-video" + Selector = ".bb-video" }; var open = await service.Open(options); Assert.True(open); @@ -45,14 +46,14 @@ public async Task Open_Ok() var close = await service.Close(".bb-video"); Assert.True(close); - var apply = await service.Apply(new MediaTrackConstraints() { Width = 640, Height = 480, VideoSelector = ".bb-video" }); + var apply = await service.Apply(new MediaTrackConstraints() { Width = 640, Height = 480, Selector = ".bb-video" }); Assert.True(apply); Assert.Equal("test-device-id", options.DeviceId); Assert.Equal("user", options.FacingMode); Assert.Equal(640, options.Height); Assert.Equal(480, options.Width); - Assert.Equal(".bb-video", options.VideoSelector); + Assert.Equal(".bb-video", options.Selector); await service.Capture(); var url = await service.GetPreviewUrl();