Code/UI/VideoPanel.razor.cs
using Sandbox.Audio;
using Sandbox.UI;
using System;
using System.Threading;
using System.Threading.Tasks;
namespace Duccsoft;
/// <summary>
/// A Panel that manages an instance of a VideoPlayer, using its texture as the background image.
/// Supports all the playback controls of VideoPlayer.
/// </summary>
[Alias("video")]
public partial class VideoPanel : Panel, IVideoPanel
{
/// <summary>
/// The path/url of a video relative to VideoRoot.
/// </summary>
public string VideoPath { get; set; }
/// <summary>
/// Specifies where the video may be found, whether it comes from a BaseFileSystem or a website.
/// Set this to <see cref="VideoRoot.WebStream"/> if VideoPath is a URL.
/// </summary>
public VideoRoot VideoRoot { get; set; } = VideoRoot.MountedFileSystem;
/// <summary>
/// If true, the video will automatically loop whenever HasReachedEnd is true.
/// </summary>
public bool ShouldLoop { get; set; } = true;
public bool AutoPlay { get; set; } = true;
public bool StartMuted { get; set; } = false;
public bool ShowControls { get; set; } = false;
public bool AutoHideControls { get; set; } = true;
public float AutoHideDelay { get; set; } = 1f;
public float Width
{
get
{
if ( Style is null || Parent is null )
return 0f;
return Style.Width.Value.GetPixels( Parent.Box.Rect.Width );
}
set
{
if ( Style is null )
return;
Style.Width = Length.Pixels( value );
}
}
public float Height
{
get
{
if ( Style is null || Parent is null )
return 0f;
return Style.Height.Value.GetPixels( Parent.Box.Rect.Height );
}
set
{
if ( Style is null )
return;
Style.Height = Length.Pixels( value );
}
}
/// <summary>
/// If true, the video is in the process of being loaded (e.g. from a remote server).
/// </summary>
public bool IsLoading => _videoLoader?.IsLoading != false || ( IsPlaying && _sinceLastTextureUpdate > 0.2f );
/// <summary>
/// If true, the video is playing, is not paused, and has not yet finished playing.
/// </summary>a
public bool IsPlaying => !HasReachedEnd && VideoPlayer is not null && !VideoPlayer.IsPaused;
/// <summary>
/// If true, this video has reached the end of the file. If ShouldLoop is enabled, the video will
/// automatically loop.
/// </summary>
public bool HasReachedEnd => VideoPlayer != null && VideoPlayer.PlaybackTime >= VideoPlayer.Duration;
#region Controls
/// <inheritdoc cref="VideoPlayer.Pause"/>
public void Pause() => VideoPlayer?.Pause();
/// <inheritdoc cref="VideoPlayer.IsPaused"/>
public bool IsPaused => VideoPlayer?.IsPaused == true;
/// <inheritdoc cref="VideoPlayer.Resume"/>
public void Resume() => VideoPlayer?.Resume();
/// <inheritdoc cref="VideoPlayer.TogglePause"/>
public void TogglePause() => VideoPlayer?.TogglePause();
/// <inheritdoc cref="VideoPlayer.Seek(float)"/>
public void Seek( float time ) => VideoPlayer?.Seek( time );
/// <inheritdoc cref="VideoPlayer.Duration"/>
public float Duration => VideoPlayer?.Duration ?? 0f;
/// <summary>
/// Returns the current playback time in seconds of the video, or if setting,
/// will seek to the specified playback time in seconds.
/// </summary>
public float PlaybackTime
{
get => VideoPlayer?.PlaybackTime ?? 0f;
set => Seek( value );
}
/// <inheritdoc cref="VideoPlayer.Stop"/>
public void Stop() => VideoPlayer?.Stop();
/// <summary>
/// Provides access to the various audio-related properties of VideoPlayer such
/// as Volume and Position.
/// </summary>
public IAudioAccessor Audio => _audioAccessor;
/// <summary>
/// Specifies a GameObject in the world from which the audio of this video shall be emitted.
/// </summary>
public GameObject AudioSource
{
get => _audioSource;
set
{
_audioSource = value;
if ( _audioAccessor is not null )
{
_audioAccessor.Target = value;
}
}
}
private GameObject _audioSource;
public MixerHandle TargetMixer
{
get => _targetMixer;
set
{
_targetMixer = value;
if ( _audioAccessor is not null )
{
_audioAccessor.TargetMixer = value.Get();
}
}
}
private MixerHandle _targetMixer;
#endregion
// Internal state
private VideoPlayer VideoPlayer { get; set; }
private Texture VideoTexture { get; set; }
private AsyncVideoLoader _videoLoader;
private Task<VideoPlayer> _videoLoadTask;
private TrackingAudioAccessor _audioAccessor;
private CancellationTokenSource _cancelSource = new();
private bool _shouldPauseNextFrame;
private RealTimeSince _sinceLastTextureUpdate;
protected override int BuildHash() => HashCode.Combine( ShowControls, AutoHideControls, AutoHideDelay );
public override void SetProperty( string name, string value )
{
if ( name == "src" )
{
VideoPath = value;
}
if ( name == "width" )
{
Style.Width = Length.Pixels( value.ToFloat( 160 ) );
}
if ( name == "height" )
{
Style.Height = Length.Pixels( value.ToFloat( 90 ) );
}
if ( name == "muted" )
{
StartMuted = value.ToBool();
}
base.SetProperty( name, value );
}
protected async override void OnAfterTreeRender( bool firstTime )
{
base.OnAfterTreeRender( firstTime );
if ( !firstTime )
return;
VideoPlayer = new VideoPlayer();
// Attempt to play whatever video is specified by the initial VideoPath and VideoRoot.
await PlayVideo();
}
/// <summary>
/// Attempt to play whatever video is specified by the current VideoPath and VideoRoot.
/// </summary>
private async Task PlayVideo()
{
// Now we're using the new video path and root.
_previousVideoPath = VideoPath;
_previousVideoRoot = VideoRoot;
Stop();
// Blank out the background when setting a new background image.
Style.SetBackgroundImage( (Texture)null );
StateHasChanged();
VideoTexture = null;
// Trying to play nothing? Success means stopping the current video.
if ( string.IsNullOrWhiteSpace( VideoPath ) )
{
return;
}
// Cancel whatever other video might be loading.
if ( _videoLoadTask is not null )
{
CancelVideoLoad();
// Wait for that video to finish cancelling before we load our own.
await _videoLoadTask;
}
VideoPlayer ??= new VideoPlayer();
VideoPlayer.OnTextureData = OnTextureData;
_cancelSource = new CancellationTokenSource();
// Cache the CancellationToken because Task.IsCanceled isn't whitelisted,
// and subsequent calls to PlayVideo would create a new CancellationTokenSource.
var cancelToken = _cancelSource.Token;
_videoLoadTask = LoadVideo( cancelToken );
VideoPlayer = await _videoLoadTask;
_videoLoadTask = null;
// If loading the video was cancelled...
if ( cancelToken.IsCancellationRequested )
{
// ...don't touch the video player, and don't update anything, because
// there may be another video that began loading later and finished before
// this one, and we don't want to overwrite the effect it had.
return;
}
_shouldPauseNextFrame = !AutoPlay;
ConfigureAudio();
UpdateBackgroundImage();
}
private void OnTextureData( ReadOnlySpan<byte> span, Vector2 size )
{
_sinceLastTextureUpdate = 0f;
int width = (int)size.x;
int height = (int)size.y;
if ( VideoTexture is null || VideoTexture.Size != size )
{
VideoTexture = Texture.Create( width, height, ImageFormat.RGBA8888 )
.WithName( "VideoPanel_Texture" )
.Finish();
UpdateBackgroundImage();
}
VideoTexture.Update( span, 0, 0, width, height );
if ( _shouldPauseNextFrame )
{
VideoPlayer.Pause();
_shouldPauseNextFrame = false;
}
}
/// <summary>
/// By default, will ensure that a TrackingAudioAccessor is created and
/// configured to use the VideoPlayer and AudioSource of this VideoPanel.
/// <br/><br/>
/// Called in PlayVideo after a video is loaded, but before OnPostVideoLoad.
/// </summary>
private void ConfigureAudio()
{
_audioAccessor ??= new TrackingAudioAccessor()
{
Muted = StartMuted
};
_audioAccessor.VideoPlayer = VideoPlayer;
_audioAccessor.Target = AudioSource;
_audioAccessor.TargetMixer = TargetMixer.Get();
}
/// <summary>
/// By default, sets the background image of this panel to VideoTexture and rebuilds the UI.
/// <br/><br/>
/// Called in PlayVideo after a video is loaded, but before OnPostVideoLoad.
/// </summary>
private void UpdateBackgroundImage()
{
if ( VideoPlayer is null )
return;
Style.SetBackgroundImage( VideoTexture );
StateHasChanged();
}
/// <summary>
/// Load whatever video is specified by the current VideoPath and VideoRoot, returning
/// an instance of a VideoPlayer. Called during PlayVideo.
/// </summary>
private async Task<VideoPlayer> LoadVideo( CancellationToken cancelToken )
{
_videoLoader ??= new AsyncVideoLoader( VideoPlayer );
if ( VideoRoot == VideoRoot.WebStream )
{
return await _videoLoader.LoadFromUrl( VideoPath, cancelToken );
}
else
{
return await _videoLoader.LoadFromFile( VideoRoot.AsFileSystem(), VideoPath, cancelToken );
}
}
private void CancelVideoLoad()
{
_cancelSource?.Cancel();
_cancelSource?.Dispose();
_cancelSource = null;
}
// Refresh detection
private string _previousVideoPath;
private VideoRoot _previousVideoRoot;
private bool VideoHasChanged => _previousVideoPath != VideoPath || _previousVideoRoot != VideoRoot;
public override void Tick()
{
if ( VideoPlayer is null )
return;
// If the VideoPath or VideoRoot have changed...
if ( VideoHasChanged )
{
// ...play that new video instead.
_ = PlayVideo();
return;
}
// The VideoTexture will not update unless Present is called.
VideoPlayer.Present();
DetectAndHandleLoop();
}
/// <summary>
/// By default, detects whether the video has reached its conclusion.
/// If so, and if ShouldLoop is true, the playback time will be
/// set to the start of the video.
/// </summary>
private void DetectAndHandleLoop()
{
// We use a custom looping mechanism because VideoPlayer.Repeat seems to mess up PlaybackTime.
if ( ShouldLoop && HasReachedEnd )
{
VideoPlayer.Seek( 0f );
}
}
public override void OnDeleted()
{
CancelVideoLoad();
VideoPlayer?.Stop();
VideoPlayer?.Dispose();
VideoTexture?.Dispose();
_audioAccessor?.Dispose();
}
}