Code/MoviePlayer.cs
using System.Text.Json.Serialization;
namespace Sandbox.MovieMaker;
#nullable enable
/// <summary>
/// Plays a <see cref="IClip"/> in a <see cref="Scene"/> to animate properties over time.
/// </summary>
[Icon( "live_tv" )]
public sealed class MoviePlayer : Component
{
private MovieTime _position;
private bool _isPlaying;
private IMovieResource? _source;
private IClip? _clip;
private TrackBinder? _binder;
/// <summary>
/// Maps <see cref="ITrack"/>s to game objects, components, and property <see cref="ITrackTarget"/>s in the scene.
/// </summary>
[Property, Hide]
public TrackBinder Binder => _binder ??= new TrackBinder( Scene );
/// <summary>
/// Contains a <see cref="IClip"/> to play. Can be a <see cref="MovieResource"/> or <see cref="EmbeddedMovieResource"/>.
/// </summary>
[Property, Hide]
public IMovieResource? Resource
{
get => _source;
set
{
_clip = null;
_source = value;
UpdatePosition();
}
}
[JsonIgnore, Property, Title( "Movie" ), Group( "Source" ), Order( -100 ), HideIf( nameof(IsEmbedded), true )]
private MovieResource? InspectorResource
{
get => Resource as MovieResource;
set => Resource = value;
}
private bool IsEmbedded => Resource is EmbeddedMovieResource;
public IClip? Clip
{
get => _clip ?? _source?.Compiled;
set
{
_clip = value;
UpdatePosition();
}
}
[Property, Group( "Playback" )]
public bool IsPlaying
{
get => _isPlaying;
set
{
_isPlaying = value;
UpdatePosition();
}
}
[Property, Group( "Playback" )]
public bool IsLooping { get; set; }
[Property, Group( "Playback" ), Range( 0f, 2f, 0.1f )]
public float TimeScale { get; set; } = 1f;
public MovieTime Position
{
get => _position;
set
{
_position = value;
UpdatePosition();
}
}
[Property, Group( "Playback" ), Title( "Position" )]
public float PositionSeconds
{
get => (float)Position.TotalSeconds;
set => Position = MovieTime.FromSeconds( value );
}
/// <summary>
/// Play the current movie from the start.
/// </summary>
public void Play()
{
_position = 0;
_isPlaying = true;
UpdatePosition();
}
/// <summary>
/// Play the specified movie from the start.
/// </summary>
public void Play( MovieResource movie )
{
_position = 0;
_isPlaying = true;
Resource = movie;
}
/// <summary>
/// Apply the movie clip to the scene at the current time position.
/// </summary>
private void UpdatePosition()
{
if ( !Enabled ) return;
if ( Clip is not { } clip ) return;
clip.Update( _position, Binder );
if ( IsPlaying )
{
UpdateAnimationPlaybackRate( clip );
}
else
{
StopControllingRigidBodies();
}
}
protected override void OnEnabled()
{
UpdatePosition();
}
protected override void OnUpdate()
{
if ( !IsPlaying ) return;
_position += MovieTime.FromSeconds( Time.Delta * TimeScale );
if ( Clip?.Duration is { IsPositive: true } duration && _position >= duration )
{
if ( IsLooping )
{
// Rewind if looping
_position.GetFrameIndex( duration, remainder: out _position );
}
else
{
// Otherwise stop
_isPlaying = false;
_position = duration;
}
}
UpdatePosition();
}
private readonly HashSet<Rigidbody> _controlledBodies = new();
private readonly HashSet<Rigidbody> _currentControlledBodies = new();
/// <summary>
/// Set the <see cref="SkinnedModelRenderer.PlaybackRate"/> of all bound renderers.
/// </summary>
private void UpdateAnimationPlaybackRate( IClip clip )
{
_currentControlledBodies.Clear();
foreach ( var rigidbody in Binder.GetComponents<Rigidbody>( clip ) )
{
_currentControlledBodies.Add( rigidbody );
if ( rigidbody.MotionEnabled && _controlledBodies.Add( rigidbody ) )
{
rigidbody.MotionEnabled = false;
}
}
foreach ( var rigidbody in _controlledBodies )
{
if ( !_currentControlledBodies.Contains( rigidbody ) )
{
rigidbody.MotionEnabled = true;
}
}
_controlledBodies.RemoveWhere( x => !_currentControlledBodies.Contains( x ) );
foreach ( var controller in Binder.GetComponents<PlayerController>( clip ) )
{
if ( controller.Renderer is { } renderer )
{
UpdateAnimationPlaybackRate( renderer );
}
}
foreach ( var renderer in Binder.GetComponents<SkinnedModelRenderer>( clip ) )
{
UpdateAnimationPlaybackRate( renderer );
}
}
private void StopControllingRigidBodies()
{
foreach ( var rigidbody in _controlledBodies )
{
rigidbody.MotionEnabled = true;
}
_controlledBodies.Clear();
}
protected override void OnDisabled()
{
StopControllingRigidBodies();
}
private void UpdateAnimationPlaybackRate( SkinnedModelRenderer renderer )
{
if ( renderer.SceneModel is not { } model ) return;
// We're assuming SkinnedModelRenderer.PlaybackRate persists even if we change SceneModel.PlaybackRate,
// so we don't stomp relative playback rates
model.PlaybackRate = renderer.PlaybackRate * TimeScale;
}
}