Plays a list of items (clip + percentage range) on a VideoPlayer. Double-buffers via a second player (auto-created child incl. AudioSource copy) and drives the screen texture through a MaterialPropertyBlock, flipping only once the next player delivers frames - no black/freeze gap. Editor drawer renders each item as clip row + min-max percent range slider. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
351 lines
11 KiB
C#
351 lines
11 KiB
C#
using System;
|
|
using UnityEngine;
|
|
using UnityEngine.Video;
|
|
|
|
/// <summary>
|
|
/// Plays a list of clips sequentially on the sibling <see cref="VideoPlayer"/>. Each entry
|
|
/// defines the portion of its clip to play as a normalized start/end range (drawn as a
|
|
/// percent min-max slider). With an empty list the component leaves the player untouched,
|
|
/// so a plain single-clip setup keeps working.
|
|
/// For material-override screens (e.g. the TV) a second player pre-buffers the next entry
|
|
/// while the current one plays; both players run API-only and this component drives the
|
|
/// screen texture via a property block, flipping it only once the incoming player delivers
|
|
/// frames — transitions are gapless. Other render modes fall back to single-player playback.
|
|
/// </summary>
|
|
[RequireComponent(typeof(VideoPlayer))]
|
|
[DisallowMultipleComponent]
|
|
public class VideoClipPlaylist : MonoBehaviour
|
|
{
|
|
[Serializable]
|
|
public class Item
|
|
{
|
|
[Tooltip("Clip to play for this entry. Entries without a clip are skipped.")]
|
|
public VideoClip clip;
|
|
|
|
[Tooltip("Portion of the clip to play, normalized 0..1 (shown as percent).")]
|
|
public Vector2 range = new Vector2(0f, 1f);
|
|
}
|
|
|
|
[Tooltip("Played top to bottom; entries without a clip are skipped.")]
|
|
public Item[] items = Array.Empty<Item>();
|
|
|
|
[Tooltip("Restart from the first entry after the last one finished.")]
|
|
public bool loop = true;
|
|
|
|
[Tooltip("Second player used to pre-buffer the next entry for gapless transitions. " +
|
|
"Auto-created on a child when left empty (material-override screens only).")]
|
|
public VideoPlayer secondaryPlayer;
|
|
|
|
// Segment end is checked slightly early so ranges ending at 100% advance via the time
|
|
// check even when VideoPlayer's loop-point event races it (both paths advance safely).
|
|
private const double EndEpsilon = 0.05;
|
|
private const double MinSegmentSeconds = 0.05;
|
|
|
|
private VideoPlayer _active;
|
|
private VideoPlayer _standby; // null => single-player fallback (non material-override)
|
|
private Renderer _screen;
|
|
private int _screenProperty;
|
|
private MaterialPropertyBlock _block;
|
|
private Texture _presented;
|
|
private bool _setupDone;
|
|
|
|
private int _index = -1;
|
|
private double _segmentEnd = double.MaxValue;
|
|
private bool _activePreparing;
|
|
private bool _endReached;
|
|
|
|
private int _standbyIndex = -1;
|
|
private double _standbySegmentEnd;
|
|
private bool _standbyReady;
|
|
private bool _switching;
|
|
|
|
private void Awake()
|
|
{
|
|
_active = GetComponent<VideoPlayer>();
|
|
Hook(_active);
|
|
}
|
|
|
|
private void OnDestroy()
|
|
{
|
|
Unhook(_active);
|
|
Unhook(_standby);
|
|
}
|
|
|
|
private void OnEnable()
|
|
{
|
|
int first = NextPlayableIndex(0);
|
|
if (first < 0) return; // empty list: leave the player's own setup alone
|
|
|
|
SetupOnce();
|
|
Configure(_active);
|
|
if (_standby) Configure(_standby);
|
|
PlayItem(first);
|
|
}
|
|
|
|
private void OnDisable()
|
|
{
|
|
_activePreparing = false;
|
|
_standbyReady = false;
|
|
_switching = false;
|
|
_endReached = false;
|
|
_index = -1;
|
|
_standbyIndex = -1;
|
|
if (_active) _active.Stop();
|
|
if (_standby) _standby.Stop();
|
|
}
|
|
|
|
private void Update()
|
|
{
|
|
if (_index < 0) return;
|
|
|
|
if (_switching)
|
|
{
|
|
// Hold the old frame until the incoming player actually delivers.
|
|
if (_standby.frame >= 0 && _standby.texture) CompleteSwitch();
|
|
}
|
|
else if (!_activePreparing)
|
|
{
|
|
if (_endReached || (_active.isPlaying && _active.time >= _segmentEnd))
|
|
Advance();
|
|
}
|
|
|
|
Present();
|
|
}
|
|
|
|
/// <summary>Advances to the next playable entry (wraps when <see cref="loop"/> is on).</summary>
|
|
public void Next()
|
|
{
|
|
if (_index < 0)
|
|
{
|
|
int first = NextPlayableIndex(0);
|
|
if (first >= 0 && isActiveAndEnabled) PlayItem(first);
|
|
return;
|
|
}
|
|
_endReached = true;
|
|
}
|
|
|
|
private void SetupOnce()
|
|
{
|
|
if (_setupDone) return;
|
|
_setupDone = true;
|
|
|
|
// Gapless playback needs to own the output; that is only possible when the player
|
|
// drives a renderer material — then both players run API-only and this component
|
|
// applies the current texture itself.
|
|
if (_active.renderMode != VideoRenderMode.MaterialOverride) return;
|
|
_screen = _active.targetMaterialRenderer ? _active.targetMaterialRenderer : GetComponent<Renderer>();
|
|
if (!_screen) return;
|
|
|
|
string property = _active.targetMaterialProperty;
|
|
_screenProperty = Shader.PropertyToID(string.IsNullOrEmpty(property) ? "_MainTex" : property);
|
|
_block = new MaterialPropertyBlock();
|
|
|
|
if (!secondaryPlayer) secondaryPlayer = CreateSecondaryPlayer();
|
|
_standby = secondaryPlayer;
|
|
Hook(_standby);
|
|
}
|
|
|
|
private VideoPlayer CreateSecondaryPlayer()
|
|
{
|
|
var go = new GameObject("Video Player (Playlist Buffer)");
|
|
go.transform.SetParent(transform, false);
|
|
var player = go.AddComponent<VideoPlayer>();
|
|
player.playbackSpeed = _active.playbackSpeed;
|
|
player.audioOutputMode = _active.audioOutputMode;
|
|
|
|
if (_active.audioOutputMode == VideoAudioOutputMode.AudioSource)
|
|
{
|
|
AudioSource template = _active.GetTargetAudioSource(0);
|
|
if (!template) template = GetComponent<AudioSource>();
|
|
if (template)
|
|
{
|
|
var audio = go.AddComponent<AudioSource>();
|
|
CopyAudioSettings(template, audio);
|
|
player.SetTargetAudioSource(0, audio);
|
|
}
|
|
else
|
|
{
|
|
player.audioOutputMode = VideoAudioOutputMode.Direct;
|
|
}
|
|
}
|
|
return player;
|
|
}
|
|
|
|
private static void CopyAudioSettings(AudioSource from, AudioSource to)
|
|
{
|
|
to.playOnAwake = false;
|
|
to.loop = false;
|
|
to.outputAudioMixerGroup = from.outputAudioMixerGroup;
|
|
to.mute = from.mute;
|
|
to.volume = from.volume;
|
|
to.pitch = from.pitch;
|
|
to.priority = from.priority;
|
|
to.panStereo = from.panStereo;
|
|
to.spatialBlend = from.spatialBlend;
|
|
to.spatialize = from.spatialize;
|
|
to.spatializePostEffects = from.spatializePostEffects;
|
|
to.reverbZoneMix = from.reverbZoneMix;
|
|
to.bypassEffects = from.bypassEffects;
|
|
to.bypassListenerEffects = from.bypassListenerEffects;
|
|
to.bypassReverbZones = from.bypassReverbZones;
|
|
to.dopplerLevel = from.dopplerLevel;
|
|
to.spread = from.spread;
|
|
to.rolloffMode = from.rolloffMode;
|
|
to.minDistance = from.minDistance;
|
|
to.maxDistance = from.maxDistance;
|
|
}
|
|
|
|
private void Configure(VideoPlayer player)
|
|
{
|
|
player.playOnAwake = false;
|
|
player.isLooping = false;
|
|
player.source = VideoSource.VideoClip;
|
|
if (_screen) player.renderMode = VideoRenderMode.APIOnly;
|
|
}
|
|
|
|
private void Hook(VideoPlayer player)
|
|
{
|
|
player.prepareCompleted += OnPrepareCompleted;
|
|
player.loopPointReached += OnLoopPointReached;
|
|
}
|
|
|
|
private void Unhook(VideoPlayer player)
|
|
{
|
|
if (!player) return;
|
|
player.prepareCompleted -= OnPrepareCompleted;
|
|
player.loopPointReached -= OnLoopPointReached;
|
|
}
|
|
|
|
private int NextPlayableIndex(int start)
|
|
{
|
|
for (int i = start; i < items.Length; i++)
|
|
if (items[i] != null && items[i].clip)
|
|
return i;
|
|
return -1;
|
|
}
|
|
|
|
private int NextPlayableAfter(int index)
|
|
{
|
|
int next = NextPlayableIndex(index + 1);
|
|
if (next < 0 && loop) next = NextPlayableIndex(0);
|
|
return next;
|
|
}
|
|
|
|
private void PlayItem(int index)
|
|
{
|
|
_index = index;
|
|
_activePreparing = true;
|
|
_segmentEnd = double.MaxValue;
|
|
_active.Stop();
|
|
_active.clip = items[index].clip;
|
|
_active.Prepare();
|
|
}
|
|
|
|
private void PrepareStandby(int index)
|
|
{
|
|
_standbyIndex = index;
|
|
_standbyReady = false;
|
|
if (index < 0) return; // last entry without loop: nothing to buffer
|
|
_standby.Stop();
|
|
_standby.clip = items[index].clip;
|
|
_standby.Prepare();
|
|
}
|
|
|
|
private void Advance()
|
|
{
|
|
if (_standby)
|
|
{
|
|
if (_standbyIndex < 0)
|
|
{
|
|
_endReached = false;
|
|
StopPlayback();
|
|
return;
|
|
}
|
|
if (!_standbyReady) return; // buffer not ready yet: hold the frame, retry next Update
|
|
_endReached = false;
|
|
_standby.Play();
|
|
_switching = true;
|
|
}
|
|
else
|
|
{
|
|
_endReached = false;
|
|
int next = NextPlayableAfter(_index);
|
|
if (next < 0) StopPlayback();
|
|
else PlayItem(next);
|
|
}
|
|
}
|
|
|
|
private void CompleteSwitch()
|
|
{
|
|
_switching = false;
|
|
_active.Stop();
|
|
(_active, _standby) = (_standby, _active);
|
|
_index = _standbyIndex;
|
|
_segmentEnd = _standbySegmentEnd;
|
|
PrepareStandby(NextPlayableAfter(_index));
|
|
}
|
|
|
|
private void StopPlayback()
|
|
{
|
|
_index = -1;
|
|
_active.Stop();
|
|
}
|
|
|
|
private void Present()
|
|
{
|
|
if (!_screen) return;
|
|
Texture texture = _active.texture;
|
|
if (!texture || texture == _presented) return;
|
|
_presented = texture;
|
|
_screen.GetPropertyBlock(_block);
|
|
_block.SetTexture(_screenProperty, texture);
|
|
_screen.SetPropertyBlock(_block);
|
|
}
|
|
|
|
private double ComputeSegment(int index, double length, out double segmentEnd)
|
|
{
|
|
Vector2 range = items[index].range;
|
|
double start = length * Mathf.Clamp01(Mathf.Min(range.x, range.y));
|
|
double end = length * Mathf.Clamp01(Mathf.Max(range.x, range.y));
|
|
// Degenerate ranges still play a minimal moment instead of skip-looping forever.
|
|
end = Math.Min(Math.Max(end, start + MinSegmentSeconds), length);
|
|
segmentEnd = end - EndEpsilon;
|
|
return start;
|
|
}
|
|
|
|
private void OnPrepareCompleted(VideoPlayer source)
|
|
{
|
|
if (source == _active && _activePreparing && _index >= 0)
|
|
{
|
|
_activePreparing = false;
|
|
source.time = ComputeSegment(_index, source.length, out _segmentEnd);
|
|
source.Play();
|
|
if (_standby && !_switching) PrepareStandby(NextPlayableAfter(_index));
|
|
}
|
|
else if (source == _standby && _standbyIndex >= 0 && !_standbyReady)
|
|
{
|
|
// Pre-seek now so the switch starts on the right frame without a visible jump.
|
|
source.time = ComputeSegment(_standbyIndex, source.length, out _standbySegmentEnd);
|
|
_standbyReady = true;
|
|
}
|
|
}
|
|
|
|
private void OnLoopPointReached(VideoPlayer source)
|
|
{
|
|
// Natural clip end (range up to 100%) — advance if this is the presenting player.
|
|
if (source == _active && !_activePreparing && !_switching && _index >= 0 && isActiveAndEnabled)
|
|
_endReached = true;
|
|
}
|
|
|
|
private void OnValidate()
|
|
{
|
|
if (items == null) return;
|
|
foreach (Item item in items)
|
|
{
|
|
if (item == null) continue;
|
|
// Fresh array elements zero-initialize the range; default to "full clip".
|
|
if (item.range == Vector2.zero) item.range = new Vector2(0f, 1f);
|
|
}
|
|
}
|
|
}
|