Avalonia跨平台实战(四),自定义控件Camere补充和自定义简单VideoPlayer控件
上文我们讲到自定义了一个Camera相机控件,本次我们来补充一下续集。
首先,既然是Camera控件,只有拍照怎么行呢,那必须得加上录像呀。话不多说,先上效果图
这里我们优化了下按钮样式,给Camera添加了个默认样式(其实也就是一张黑色的背景图),减少了重复事件代码。完整代码会贴在帖子最后。
这是点击录像之后的样式,可以看到,录像按钮变了样式,正在记录录像时长。
这里是录像完成之后下面的VideoPlayer控件的效果,其实这里还有优化的地方,也就是接收到临时视频文件的时候取一帧作为封面,这里我就不演示了,大家可以自己修改下
好了,现在进入正文。既然要添加录像功能,我们也得搞清楚里面的原理。
-
首先我们上一篇博客讲到了帧动画的概念,那这里录像对我们来讲其实就是从某段时间到某一段时间的帧动画数据集合。我们只需要保存下来这段时间的帧动画即可。
首先,我们定义对外的属性来控制是否录像,还需要定义录像写入器,来将这些录像写入视频文件,后面才能做播放的事情
/// <summary> /// 录像状态 /// </summary> private bool _isRecording; /// <summary> /// 录像写入器 /// </summary> private VideoWriter? _videoWriter; /// <summary> /// 是否开启录像 /// </summary> public static readonly StyledProperty<bool> IsRecordingProperty = AvaloniaProperty.Register<Camera, bool>(nameof(IsRecording), defaultValue: false); /// <summary> /// 录像临时文件 /// </summary> public static readonly StyledProperty<string> CurrentVideoProperty = AvaloniaProperty.Register<Camera, string>(nameof(CurrentVideo)); public string CurrentVideo { get => GetValue(CurrentVideoProperty); set => SetValue(CurrentVideoProperty, value); }
同时,我们得定义监听事件来处理录像功能
private void OnRecordingChanged(bool recording) { if (recording) { StartRecording(); } else { StopRecording(); } } private void StartRecording() { if (!_isRunning || _isRecording) return; var temporaryVideoDirectory = Path.Combine(AppContext.BaseDirectory, "TemporaryVideoFiles"); if (!Directory.Exists(temporaryVideoDirectory)) { Directory.CreateDirectory(temporaryVideoDirectory); } CurrentVideo = Path.Combine(temporaryVideoDirectory, $"{DateTime.Now:yyyyMMddHHmmssfff}.avi"); _isRecording = true; // 你可以设置分辨率、帧率和编码格式,这里以 MJPEG 为例 _videoWriter = new VideoWriter(); _videoWriter.Open(CurrentVideo, FourCC.MJPG, 30, new OpenCvSharp.Size(640, 480)); } private void StopRecording() { if (!_isRecording) return; _isRecording = false; _videoWriter?.Release(); _videoWriter?.Dispose(); _videoWriter = null; }
在这里,我视频数据是保存在文件里的,这个看各位小伙伴自己选择怎么保存视频数据。我这里是选择的最简单的方式
同时,我们得将属性和监听事件挂钩,在构造函数中加入这么一句代码,也就是监听属性变化订阅Changed事件,属性变化会自动调用订阅事件并传入新值
this.GetObservable(IsRecordingProperty).Subscribe(OnRecordingChanged);
到这里,我们的属性和方法就定义完了,我们还得修改一下之前的捕获帧画面
CaptureLoop()
方法,在while循环中加入几行简单的代码if (_isRecording && _videoWriter?.IsOpened()==true) { _videoWriter.Write(mat); }
到这里,录像功能就加好了。我们来演示一下看下效果
打开摄像头,点击录像,然后停止录像,可以看到我们的目录下多出了一个视频文件,证明我们录像成功了。
VideoPlayer控件
既然都完成了录像,那怎么得也得播放出来呀,之前有讲过,官方的流媒体控件需要付费才能使用。那我们找不到其他的库那就自己弄一个简单的吧,毕竟自己写的东西还是自己最熟悉,后面扩展也比较好扩展,同样也是在加深自己的学习能力
-
定义界面
既然都是播放器了,那肯定要有点样式对吧,至少,得能点击播放和暂停功能,时长这些简单的得要有吧,
首先我们先新建一个UserControl,名为VideoPlayer
-
效果图
-
View代码
<UserControl xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:icon="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia" mc:Ignorable="d" d:DesignWidth="1000" d:DesignHeight="600" x:Class="GeneralPurposeProgram.Controls.VideoPlayer"> <UserControl.Styles> <Style Selector="DockPanel.VideoBottom icon|MaterialIcon"> <Setter Property="Width" Value="35"></Setter> <Setter Property="Height" Value="35"></Setter> <Setter Property="Foreground" Value="#ffffff"></Setter> </Style> <Style Selector="DockPanel.VideoBottom TextBlock"> <Setter Property="Foreground" Value="#ffffff"></Setter> </Style> </UserControl.Styles> <Grid> <!-- 视频显示区域 --> <Image x:Name="PlayerImage" Stretch="Fill" Source="avares://GeneralPurposeProgram/Assets/Camere.jpg" HorizontalAlignment="Stretch" VerticalAlignment="Stretch" /> <!-- 控件叠加层:底部控制条 --> <DockPanel x:Name="VideoBottom" IsVisible="true" Classes="VideoBottom" VerticalAlignment="Bottom" Background="#80000000" Height="50"> <!-- 播放进度条 --> <!-- <Slider x:Name="ProgressSlider" Minimum="0" Maximum="100" DockPanel.Dock="Top" /> --> <StackPanel Orientation="Horizontal" DockPanel.Dock="Bottom" Spacing="6"> <!-- 播放按钮 --> <Button Click="PlayPauseButton_OnClick" BorderBrush="Transparent" BorderThickness="0"> <icon:MaterialIcon x:Name="PlayBtnIcon" Kind="Play"></icon:MaterialIcon> </Button> <!-- 下一个按钮 --> <!-- <icon:MaterialIcon Width="30" Height="30" Kind="SkipNext"></icon:MaterialIcon> --> <!-- 时长 --> <TextBlock x:Name="SurplusVideoSecond" HorizontalAlignment="Center" VerticalAlignment="Center" Text="00:00:00" /> <TextBlock HorizontalAlignment="Center" VerticalAlignment="Center" Text="/" /> <TextBlock x:Name="TotalVideoSecond" HorizontalAlignment="Center" VerticalAlignment="Center" Text="00:00:00" /> </StackPanel> </DockPanel> </Grid> </UserControl>
这里窗体黑色效果其实也就是一张黑色背景图。窗体有了,那得完善背后的功能了。
-
背后原理
首先,这里播放的原理其实和摄像头的原理一样,也是通过线程来处理Image控件切换帧画面效果。但是,我们得明白,视频时可以暂停和播放的,还得实现读秒器和视频时长
那如何控制视频的播放和暂停呢,那我们就要用到线程同步了,这里使用了ManualResetEventSlim,一个线程同步的轻量级版本。
从功能上看,我们的播放功能就是在线程中循环处理出来的效果,那这里暂停和继续播放我们就可以通过阻塞线程来实现我们想要的结果。ManualResetEventSlim有很多个方法,我们这里使用到的就是下面这三个方法
我们在播放的地方加入阻塞线程的方法
Wait(token)
,点击播放和暂停触发Set()
和Reset()
,来控制线程的阻塞。接下来,我们上完整代码
public partial class VideoPlayer : UserControl { public static readonly StyledProperty<string> VideoPathProperty = AvaloniaProperty.Register<VideoPlayer, string>(nameof(VideoPath)); public static readonly StyledProperty<bool> IsPlayingProperty = AvaloniaProperty.Register<VideoPlayer, bool>(nameof(IsPlaying), defaultValue: false); private CancellationTokenSource? _playbackCts; private bool _isPlaying; private readonly ManualResetEventSlim _playbackPauseEvent = new(true); // 初始为允许执行 public string VideoPath { get => GetValue(VideoPathProperty); set => SetValue(VideoPathProperty, value); } public bool IsPlaying { get => GetValue(IsPlayingProperty); set => SetValue(IsPlayingProperty, value); } public VideoPlayer() { InitializeComponent(); this.GetObservable(IsPlayingProperty).Subscribe(OnIsPlayingChanged); this.GetObservable(VideoPathProperty).Subscribe(OnVideoPathChanged); } private void OnIsPlayingChanged(bool playing) { if (playing) { _playbackPauseEvent.Set(); // 恢复播放 if (!_isPlaying) { StartPlayback(); } } else { _playbackPauseEvent.Reset(); // 暂停播放 } } private void OnVideoPathChanged(string path) { StopPlayback(); if (string.IsNullOrEmpty(path)) return; var result = GetVideoDurationInSeconds(path); var duration = TimeSpan.FromSeconds(result); var formatted = duration.ToString(@"hh\:mm\:ss"); TotalVideoSecond.Text = formatted; } private void StartPlayback() { if (_isPlaying || string.IsNullOrEmpty(VideoPath)) return; _playbackCts = new CancellationTokenSource(); _isPlaying = true; var path = VideoPath; SurplusVideoSecond.Text = "00:00:00"; Task.Run(() => PlaybackLoop(path, _playbackCts.Token)); } private void StopPlayback() { _playbackCts?.Cancel(); _playbackPauseEvent.Set(); // 避免死锁 _isPlaying = false; } private void PlaybackLoop(string videoPath, CancellationToken token) { try { using var capture = new VideoCapture(videoPath); if (!capture.IsOpened()) return; var fps = capture.Fps; if (fps <= 0) fps = 30; // fallback var frameInterval = TimeSpan.FromSeconds(1 / fps); // 精确帧间隔 using var frame = new Mat(); var stopwatch = System.Diagnostics.Stopwatch.StartNew(); while (!token.IsCancellationRequested) { _playbackPauseEvent.Wait(token); // 若为暂停状态,将阻塞至恢复或取消 var now = stopwatch.Elapsed; if (!capture.Read(frame) || frame.Empty()) break; var bitmap = ConvertMatToBitmap(frame); var currentFrame = capture.PosFrames; var totalFps = capture.Fps; var elapsed = TimeSpan.FromSeconds(currentFrame / totalFps); Dispatcher.UIThread.InvokeAsync(() => { PlayerImage.Source = bitmap; SurplusVideoSecond.Text = elapsed.ToString(@"hh\:mm\:ss"); }); var nextFrameTime = now + frameInterval; var sleepTime = nextFrameTime - stopwatch.Elapsed; if (sleepTime > TimeSpan.Zero) Thread.Sleep(sleepTime); } Dispatcher.UIThread.InvokeAsync(() => { IsPlaying = false; _isPlaying = false; _playbackPauseEvent.Set(); // 避免死锁 PlayBtnIcon.Kind = MaterialIconKind.Play; SurplusVideoSecond.Text = "00:00:00"; }); } catch (Exception ex) { Console.WriteLine($"Playback error: {ex.Message}"); } } protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) { base.OnDetachedFromVisualTree(e); StopPlayback(); } private void PlayPauseButton_OnClick(object? sender, RoutedEventArgs e) { IsPlaying = !IsPlaying; PlayBtnIcon.Kind = IsPlaying ? MaterialIconKind.Pause : MaterialIconKind.Play; } private static WriteableBitmap ConvertMatToBitmap(Mat mat) { using var ms = mat.ToMemoryStream(); ms.Seek(0, SeekOrigin.Begin); return WriteableBitmap.Decode(ms); } private static double GetVideoDurationInSeconds(string videoPath) { using var capture = new VideoCapture(videoPath); if (!capture.IsOpened()) throw new InvalidOperationException("无法打开视频文件"); var frameCount = capture.Get(VideoCaptureProperties.FrameCount); // 总帧数 var fps = capture.Get(VideoCaptureProperties.Fps); // 帧率 if (fps <= 0) throw new InvalidOperationException("无法获取帧率"); var duration = frameCount / fps; // 秒数 return duration; } }
这里需要注意的一个点就是我们计算播放时间用到了
Stopwatch
,先要取得视频的帧间隔,再去计算当前时间和帧间隔来处理线程的休眠时间完成视频的时长读秒操作。控件使用方法也很简单,绑定一个视频文件,绑定一个初始播放状态即可
这里我们只是简单实现了一下播放、暂停、继续播放和视频读秒的操作,本来相加进度条和拖动播放,但现在思路还没完善。各位小伙伴可自行扩展。等后续完善一下我要的控件之后,我会上传到开源平台上,大家喜欢的到时候也可以用。也欢迎大佬指导下我这个菜鸟程序员。
本片博客也就到这里了,下一期博客我们讲一下导航菜单和各种控件库的使用感受。大家动动小手点个赞可好。
-