简介:一份基于C#的视频播放器完整可运行源码,专注于同时支持本地视频与网络视频流播放,核心通过C#调用VLC库实现。项目采用Windows Forms界面,涵盖播放、暂停、停止、音量及进度控制,并涉及HTTP、RTSP、HLS等流协议处理,适合希望掌握C#多媒体开发或需要快速搭建播放器的开发者参考。资源共786个文件,压缩包约107.57MB,其中包含732个动态库文件,用于封装VLC解码播放能力;13个C#源文件对应界面逻辑与调用实现;另有工程配置、可执行程序及少量资源文件,目录完整,可直接打开编译运行。已有405人学习。借助这套源码,可以直观理解VLC.DotNet的封装调用、多线程播放逻辑、事件驱动UI交互,以及资源释放等关键实现;代码中还对内存管理与异常处理做了基础示范,对进阶C#桌面应用开发和处理视频流场景都有实际参考价值。
1. 做这个 C# 视频播放器,先想清楚播放内核怎么选
本地视频和网络视频都要能播,这个需求听起来简单,真正动手时 80% 的精力都耗在解码和流协议上,而不是界面。如果你直接拖一个MediaElement或WindowsMediaPlayer控件进去,很快会发现本地 MKV 花屏、网络直播流打不开、某些 H.265 编码直接罢工。反直觉的结论是:C# 视频播放器能不能「运行起来」,取决于你有没有在项目里正确接入一个解码内核,界面只是它的皮肤。常见的可靠做法是选 LibVLC,它是 VLC 的解码核心,C# 这边有 LibVLCSharp 封装,能同时吞下本地文件、HTTP 点播、HLS 直播和 RTSP 流。这篇就按一套「能直接跑」的源码组织方式,把初始化、本地播放、网络播放、进度控制和排错串起来,适合要自研桌面播放器、或者想给上位机/工具箱塞一个视频模块的开发者在本地复现。全文代码基于 WPF + LibVLCSharp,工程建好后把视频地址换掉就能用。
2. 引入 LibVLC 并跑通本地视频播放:初始化、事件与渲染
2.1 为什么用 LibVLC 而不是 WPF 自带控件
WPF 里MediaElement的本质是 Windows Media Player 内核,支持格式按系统已安装解码器走,MP4 能放,MKV 里封装 AVC 可能放不了,网络流只能碰 RTSP 和渐进式 HTTP,「能支持到什么程度」完全不可控。WindowsMediaPlayerCOM 控件同样受制于系统解码器,企业内网环境往往大量 Windows Server 没装全解码包,跑起来就是黑屏。LibVLC 把解码器、复用器、网络协议处理全部打成独立原生库打进程序目录,不依赖系统播放器组件,格式覆盖列表和 VLC 桌面版一致:MP4、MKV、AVI、FLV、TS、MP3、FLAC,网络协议覆盖 HTTP/HTTPS、HLS、RTSP、RTMP 和 UDP 组播。选它还有一个工程上的理由:LibVLCSharp 的渲染层封装好了,一个VideoView控件就能把画面嵌入 WPF 窗口,不用自己写 DirectX 互操作。编码格式不支持的锅由内核背,C# 侧代码关注业务逻辑即可。
2.2 最小可运行工程:NuGet 包组合与项目结构
新建一个 WPF 项目后,需要三个 NuGet 包配合,缺一不可。LibVLCSharp是托管 API,LibVLCSharp.WPF提供VideoView控件,VideoLAN.LibVLC.Windows是 Windows 平台原生二进制,包含 libvlc.dll 和所有插件模块。后两个不装,编译能过,运行时报找不到 libvlc 核心。包装齐后先确认原生库被正确拷到输出目录,通常在bin\Debug\net8.0-windows\libvlc或直接输出到根目录,看项目配置里包的 CopyLocal 行为。典型工程结构如下:
PlayerDemo/ ├── App.xaml ├── MainWindow.xaml // VideoView 渲染控件 + 控制栏 ├── MainWindow.xaml.cs // 页面事件绑定 ├── PlayerCore.cs // LibVLC 封装,播放器核心逻辑 └── PlaylistManager.cs // 播放列表与循环模式先看核心初始化代码。LibVLC对象代表一个全局运行上下文,MediaPlayer是实际干活的播放器对象,两者要按「先实例化 LibVLC,再实例化 MediaPlayer」的顺序来:
// PlayerCore.cs using LibVLCSharp.Shared; public class PlayerCore : IDisposable { private LibVLC _libVLC; private MediaPlayer _mediaPlayer; private bool _disposed; public PlayerCore() { // 初始化原生库搜索路径,会在进程目录里找 libvlc 相关 DLL Core.Initialize(); _libVLC = new LibVLC(); _mediaPlayer = new MediaPlayer(_libVLC); } public MediaPlayer Player => _mediaPlayer; public void PlayLocalFile(string filePath) { // UriKind.Absolute:必须用绝对 URI,LibVLC 不接收相对路径 var media = new Media(_libVLC, new Uri(filePath, UriKind.Absolute)); _mediaPlayer.Play(media); media.Dispose(); // 播放器内部持有引用,托管侧可以释放 } public void Dispose() { if (_disposed) return; _mediaPlayer.Dispose(); _libVLC.Dispose(); _disposed = true; } }逻辑说明:Core.Initialize()只负责加载原生库,没有这一步实例化LibVLC会直接抛DllNotFoundException。Play(media)是异步行为,方法本身很快返回,实际缓冲、解码都发生在后台线程;media.Dispose()在调用Play之后立即执行没有问题,因为 LibVLC 内部引用计数会把播放器持有的媒体实例保留到播放结束。参数上要注意new Uri(filePath, UriKind.Absolute)这一行,路径里带中文或空格并不会导致问题,真正会翻车的是你写了相对路径new Uri("video.mp4"),这种构造出来的 URI 缺少 scheme,LibVLC 无法识别。
2.3 MediaPlayer 的 5 个关键事件与状态机
事件与错误反馈是播放器能否「人模人样」的关键。LibVLCSharp 里MediaPlayer的所有事件都在后台线程引发,UI 更新要自行切回主线程,这一点在 2.4 和 3.2 会反复遇到。下面这张表是播放器开发里最常用的 5 个事件,每个都会在特定时机触发一次或多次:
| 事件 | 触发时机 | 典型用途 |
|---|---|---|
Playing | 媒体开始输出画面或声音 | 切换播放/暂停按钮态,隐藏加载圈 |
Paused | 暂停完成 | 同步按钮文字 |
TimeChanged | 播放位置变化,约每 250ms 一次 | 更新进度条,显示当前时间 |
EndReached | 播放到文件结尾 | 自动播下一首 / 停止播放 |
EncounteredError | 文件损坏、网络超时、解码失败 | 弹错误文案,恢复 UI 状态 |
事件绑定的推荐位置是构造函数,和对象生命周期对齐。在 PlayerCore 里追加:
public event EventHandler? Playing; public event EventHandler? Paused; public event EventHandler<MediaPlayerTimeChangedEventArgs>? TimeChanged; public event EventHandler? EndReached; public event EventHandler? ErrorOccurred; public void AttachEvents(MediaPlayer player) { player.Playing += (_, _) => Playing?.Invoke(this, EventArgs.Empty); player.Paused += (_, _) => Paused?.Invoke(this, EventArgs.Empty); player.TimeChanged += (_, e) => TimeChanged?.Invoke(this, e); player.EndReached += (_, _) => EndReached?.Invoke(this, EventArgs.Empty); player.EncounteredError += (_, _) => ErrorOccurred?.Invoke(this, EventArgs.Empty); }参数说明:TimeChanged的MediaPlayerTimeChangedEventArgs只有Time一个属性,单位是毫秒,把它除以 1000 就是秒。EndReached和EncounteredError都意味着播放彻底结束,区别在于是「正常读完」还是「读不下去」。注意Playing不等于加载完成,它只表示已经开始输出第一帧画面,但后续仍有概率因磁盘或网络中断触发EncounteredError,所以错误处理的事不能只做一次。
2.4 用可播放文件验证链路,失败先看日志
完成上面代码就具备了一个最小可运行播放器。XAML 里放一个VideoView,把MediaPlayer绑定进去:
<!-- MainWindow.xaml --> <Window x:Class="PlayerDemo.MainWindow" xmlns:vlc="clr-namespace:LibVLCSharp.WPF;assembly=LibVLCSharp.WPF"> <Grid> <vlc:VideoView x:Name="VideoView" /> </Grid> </Window>// MainWindow.xaml.cs 中 KeyDown 事件或按钮事件里 _player = new PlayerCore(); VideoView.MediaPlayer = _player.Player; _player.AttachEvents(_player.Player); _player.PlayLocalFile(@"D:\demo\test.mp4");这一步跑通后,再去写任何 UI 交互。如果双击运行后窗口弹出来但画面黑屏或什么都没有,先做两件事:第一,确认bin输出目录里存在libvlc文件夹,里面有libvlc.dll和plugins子目录;第二,把 LibVLC 的日志回调挂上,看内核到底报了什么。日志往往是「渲染失败」「无法打开 MRL」「未找到解码器」这类明确结论,比自己猜快得多:
_libVLC.Log += (_, e) => { // e.Level: 0=Error, 1=Warning, 2=Notice, 3=Debug if (e.Level <= 2) Debug.WriteLine($"[libvlc {e.Level}] {e.Message}"); };常见报错里,no suitable decoder module for fourcc表示编码不对,需要装对应的解码插件;VLC is unable to open the MRL表示路径或 URL 写错;buffer deadlock prevented表示输入线程或渲染线程被阻塞,多为 UI 线程卡死导致画面无法上屏。验证链路时要准备一个格式最通用的测试文件,比如 H.264 + AAC 的 MP4,这个组合的解码器在 LibVLC 安装目录里一定存在。
提示:首次接 LibVLC 最常见的问题是
Core.Initialize()在LibVLC实例化之前被漏掉,其次是 NuGet 包只装了LibVLCSharp没装原生包。这两种情况报错一个出现在启动就崩溃,一个出现在运行到初始化的瞬间,先排这两个再查视频编码。
3. 本地视频的完整交互:文件选择、进度拖拽、倍速与列表
3.1 打开文件对话框与 Media 替换逻辑
拿到文件路径后的做法常见两种:一种是每次都new Media,旧媒体由 LibVLC 自动释放;另一种是复用同一个Media对象,替换它的MRL。第一种更省心,注意替换文件前要处理「正在播放」的状态。下面的OpenFile方法在按钮点击后触发:
private void OpenFile_Click(object sender, RoutedEventArgs e) { var dialog = new OpenFileDialog { Filter = "视频文件|*.mp4;*.mkv;*.avi;*.flv;*.rmvb;*.wmv|所有文件|*.*", CheckFileExists = true }; if (dialog.ShowDialog() == true) { // 正在播放时先停掉旧媒体,否则画面会残留上一帧 _player.Player.Stop(); _player.PlayLocalFile(dialog.FileName); Title = dialog.FileName; } }逻辑说明:Stop()和Pause()是有区别的,Stop()会把位置归零并停止一切解码工作,Pause()保留当前位置和缓冲数据。切换文件必须用Stop(),否则新文件的Playing事件可能在旧媒体还挂着的时候触发。文件名写入Title的目的是在发现加载异常时一眼看出当前操作的是哪个文件,这种细节在排查错误日志时非常有用。文件对话框的Filter是用户可感知的格式清单,但它是给人看的,不是给播放器看的过滤规则,LibVLC 实际支持的格式比这里列的要多得多。
3.2 进度条双向同步:定时器与 TimeChanged 怎么配合
进度条是播放器里交互最密集的组件,要处理好「播放器往控件推进度」和「用户拖进度回写播放器」两个方向,否则会出现拖拽后进度条自己弹回去或者卡住不动。推荐的做法是用TimeChanged驱动单次刷新,拖拽期间用布尔开关切断外部写入:
private bool _isDragging; // 拖拽进行中 private void OnTimeChanged(object? sender, MediaPlayerTimeChangedEventArgs e) { // 拖拽时不要持续改写 Slider.Value,会和用户手势抢光标 if (_isDragging) return; // mediaPlayer.Length 单位是毫秒,转成秒后计算百分比 var lengthMs = _player.Player.Length; if (lengthMs <= 0) return; double percent = (double)e.Time / lengthMs * 100; progressSlider.Dispatcher.Invoke(() => progressSlider.Value = percent); timeText.Dispatcher.Invoke(() => timeText.Text = FormatTime(e.Time)); }// 用户开始拖拽:暂停滑块值刷新 private void ProgressSlider_PreviewMouseDown(object sender, MouseButtonEventArgs e) { _isDragging = true; } // 用户松手:把位置写回播放器 private void ProgressSlider_PreviewMouseUp(object sender, MouseButtonEventArgs e) { _isDragging = false; if (_player.Player.Length <= 0) return; double targetPercent = progressSlider.Value / 100; _player.Player.Position = (float)targetPercent; }参数说明:MediaPlayer.Position是 0~1 的浮点数,Time是毫秒,Length也是毫秒。拖拽结束后用Position而非Time去设置,好处是自动适配不同视频长度,不涉及单位换算。事件处理器里用了Dispatcher.Invoke,因为TimeChanged在后台线程触发,直接碰 UI 会抛InvalidOperationException。isDragging开关只控制TimeChanged里不写 UI,Slider本身依然可以被用户拖动,因为IsMoveToPointEnabled默认就是打开的。进度条刷新频率约 4 次/秒,这个频率对 60Hz 的显示器足够了,没必要用DispatcherTimer每 16ms 轮询一次,内核对TimeChanged的触发已经有节流。
3.3 倍速、音量和截图的参数设置
这部分代码短但参数讲究。倍速通过SetRate(float)设置,范围 0.25 到 4.0,超过硬件解码能力时画面会跳帧但不崩溃;音量是 0~100 的绝对值,不是百分比缩放;截图必须发生在画面正在解码时,没有关键帧的时候截出来是黑图。三者代码如下:
private void SpeedUp_Click(object sender, RoutedEventArgs e) { float next = _player.Player.Rate + 0.25f; if (next > 4.0f) next = 1.0f; _player.Player.SetRate(next); speedText.Text = $"{_player.Player.Rate:0.00}x"; } private void VolumeSlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e) { // Volume 是整数 0~100,强制类型转换会截断小数 _player.Player.Volume = (int)volumeSlider.Value; } private void Snapshot_Click(object sender, RoutedEventArgs e) { var folder = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyPictures), "PlayerDemo"); Directory.CreateDirectory(folder); var fileName = $"{DateTime.Now:yyyyMMdd_HHmmss}.png"; var fullPath = Path.Combine(folder, fileName); // 参数:摄像机通道=0,保存路径,宽=0(保持原尺寸),高=0 bool ok = _player.Player.TakeSnapshot(0, fullPath, 0, 0); statusText.Text = ok ? $"已截图:{fullPath}" : "截图失败,请确认正在播放"; }逻辑说明:SetRate是方法不是属性赋值,误写成Player.Rate = 2.0f会导致编译不过。截图传0给宽高表示保持原始输出尺寸,不要自作聪明写成固定的 1280x720,那会把宽屏压扁。TakeSnapshot返回bool,失败的原因大概率是当前解码器还没有输出第一帧画面,所以调用时机放在Playing事件之后比较可靠。
3.4 播放列表的循环模式与容错
把多个文件串起来播放,需要在EndReached事件里做「下一首」的逻辑。常见做法是维护一个List<string>加当前索引,循环模式用枚举区分Order/RepeatOne/Shuffle:
| 模式 | EndReached里的行为 | 注意点 |
|---|---|---|
| 顺序播放 | 索引 +1,播完最后一个就 Stop | 索引越界要重置 |
| 单曲循环 | 用当前文件的路径重新 Play | 状态要清空,否则Playing不触发 |
| 随机播放 | 随机选一个不和当前相同的索引 | 文件不足 2 个时退化为顺序播放 |
private void OnEndReached(object? sender, EventArgs e) { Dispatcher.Invoke(() => { switch (_playMode) { case PlayMode.RepeatOne: _player.PlayLocalFile(playList[_currentIndex]); break; case PlayMode.Shuffle: var next = _random.Next(playList.Count); if (playList.Count > 1 && next == _currentIndex) next = (_currentIndex + 1) % playList.Count; _currentIndex = next; _player.PlayLocalFile(playList[_currentIndex]); break; default: if (_currentIndex + 1 < playList.Count) { _currentIndex++; _player.PlayLocalFile(playList[_currentIndex]); } else { _player.Player.Stop(); } break; } }); }说明:EndReached同样跑在后台线程,直接改 UI 和调用列表都要切回主线程。随机模式里限定「不能和当前相同」是为了避免播放器误以为文件没切换,但这只在列表长度大于 1 时有意义。单曲循环最省事的是不切Media直接调Play(),LibVLC 会从文件头重新开始。这个逻辑放在哪个类里都不重要,但playList、_currentIndex和_playMode这三个状态要放在同一个生命周期里,避免事件回调时访问已释放的对象。
4. 网络视频如何播放:URL 直接喂给 Media,按流类型选参数
4.1 网络流和本地文件的本质区别
本地文件是「随机访问」,LibVLC 可以通过文件系统的 seek 能力快速跳到任意位置;网络视频则是「字节流」,能否跳转取决于服务器是否支持 HTTP Range 请求,或者传输协议是否天然支持随机访问。这个区别直接决定了两件事:一是能否拖进度条,二是缓冲策略怎么调。LibVLC 对本地文件默认不做预缓冲,读多少解多少;对网络流默认会预读几百毫秒到几秒的数据,避免播放过程中断断续续。
另一个容易被忽略的点是地址类型。同样是一段 URL,http://.../video.mp4是渐进式下载,可以拖;http://.../live.m3u8是 HLS 直播切片,包含大量 ts 分片,拖进度条没有意义;rtsp://...是实时流,延迟控制和本地文件是两套机制。一份可运行的网络播放源码,应该在Play之前先识别 URL 特征并设置对应选项,不要都用一套参数。识别的最简方式是解析扩展名或看 scheme,对m3u8打上:hls-live-edge标签,对rtsp调小:network-caching。
4.2 播放网络视频的最小代码:URL、缓冲与连接超时
和播放本地文件相比,网络播放只在Media上多了一个AddOption调用。下面是一个可放入按钮事件的完整示例:
private void PlayNetwork_Click(string url) { if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) { statusText.Text = "URL 格式不正确"; return; } var media = new Media(_player.Player.LibVLC, uri); // network-caching 单位是毫秒,300ms 约为 3 秒视频内容 media.AddOption(":network-caching=300"); // 对 SSL 站点放宽时钟抖动限制,防止音画不同步 media.AddOption(":clock-jitter=0.03"); // 10 秒连不上就放弃,避免 UI 卡在无限缓冲 media.AddOption(":rtsp-connection-timeout=10"); _player.Player.Play(media); media.Dispose(); }参数说明:第一个选项是把网络缓冲从默认值改小或改大的入口,直播场景要求低延迟,设 300ms 合适;点播大文件希望起播更平滑,可以上调到 1000~2000,但这会牺牲起播速度。第二个选项clock-jitter影响音视频同步容差,默认 0.5 秒在弱网下容易出现音画错位,压到 0.03 是利用精确时钟。第三个只对 RTSP 生效,HTTP 的默认超时由操作系统控制,LibVLC 层没有统一超时参数。Uri.TryCreate校验拦截了"http://"开头但缺少主机名的脏数据,这类错误在用户手动粘贴地址时非常常见,不校验的话 LibVLC 报错提示不友好。
4.3 处理直播流与点播流的差异:NetworkCaching 与位置跳转
网络流只有两种操作:播放和停止。点播类和本地文件一样可以拖动进度,直播类只能实时看。代码里要识别这两种场景,最直接的方法是看 URL 特征:
bool IsLive(string url) { // HLS 直播的 m3u8 不一定都带 .m3u8 扩展名,某些 CDN 加签名参数 if (url.Contains(".m3u8")) return true; // RTSP 一定是实时流 if (url.StartsWith("rtsp://", StringComparison.OrdinalIgnoreCase)) return true; // UDP 组播,网络电视常用 if (url.StartsWith("udp://", StringComparison.OrdinalIgnoreCase)) return true; return false; }直播流在Playing事件触发后,Length可能是-1或者一个随时变化的值,进度条要按「不可用」处理,显示成加载动画。点播流则要注意:HTTP 渐进式下载能否拖拽,取决于服务器返回的 HTTP Header 里有没有Accept-Ranges: bytes。LibVLC 遇到不支持 Range 的服务器仍然可以播放,但Position设置不会生效,这时要提示用户不可拖拽,而不是静默失败。
| 场景 | 推荐network-caching | 是否可以 seek | 进度条表现 |
|---|---|---|---|
| HTTP/HTTPS 点播 | 500 或默认 | 多数服务器支持 | 正常显示 |
| HLS 点播(VOD) | 1000 | 不支持,服务器切片是顺序的 | 隐藏或禁用 |
| HLS 直播 | 300 | 不支持 | 显示为实时 |
| RTSP 直播 | 150~300 | 不支持 | 显示为实时 |
| UDP 组播 | 300 | 不支持 | 显示为实时 |
4.4 常见的 3 个问题:无法播放、卡顿、封面获取
无法播放优先看 URL 是否能被Media识别。直接在浏览器里打开 URL,能下载文件或能播放,说明地址有效;浏览器里也打不开,就是地址本身失效。LibVLC 的报错区分度足够:access error: HTTP 404是地址不存在,connection failed是主机不可达,no suitable demux module是内容格式不是纯媒体流,比如地址被反爬返回了 HTML 页面。对这类情况,正确做法是检查服务器返回的Content-Type,用HttpClient简单拉一下头:
using var http = new HttpClient(); http.Timeout = TimeSpan.FromSeconds(5); var resp = await http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead); var contentType = resp.Content.Headers.ContentType?.MediaType; bool looksLikeVideo = contentType?.StartsWith("video/") == true || contentType == "application/vnd.apple.mpegurl" || contentType == "application/x-mpegURL";这段代码不下载完整内容,只读响应头,几毫秒就能判断地址是否为视频流。如果拿到的是text/html,说明服务器把它当网页返回了,不是视频地址。
卡顿主要调两个参数:network-caching加大到 1000~2000 毫秒,以及确认没有打开硬件解码导致的兼容性问题。集成显卡或老显卡对 H.265 的硬解支持不好,画面会花或直接绿屏,这时尝试把硬件解码关闭,在 LibVLC 构造时传入--no-avcodec-hw会绕过硬解路径。
封面获取属于网络视频播放器的附加需求。如果视频来自 YouTube 等平台,用 oEmbed 接口拿缩略图是最标准的做法;自己服务器上的视频封面,要么后端提供缩略图接口,要么在播放首帧后调用 3.3 的TakeSnapshot。前端拿到 URL 后直接显示即可,不要尝试在 C# 里解析视频文件本身的封面流,那要引入额外的解析库,工程上不划算。
5. 提升播放器可用性的几个落地技巧
5.1 拖动进度条导致的 UI 刷新卡顿,用阈值节流解决
TimeChanged虽然是后台事件,但每次回调内部都要跨线程访问 UI,视频倍速拉到 2x 以上时这个回调频率会翻倍。与其在TimeChanged里频繁调用Dispatcher.Invoke,不如加一个时间窗口,只允许真实的毫秒变化超过某个阈值再刷。推荐的做法是用Stopwatch记录上次刷新时间,只有间隔超过 300ms 才更新进度条标签栏,Slider.Value保留在事件里更新,因为它本身是绑定值,不涉及字符串拼接,开销小很多。实测同等负载下画面掉帧明显减少,这是处理「和播放器卡顿搏斗」的常见手段。
5.2 双击全屏与 ESC 退出全屏的键位处理
WPF 里最稳妥的全屏不是设置WindowState=Maximized,而是隐藏窗口边框加最大化。最大化窗口仍然占据任务栏空间,真全屏要做到「视觉上无边框、逻辑上无最小化按钮」。用下面的键位处理方式,用户在任何控件焦点下按 ESC 都能退出:
private void VideoView_MouseDoubleClick(object sender, MouseButtonEventArgs e) { _isFullScreen = !_isFullScreen; if (_isFullScreen) { _oldWindowStyle = WindowStyle; WindowStyle = WindowStyle.None; ResizeMode = ResizeMode.NoResize; WindowState = WindowState.Maximized; } else { WindowStyle = _oldWindowStyle; ResizeMode = ResizeMode.CanResize; WindowState = WindowState.Normal; } } private void Window_KeyDown(object sender, KeyEventArgs e) { if (e.Key == Key.Escape && _isFullScreen) { // 复用退出全屏逻辑 VideoView_MouseDoubleClick(this, null); } }_oldWindowStyle要提前记录原始值,否则退出全屏后窗口样式无法恢复。光标在键盘焦点不在 VideoView 上时,KeyDown要挂到 Window 层,否则 ESC 会被按钮控件吞掉。
5.3 恢复上次播放位置:用 Configuration 保存时间戳
网络视频和本地视频都适用这个技巧。在TimeChanged里周期性把当前Time写入内存变量,窗口关闭时序列化到本地 JSON 文件,下次启动时把时间戳传给MediaPlayer.Time属性进行 seek:
private void SavePlaybackState() { var state = new PlaybackState { FilePath = _currentFile, PositionMs = _player.Player.Time, LastPlayedAt = DateTime.Now }; File.WriteAllText("playback.json", JsonSerializer.Serialize(state)); } private void RestorePlaybackState() { if (!File.Exists("playback.json")) return; var state = JsonSerializer.Deserialize<PlaybackState>(File.ReadAllText("playback.json")); if (state == null || !File.Exists(state.FilePath)) return; _currentFile = state.FilePath; _player.PlayLocalFile(state.FilePath); // 播放事件触发后再 seek;直接设置 Time 可能早于轨道就绪 EventHandler<EventArgs> handler = null; handler = (_, _) => { if (_player.Player.Time > 0) return; _player.Player.Time = state.PositionMs; _player.Player.Playing -= handler; }; _player.Player.Playing += handler; }逻辑说明:seek 操作的时机很讲究,过早调用Time会失败,因为 track 还没 ready。上面的写法是监听Playing事件,在处理器里判断Time > 0来兜底重复触发,保证恢复位置只执行一次。保存频率不用每帧都写,手动关闭时写一次即可,崩溃场景最多丢失最后一次手动保存前的进度。这一点比节流更能直接影响用户日常体验,另外也避免了音频续播和画面错位的常见投诉。
本文还有配套的精品资源,点击获取