using System; using UnityEngine; using UnityEngine.Video; /// /// Plays a list of clips sequentially on the sibling . 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. /// [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(); [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(); 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(); } /// Advances to the next playable entry (wraps when is on). 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(); 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(); player.playbackSpeed = _active.playbackSpeed; player.audioOutputMode = _active.audioOutputMode; if (_active.audioOutputMode == VideoAudioOutputMode.AudioSource) { AudioSource template = _active.GetTargetAudioSource(0); if (!template) template = GetComponent(); if (template) { var audio = go.AddComponent(); 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); } } }