diff --git a/Assets/Scripts/Components/VideoClipPlaylist.cs b/Assets/Scripts/Components/VideoClipPlaylist.cs
new file mode 100644
index 0000000..2be44cf
--- /dev/null
+++ b/Assets/Scripts/Components/VideoClipPlaylist.cs
@@ -0,0 +1,350 @@
+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);
+ }
+ }
+}
diff --git a/Assets/Scripts/Components/VideoClipPlaylist.cs.meta b/Assets/Scripts/Components/VideoClipPlaylist.cs.meta
new file mode 100644
index 0000000..a5c90c0
--- /dev/null
+++ b/Assets/Scripts/Components/VideoClipPlaylist.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: e05ac1f3c7372d743bff4ab1492a9fab
\ No newline at end of file
diff --git a/Assets/Scripts/Editor/VideoClipPlaylistItemDrawer.cs b/Assets/Scripts/Editor/VideoClipPlaylistItemDrawer.cs
new file mode 100644
index 0000000..bb8259b
--- /dev/null
+++ b/Assets/Scripts/Editor/VideoClipPlaylistItemDrawer.cs
@@ -0,0 +1,58 @@
+using UnityEditor;
+using UnityEngine;
+
+///
+/// Draws a as two lines: the clip field and a
+/// percent min-max slider (stored normalized 0..1) selecting the played portion.
+///
+[CustomPropertyDrawer(typeof(VideoClipPlaylist.Item))]
+public class VideoClipPlaylistItemDrawer : PropertyDrawer
+{
+ private const float PercentFieldWidth = 44f;
+ private const float Gap = 4f;
+
+ public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
+ => EditorGUIUtility.singleLineHeight * 2f + EditorGUIUtility.standardVerticalSpacing;
+
+ public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
+ {
+ SerializedProperty clipProp = property.FindPropertyRelative(nameof(VideoClipPlaylist.Item.clip));
+ SerializedProperty rangeProp = property.FindPropertyRelative(nameof(VideoClipPlaylist.Item.range));
+
+ EditorGUI.BeginProperty(position, label, property);
+
+ float line = EditorGUIUtility.singleLineHeight;
+ var clipRect = new Rect(position.x, position.y, position.width, line);
+ EditorGUI.PropertyField(clipRect, clipProp, label);
+
+ var rowRect = new Rect(position.x, clipRect.yMax + EditorGUIUtility.standardVerticalSpacing,
+ position.width, line);
+ Rect rangeRect = EditorGUI.PrefixLabel(rowRect, new GUIContent("Range (%)",
+ "Played portion of the clip in percent (start/end)."));
+
+ Vector2 range = rangeProp.vector2Value;
+ float min = Mathf.Round(range.x * 1000f) / 10f;
+ float max = Mathf.Round(range.y * 1000f) / 10f;
+
+ var minRect = new Rect(rangeRect.x, rangeRect.y, PercentFieldWidth, line);
+ var maxRect = new Rect(rangeRect.xMax - PercentFieldWidth, rangeRect.y, PercentFieldWidth, line);
+ var sliderRect = new Rect(minRect.xMax + Gap, rangeRect.y,
+ Mathf.Max(0f, rangeRect.width - 2f * (PercentFieldWidth + Gap)), line);
+
+ EditorGUI.BeginChangeCheck();
+ int indent = EditorGUI.indentLevel;
+ EditorGUI.indentLevel = 0; // fields inside the row must not re-indent
+ min = EditorGUI.FloatField(minRect, min);
+ EditorGUI.MinMaxSlider(sliderRect, ref min, ref max, 0f, 100f);
+ max = EditorGUI.FloatField(maxRect, max);
+ EditorGUI.indentLevel = indent;
+ if (EditorGUI.EndChangeCheck())
+ {
+ min = Mathf.Clamp(min, 0f, 100f);
+ max = Mathf.Clamp(max, min, 100f);
+ rangeProp.vector2Value = new Vector2(min / 100f, max / 100f);
+ }
+
+ EditorGUI.EndProperty();
+ }
+}
diff --git a/Assets/Scripts/Editor/VideoClipPlaylistItemDrawer.cs.meta b/Assets/Scripts/Editor/VideoClipPlaylistItemDrawer.cs.meta
new file mode 100644
index 0000000..320373b
--- /dev/null
+++ b/Assets/Scripts/Editor/VideoClipPlaylistItemDrawer.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 7623ddbca00a62a4c8718ae46341b87a
\ No newline at end of file