using System.Collections; using System.IO; using UnityEngine; using UnityEngine.Video; #if ENABLE_INPUT_SYSTEM && !ENABLE_LEGACY_INPUT_MANAGER using UnityEngine.InputSystem; #endif #if UNITY_EDITOR using UnityEditor; #endif /// /// Attach to the same GameObject as your Video Player. /// Fill the FileNames array with filenames relative to StreamingAssets (e.g. "myvideo.mp4"). /// Space runs a countdown then plays. P pauses and resumes. /// In Play mode the Inspector gains a transport: play/pause, a scrub bar, and a /// timestamp box for jumping to an exact position. /// /// Files must be placed in Assets/StreamingAssets/ and are NOT imported by Unity, /// so any codec (H.265 8K, HAP, etc.) is passed straight to the player. /// [RequireComponent(typeof(VideoPlayer))] public class VideoClipPicker : MonoBehaviour { [Header("Clips (filenames inside StreamingAssets/)")] [Tooltip("Filenames relative to StreamingAssets, e.g. \"myvideo.mp4\" or \"subfolder/clip.mp4\".")] public string[] fileNames = new string[0]; [HideInInspector] public int selectedIndex; [Header("Countdown")] [Tooltip("How long the countdown runs after Space is pressed.")] public float countdownSeconds = 3f; [Header("Playback")] [Tooltip("Pause automatically when the application loses focus.")] public bool autoPauseOnFocusLoss = false; /// Seconds left on the countdown, or 0 when it is not running. public float RemainingSeconds { get; private set; } /// True while a countdown is in progress. public bool IsCounting => countdownRoutine != null; public bool IsPlaying => player != null && player.isPlaying; public bool IsPaused => player != null && player.isPaused; /// Current playback position in seconds. public double CurrentTime => player != null ? player.time : 0d; /// /// Clip length in seconds. Only valid after the player has prepared the URL, /// so it returns 0 before playback starts (unlike the old VideoClip version /// which could read length from the imported asset). /// public double Duration => player != null ? player.length : 0d; private VideoPlayer player; private Coroutine countdownRoutine; // ---------------------------------------------------------------- lifecycle private void Awake() { player = GetComponent(); // URL mode: Unity must not try to import the source. player.source = VideoSource.Url; } private void Update() { if (SpacePressedThisFrame()) StartCountdown(); if (PauseKeyPressedThisFrame()) TogglePause(); } private void OnApplicationFocus(bool hasFocus) { if (autoPauseOnFocusLoss && !hasFocus) Pause(); } // ---------------------------------------------------------------- input private static bool SpacePressedThisFrame() { #if ENABLE_INPUT_SYSTEM && !ENABLE_LEGACY_INPUT_MANAGER return Keyboard.current != null && Keyboard.current.spaceKey.wasPressedThisFrame; #else return Input.GetKeyDown(KeyCode.Space); #endif } private static bool PauseKeyPressedThisFrame() { #if ENABLE_INPUT_SYSTEM && !ENABLE_LEGACY_INPUT_MANAGER return Keyboard.current != null && Keyboard.current.pKey.wasPressedThisFrame; #else return Input.GetKeyDown(KeyCode.P); #endif } // ---------------------------------------------------------------- countdown public void StartCountdown() { if (countdownRoutine != null) StopCoroutine(countdownRoutine); countdownRoutine = StartCoroutine(CountdownRoutine()); } public void CancelCountdown() { if (countdownRoutine != null) StopCoroutine(countdownRoutine); countdownRoutine = null; RemainingSeconds = 0f; } private IEnumerator CountdownRoutine() { RemainingSeconds = countdownSeconds; int lastLogged = -1; while (RemainingSeconds > 0f) { int whole = Mathf.CeilToInt(RemainingSeconds); if (whole != lastLogged) { Debug.Log($"Countdown: {whole}"); lastLogged = whole; } RemainingSeconds -= Time.deltaTime; yield return null; } RemainingSeconds = 0f; countdownRoutine = null; Debug.Log("Countdown: go"); PlaySelected(); yield return new WaitForSeconds(0.5f); // Show first frame and ready to resume Pause(); } // ---------------------------------------------------------------- transport /// Plays the currently selected file from the beginning. public void PlaySelected() { EnsurePlayer(); string url = GetSelectedUrl(); if (url == null) return; player.Pause(); player.source = VideoSource.Url; player.url = url; player.frame = 0; player.time = 0d; player.Play(); } public void Pause() { EnsurePlayer(); if (player.isPlaying) player.Pause(); } public void Resume() { EnsurePlayer(); if (string.IsNullOrEmpty(player.url)) { PlaySelected(); return; } player.Play(); } public void TogglePause() { EnsurePlayer(); if (player.isPlaying) Pause(); else Resume(); } /// Seeks to a position in seconds. Works while paused. public void Seek(double seconds) { EnsurePlayer(); double duration = Duration; if (duration <= 0d) return; // If no URL is loaded yet, load the selected one first. if (string.IsNullOrEmpty(player.url)) { player.source = VideoSource.Url; player.url = GetSelectedUrl(); } seconds = System.Math.Max(0d, System.Math.Min(seconds, duration - 0.05d)); bool wasPaused = !player.isPlaying; player.time = seconds; if (wasPaused && player.isPrepared) player.StepForward(); } /// Switches to a different file and restarts from frame zero. public void SelectClip(int index) { if (fileNames == null || index < 0 || index >= fileNames.Length) return; selectedIndex = index; // PlaySelected(); } // ---------------------------------------------------------------- helpers /// Returns the streaming-assets URL for the selected file, or null. public string GetSelectedUrl() { if (fileNames == null || fileNames.Length == 0) return null; string name = fileNames[Mathf.Clamp(selectedIndex, 0, fileNames.Length - 1)]; if (string.IsNullOrWhiteSpace(name)) return null; // Application.streamingAssetsPath is correct on all platforms. // On Android it returns a jar:// URL; on desktop a plain file path. // VideoPlayer.url accepts both. string path = Path.Combine(Application.streamingAssetsPath, name); #if UNITY_ANDROID && !UNITY_EDITOR // Android StreamingAssets live inside the APK; the path is already a // jar:// URI so we return it directly. return path; #else // Desktop/Editor: convert the OS path to a file:// URL so VideoPlayer // handles spaces and special characters correctly. return "file://" + path.Replace("\\", "/"); #endif } private void EnsurePlayer() { if (player == null) player = GetComponent(); } // ---------------------------------------------------------------- timestamps public static string FormatTime(double seconds) { if (seconds < 0d) seconds = 0d; int total = Mathf.FloorToInt((float)seconds); int h = total / 3600; int m = (total % 3600) / 60; int s = total % 60; return h > 0 ? $"{h}:{m:00}:{s:00}" : $"{m}:{s:00}"; } public static bool TryParseTimestamp(string text, out double seconds) { seconds = 0d; if (string.IsNullOrWhiteSpace(text)) return false; string[] parts = text.Trim().Split(':'); if (parts.Length > 3) return false; double result = 0d; for (int i = 0; i < parts.Length; i++) { if (!double.TryParse(parts[i], out double value) || value < 0d) return false; result = result * 60d + value; } seconds = result; return true; } } // ================================================================ Editor #if UNITY_EDITOR [CustomEditor(typeof(VideoClipPicker))] public class VideoClipPickerEditor : Editor { private string timestampField = "0:00"; private float pendingScrub = -1f; public override bool RequiresConstantRepaint() => Application.isPlaying; public override void OnInspectorGUI() { serializedObject.Update(); VideoClipPicker picker = (VideoClipPicker)target; // --- file name list --- EditorGUILayout.PropertyField(serializedObject.FindProperty("fileNames"), true); if (picker.fileNames != null && picker.fileNames.Length > 0) { EditorGUILayout.Space(); // Dropdown built from the raw filenames (no asset reference needed). string[] labels = new string[picker.fileNames.Length]; for (int i = 0; i < picker.fileNames.Length; i++) labels[i] = string.IsNullOrWhiteSpace(picker.fileNames[i]) ? $"(empty {i})" : picker.fileNames[i]; int index = Mathf.Clamp(picker.selectedIndex, 0, picker.fileNames.Length - 1); int newIndex = EditorGUILayout.Popup("Active Video", index, labels); if (newIndex != picker.selectedIndex) { Undo.RecordObject(picker, "Change Active Video"); picker.selectedIndex = newIndex; EditorUtility.SetDirty(picker); ApplyUrl(picker); } if (GUILayout.Button("Apply To Video Player")) ApplyUrl(picker); // Show the resolved URL so the developer can verify it. string url = picker.GetSelectedUrl(); EditorGUILayout.HelpBox( string.IsNullOrEmpty(url) ? "No file selected." : url, MessageType.None); } else { EditorGUILayout.HelpBox( "Add filenames above (e.g. \"myvideo.mp4\"). " + "Files must be placed in Assets/StreamingAssets/.", MessageType.Info); } EditorGUILayout.Space(); DrawPropertiesExcluding(serializedObject, "m_Script", "fileNames"); serializedObject.ApplyModifiedProperties(); EditorGUILayout.Space(); DrawTransport(picker); } private void DrawTransport(VideoClipPicker picker) { EditorGUILayout.LabelField("Transport", EditorStyles.boldLabel); if (!Application.isPlaying) { EditorGUILayout.HelpBox("Enter Play mode to scrub and pause.", MessageType.None); return; } double duration = picker.Duration; double current = picker.CurrentTime; using (new EditorGUILayout.HorizontalScope()) { if (GUILayout.Button(picker.IsPlaying ? "Pause" : "Play", GUILayout.Height(24))) picker.TogglePause(); if (GUILayout.Button("Restart", GUILayout.Height(24))) picker.PlaySelected(); } if (duration <= 0d) { EditorGUILayout.HelpBox( "Duration not available yet � the player must prepare the URL first. " + "Press Play above and wait a moment.", MessageType.None); return; } EditorGUI.BeginChangeCheck(); float shown = pendingScrub >= 0f ? pendingScrub : (float)current; float scrubbed = EditorGUILayout.Slider(shown, 0f, (float)duration); if (EditorGUI.EndChangeCheck()) pendingScrub = scrubbed; if (pendingScrub >= 0f && GUIUtility.hotControl == 0) { picker.Seek(pendingScrub); timestampField = VideoClipPicker.FormatTime(pendingScrub); pendingScrub = -1f; } EditorGUILayout.LabelField( $"{VideoClipPicker.FormatTime(current)} / {VideoClipPicker.FormatTime(duration)}"); using (new EditorGUILayout.HorizontalScope()) { timestampField = EditorGUILayout.TextField("Go To", timestampField); if (GUILayout.Button("Go", GUILayout.Width(48))) { if (VideoClipPicker.TryParseTimestamp(timestampField, out double seconds)) picker.Seek(seconds); else Debug.LogWarning($"Could not read timestamp \"{timestampField}\". Try 90, 1:30 or 0:01:30."); } if (GUILayout.Button("Now", GUILayout.Width(48))) timestampField = VideoClipPicker.FormatTime(current); } } /// /// Sets the VideoPlayer URL without playing. In Play mode it calls PlaySelected instead. /// private static void ApplyUrl(VideoClipPicker picker) { VideoPlayer player = picker.GetComponent(); if (player == null) return; if (Application.isPlaying) { picker.PlaySelected(); return; } string url = picker.GetSelectedUrl(); if (string.IsNullOrEmpty(url)) return; Undo.RecordObject(player, "Change Video URL"); player.Stop(); player.source = VideoSource.Url; player.url = url; player.frame = 0; player.time = 0d; EditorUtility.SetDirty(player); } } #endif