Files

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