424 lines
14 KiB
C#
424 lines
14 KiB
C#
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
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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;
|
|
|
|
/// <summary>Seconds left on the countdown, or 0 when it is not running.</summary>
|
|
public float RemainingSeconds { get; private set; }
|
|
|
|
/// <summary>True while a countdown is in progress.</summary>
|
|
public bool IsCounting => countdownRoutine != null;
|
|
|
|
public bool IsPlaying => player != null && player.isPlaying;
|
|
public bool IsPaused => player != null && player.isPaused;
|
|
|
|
/// <summary>Current playback position in seconds.</summary>
|
|
public double CurrentTime => player != null ? player.time : 0d;
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
public double Duration => player != null ? player.length : 0d;
|
|
|
|
private VideoPlayer player;
|
|
private Coroutine countdownRoutine;
|
|
|
|
// ---------------------------------------------------------------- lifecycle
|
|
|
|
private void Awake()
|
|
{
|
|
player = GetComponent<VideoPlayer>();
|
|
|
|
// 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
|
|
|
|
/// <summary>Plays the currently selected file from the beginning.</summary>
|
|
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();
|
|
}
|
|
|
|
/// <summary>Seeks to a position in seconds. Works while paused.</summary>
|
|
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();
|
|
}
|
|
|
|
/// <summary>Switches to a different file and restarts from frame zero.</summary>
|
|
public void SelectClip(int index)
|
|
{
|
|
if (fileNames == null || index < 0 || index >= fileNames.Length) return;
|
|
selectedIndex = index;
|
|
// PlaySelected();
|
|
}
|
|
|
|
// ---------------------------------------------------------------- helpers
|
|
|
|
/// <summary>Returns the streaming-assets URL for the selected file, or null.</summary>
|
|
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<VideoPlayer>();
|
|
}
|
|
|
|
// ---------------------------------------------------------------- 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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sets the VideoPlayer URL without playing. In Play mode it calls PlaySelected instead.
|
|
/// </summary>
|
|
private static void ApplyUrl(VideoClipPicker picker)
|
|
{
|
|
VideoPlayer player = picker.GetComponent<VideoPlayer>();
|
|
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 |