Gondwana Game Engine Development (master) - 2.6.1
Gondwana is a cross-platform 2.5D game and rendering engine written in C#/.NET 8. It provides fine-grained control over rendering, timing, and scene composition, with built-in support for parallax, z-ordering, pixel overhang, collision detection, and particle effects. Gondwana targets desktop and web platforms using SkiaSharp for graphics and pluggable audio backends for desktop and browser targets.
Loading...
Searching...
No Matches
VlcVideoPlayer.cs
1using System.Runtime.InteropServices;
2using LibVLCSharp.Shared;
3
4namespace Gondwana.Video;
5
8public sealed class VlcVideoPlayer : IVideoPlayer
9{
10 private readonly LibVLC _vlc;
11 private readonly MediaPlayer _player;
12 private readonly object _control = new();
13 private readonly object _frames = new();
14 private readonly VideoMetadataState _metadata = new();
15 private readonly MediaPlayer.LibVLCVideoLockCb _lockCb;
16 private readonly MediaPlayer.LibVLCVideoUnlockCb _unlockCb;
17 private readonly MediaPlayer.LibVLCVideoDisplayCb _displayCb;
18 private readonly MediaPlayer.LibVLCVideoFormatCb _formatCb;
19 private readonly MediaPlayer.LibVLCVideoCleanupCb _cleanupCb;
20 private VideoDecodeBuffer? _decodeBuffer;
21 private Media? _media;
22 private StreamMediaInput? _input;
23 private Stream? _ownedStream;
24 private CancellationTokenSource? _parseCancellation;
25 private Task<MediaParsedStatus>? _parseTask;
26 private long _generation;
27 private long _playbackRevision;
28 private bool _disposed, _acceptFrames, _playRequested;
29 private volatile bool _loop;
30 private (int width, int height) _naturalSize;
31 private Exception? _lastError;
32
34 public bool Loop { get => _loop; set => _loop = value; }
36 public bool IsPlaying { get { lock (_control) return !_disposed && _player.IsPlaying; } }
38 public VideoMetadata Metadata => _metadata.Current;
40 public bool IsMetadataReady => Metadata.Status == VideoMetadataStatus.Ready;
42 public bool HasAudio => Metadata.HasAudio;
44 public TimeSpan Duration => Metadata.Duration;
46 public TimeSpan Position { get { lock (_control) return _disposed ? TimeSpan.Zero : TimeSpan.FromMilliseconds(Math.Max(0, _player.Time)); } }
48 public (int width, int height) NaturalSize { get { lock (_frames) return _naturalSize; } }
50 public Exception? LastError => Volatile.Read(ref _lastError);
52 public event EventHandler? Started;
54 public event EventHandler? Paused;
56 public event EventHandler? Stopped;
58 public event EventHandler? Ended;
60 public event EventHandler<VideoStateChangedEventArgs>? StateChanged;
62 public event EventHandler<VideoFrameReadyEventArgs>? FrameReady;
63
68 public VlcVideoPlayer(string[]? vlcArgs = null, int initialWidth = 1280, int initialHeight = 720)
69 {
70 if (initialWidth <= 0) throw new ArgumentOutOfRangeException(nameof(initialWidth));
71 if (initialHeight <= 0) throw new ArgumentOutOfRangeException(nameof(initialHeight));
72 if (OperatingSystem.IsBrowser() || !BitConverter.IsLittleEndian)
73 throw new PlatformNotSupportedException("Gondwana.Video requires a little-endian native desktop LibVLC 3 runtime.");
74 try
75 {
76 // LibVLCSharp serializes process-wide initialization itself.
77 Core.Initialize();
78 _vlc = new LibVLC(vlcArgs ?? []);
79 try { _player = new MediaPlayer(_vlc); }
80 catch { _vlc.Dispose(); throw; }
81 }
82 catch (Exception ex) when (ex is DllNotFoundException or BadImageFormatException or TypeInitializationException or VLCException)
83 {
84 throw new InvalidOperationException("Gondwana.Video could not load native LibVLC 3. Add VideoLAN.LibVLC.Windows to the Windows app, or VideoLAN.LibVLC.Mac to a compatible macOS app; on Linux install libvlc-dev and VLC codec plugins. Match the process architecture and deploy the plugins directory. See Gondwana.Video/README.md. For a custom runtime call LibVLCSharp.Shared.Core.Initialize(path) before creating the player.", ex);
85 }
86 _lockCb = LockFrame;
87 _unlockCb = (_, _, _) => { };
88 _displayCb = DisplayFrame;
89 _formatCb = SetupFormat;
90 _cleanupCb = CleanupFormat;
91 _player.SetVideoCallbacks(_lockCb, _unlockCb, _displayCb);
92 _player.SetVideoFormatCallbacks(_formatCb, _cleanupCb);
93 _player.Playing += OnStarted;
94 _player.Paused += OnPaused;
95 _player.Stopped += OnStopped;
96 _player.EndReached += OnEnded;
97 _player.EncounteredError += OnError;
98 }
99
101 public void Open(Uri source)
102 {
103 ArgumentNullException.ThrowIfNull(source);
104 if (!source.IsAbsoluteUri) source = new Uri(Path.GetFullPath(source.OriginalString));
105 lock (_control)
106 {
107 ThrowIfDisposed();
108 if (!ReplaceMedia(() => new Media(_vlc, source), out long generation)) return;
109 var media = _media!;
110 _parseCancellation = new CancellationTokenSource();
111 _parseTask = media.Parse(source.IsFile ? MediaParseOptions.ParseLocal : MediaParseOptions.ParseNetwork,
112 cancellationToken: _parseCancellation.Token);
113 // The continuation does not own the media. Generation validation under _control
114 // prevents any access after replacement/disposal. Open never waits for network parse.
115 _ = ObserveParseAsync(_parseTask, media, generation);
116 }
117 }
118
120 public void Open(Stream source, bool leaveOpen = false)
121 {
122 ArgumentNullException.ThrowIfNull(source);
123 if (!source.CanRead) throw new ArgumentException("Video stream must be readable.", nameof(source));
124 lock (_control)
125 {
126 ThrowIfDisposed();
127 var input = new StreamMediaInput(source);
128 try
129 {
130 if (!ReplaceMedia(() => new Media(_vlc, input), out _))
131 {
132 input.Dispose();
133 return;
134 }
135 _input = input;
136 _ownedStream = leaveOpen ? null : source;
137 }
138 catch { input.Dispose(); throw; }
139 // Do not preparse and play against the same stream cursor concurrently.
140 // LibVLC discovers stream tracks during playback; Playing publishes metadata.
141 }
142 }
143
144 private bool ReplaceMedia(Func<Media> create, out long generation)
145 {
146 lock (_frames) _acceptFrames = false;
147 ++_generation;
148 long replacementGeneration = _generation;
149 ReleaseMedia();
150 lock (_frames) _naturalSize = (0, 0);
151 _metadata.Reset();
152 Volatile.Write(ref _lastError, null);
153 StateChanged?.Invoke(this, new("MediaOpening"));
154 if (_disposed || replacementGeneration != _generation)
155 {
156 generation = 0;
157 return false;
158 }
159 _media = create();
160 _player.Media = _media;
161 generation = _generation = _metadata.Begin();
162 StateChanged?.Invoke(this, new("MediaOpened"));
163 if (_disposed || generation != _generation) return false;
164 lock (_frames) _acceptFrames = true;
165 return true;
166 }
167
168 private async Task ObserveParseAsync(Task<MediaParsedStatus> parse, Media media, long generation)
169 {
170 MediaParsedStatus status;
171 try { status = await parse.ConfigureAwait(false); }
172 catch (OperationCanceledException) { return; }
173 catch (Exception ex) { Volatile.Write(ref _lastError, ex); status = MediaParsedStatus.Failed; }
174 // Always queue: even a synchronous Parse completion must not raise user events inside Open.
175 QueueForSource(generation, () => PublishMetadata(media, status == MediaParsedStatus.Done));
176 }
177
178 private void PublishMetadata(Media media, bool ready)
179 {
180 var metadata = ready
181? new VideoMetadata(VideoMetadataStatus.Ready, media.Tracks is { } tracks && Array.Exists(tracks, t => t.TrackType == TrackType.Audio),
182 TimeSpan.FromMilliseconds(Math.Max(0, media.Duration)))
183 : new VideoMetadata(VideoMetadataStatus.Failed, false, TimeSpan.Zero);
184 if (_metadata.Complete(_generation, metadata))
185 StateChanged?.Invoke(this, new(ready ? "MetadataReady" : "MetadataFailed"));
186 }
187
188 private void QueueForSource(long generation, Action action) => ThreadPool.QueueUserWorkItem(_ =>
189 {
190 lock (_control)
191 {
192 if (_disposed || generation != _generation) return;
193 try { action(); }
194 catch (Exception ex) { Volatile.Write(ref _lastError, ex); }
195 }
196 });
197
198 private void OnStarted(object? sender, EventArgs e)
199 {
200 long generation = Volatile.Read(ref _generation);
201 QueueForSource(generation, () =>
202 {
203 if (_input is not null && !IsMetadataReady) PublishMetadata(_media!, true);
204 Started?.Invoke(this, EventArgs.Empty);
205 });
206 }
207 private void OnPaused(object? sender, EventArgs e) => QueueForSource(Volatile.Read(ref _generation), () => Paused?.Invoke(this, EventArgs.Empty));
208 private void OnStopped(object? sender, EventArgs e) => QueueForSource(Volatile.Read(ref _generation), () => Stopped?.Invoke(this, EventArgs.Empty));
209 private void OnError(object? sender, EventArgs e) => QueueForSource(Volatile.Read(ref _generation), () =>
210 {
211 Volatile.Write(ref _lastError, new InvalidOperationException("LibVLC could not play the active source. Check its format, access, and installed codec plugins."));
212 StateChanged?.Invoke(this, new("Error"));
213 });
214 private void OnEnded(object? sender, EventArgs e)
215 {
216 long revision = Volatile.Read(ref _playbackRevision);
217 QueueForSource(Volatile.Read(ref _generation), () =>
218 {
219 long generation = _generation;
220 Ended?.Invoke(this, EventArgs.Empty);
221 if (!_disposed && _playRequested && generation == _generation && revision == _playbackRevision && Loop)
222 {
223 // Explicit controls, including Stop from an Ended handler, cancel a queued loop.
224 // Never reenter libvlc from its EndReached callback (documented deadlock risk).
225 _player.Stop();
226 _player.Play();
227 }
228 });
229 }
230
231 private uint SetupFormat(ref IntPtr opaque, IntPtr chroma, ref uint width, ref uint height, ref uint pitches, ref uint lines)
232 {
233 try
234 {
235 var buffer = new VideoDecodeBuffer(checked((int)width), checked((int)height));
236 lock (_frames)
237 {
238 _decodeBuffer?.Dispose();
239 _decodeBuffer = buffer;
240 }
241 // LibVLC 3 vmem masks: R=0xff0000 G=0xff00 B=0xff. On little endian
242 // RV32 is B,G,R,X (not alpha). Skia uses Bgra8888 + Opaque without swizzling.
243 Marshal.Copy(new byte[] { (byte)'R', (byte)'V', (byte)'3', (byte)'2' }, 0, chroma, 4);
244 pitches = (uint)buffer.Stride;
245 lines = (uint)buffer.Lines;
246 lock (_frames) _naturalSize = (buffer.Width, buffer.Height);
247 return 1;
248 }
249 catch (Exception ex) { Volatile.Write(ref _lastError, ex); return 0; }
250 }
251 private IntPtr LockFrame(IntPtr opaque, IntPtr planes)
252 {
253 // vmem serializes lock -> copy -> display for one active video output.
254 // The buffer remains alive until that output's cleanup callback or Stop joins it.
255 lock (_frames)
256 {
257 if (_decodeBuffer is not { } buffer || planes == IntPtr.Zero) return IntPtr.Zero;
258 Marshal.WriteIntPtr(planes, buffer.Pixels);
259 }
260 return IntPtr.Zero;
261 }
262 private void DisplayFrame(IntPtr opaque, IntPtr picture)
263 {
264 try
265 {
266 lock (_frames)
267 {
268 if (!_acceptFrames || _decodeBuffer is not { } buffer) return;
269 FrameReady?.Invoke(this, new(buffer.Pixels, buffer.Width, buffer.Height, buffer.Stride, 0));
270 }
271 }
272 catch (Exception ex) { Volatile.Write(ref _lastError, ex); }
273 }
274 private void CleanupFormat(ref IntPtr opaque)
275 {
276 // LibVLCSharp 3.9.7 passes cleanup straight to native code, unlike its
277 // format/lock/display shims. Its ref IntPtr is NOT our format user data.
278 // Never read/write it. This instance owns the single active vmem output.
279 lock (_frames)
280 {
281 _decodeBuffer?.Dispose();
282 _decodeBuffer = null;
283 }
284 }
286 public void Play() { lock (_control) { ThrowIfDisposed(); ++_playbackRevision; _playRequested = true; _player.Play(); } }
288 public void Pause() { lock (_control) { ThrowIfDisposed(); ++_playbackRevision; _playRequested = false; _player.SetPause(true); } }
290 public void Stop() { lock (_control) { ThrowIfDisposed(); ++_playbackRevision; _playRequested = false; _player.Stop(); } }
292 public void Seek(TimeSpan position) { lock (_control) { ThrowIfDisposed(); _player.Time = Math.Max(0, (long)position.TotalMilliseconds); } }
294 public void SetRate(double rate)
295 {
296 if (!double.IsFinite(rate) || rate <= 0) throw new ArgumentOutOfRangeException(nameof(rate));
297 lock (_control) { ThrowIfDisposed(); _player.SetRate((float)rate); }
298 }
299 private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(_disposed, this);
300 private void ReleaseMedia()
301 {
302 _playRequested = false;
303 _player.Stop(); // Joins decoder callbacks before media/input/buffer ownership is released.
304 lock (_frames) { _decodeBuffer?.Dispose(); _decodeBuffer = null; }
305 _parseCancellation?.Cancel();
306 if (_parseTask is not null)
307 {
308 try { _parseTask.GetAwaiter().GetResult(); }
309 catch (OperationCanceledException) { }
310 catch (Exception ex) { Volatile.Write(ref _lastError, ex); }
311 }
312 _parseTask = null;
313 _parseCancellation?.Dispose();
314 _parseCancellation = null;
315 _player.Media = null;
316 _media?.Dispose(); _media = null;
317 _input?.Dispose(); _input = null;
318 _ownedStream?.Dispose(); _ownedStream = null;
319 }
321 public void Dispose()
322 {
323 lock (_control)
324 {
325 if (_disposed) return;
326 _disposed = true;
327 ++_generation;
328 lock (_frames) _acceptFrames = false;
329 ReleaseMedia();
330 _metadata.Reset();
331 _player.Playing -= OnStarted;
332 _player.Paused -= OnPaused;
333 _player.Stopped -= OnStopped;
334 _player.EndReached -= OnEnded;
335 _player.EncounteredError -= OnError;
336 _player.Dispose();
337 _vlc.Dispose();
338 }
339 }
340}
bool IsPlaying
Gets a value indicating whether the video is currently playing.
bool Loop
Gets or sets a value indicating whether the video should loop when it reaches the end.
void Stop()
Stops the video playback and resets the position.
EventHandler< VideoFrameReadyEventArgs >? FrameReady
EventHandler< VideoStateChangedEventArgs >? StateChanged
bool HasAudio
Gets whether the video has an audio track. Consult IsMetadataReady before interpreting false.
VlcVideoPlayer(string[]? vlcArgs=null, int initialWidth=1280, int initialHeight=720)
Creates a native desktop player. Native libraries are supplied by the application.
void SetRate(double rate)
Sets the playback rate of the video.
void Open(Stream source, bool leaveOpen=false)
Opens a readable stream. Ownership transfers on success unless leaveOpen is true. Keep a borrowed str...
TimeSpan Position
Gets the current playback position in the video.
void Seek(TimeSpan position)
Seeks to the specified position in the video.
void Play()
Starts or resumes playback of the video.
Exception? LastError
The most recent native callback or playback failure, if any.
VideoMetadata Metadata
Gets an atomic snapshot of metadata availability and values for the current source.
void Open(Uri source)
Opens a video from the specified source URI.
TimeSpan Duration
Gets the total duration of the video.
bool IsMetadataReady
True only when metadata for the current source is available.
void Pause()
Pauses the video playback.
Defines the contract for a video player that can play, pause, stop, and seek video content.
VideoMetadataStatus
The availability of metadata for the active source.
@ Failed
Metadata discovery failed.
record VideoMetadata(VideoMetadataStatus Status, bool HasAudio, TimeSpan Duration)
An immutable metadata snapshot. Values are authoritative only when status is Ready.