Hi! I know this thread is a little old, but I’ve found solutions at various sources, and I wanted to bundle the in one neat answer for future devs. My solution worked on Unity 2022, and Meta Quest 2 and 3 v65.
-
Get an AndroidManifest: go to → Project Settings - Player - Publishing Settings - Build - Custom Main Manifest
This will generate a AndroidManifest.xml in the /Assets/Plugins/Android/ folder
-
Get permissions:
using UnityEngine;
using UnityEngine.Android;
public class GetPermissionOnAndroid : MonoBehaviour
{
private void Start()
{
#if UNITY_ANDROID && !UNITY_EDITOR
GetExternalReadPermission();
#endif
}
private void GetExternalReadPermission()
{
GetPermission(Permission.ExternalStorageRead);
}
private static void GetExternalWritePermission()
{
GetPermission(Permission.ExternalStorageWrite);
}
private static void GetPermission(string permission)
{
if (Permission.HasUserAuthorizedPermission(permission))
{
Debug.Log("Permission is already granted.");
return; }
Debug.LogFormat("Requesting permission to {0}.", permission);
Permission.RequestUserPermission(permission);
}
}
- Get the path to the Movies folder. I’m using this code, which works on Android and MacOS. Haven’t been able to test this on Windows, but if anyone can comment on that, I can update if needed:
public static class LocalPathHelpers
{
public static string GetMoviesDirectoryPath()
{
var moviesPath = "";
#if UNITY_ANDROID && !UNITY_EDITOR
using var envClass = new UnityEngine.AndroidJavaClass("android.os.Environment");
using var moviesDir = envClass.CallStatic<UnityEngine.AndroidJavaObject>("getExternalStoragePublicDirectory", "Movies");
moviesPath = moviesDir.Call<string>("getAbsolutePath");
#elif UNITY_EDITOR_OSX || UNITY_STANDALONE_OSX
moviesPath = System.IO.Path.Combine(System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile), "Movies");
#elif UNITY_EDITOR_WIN || UNITY_STANDALONE_WIN
moviesPath = System.IO.Path.Combine(System.System.Environment.GetFolderPath(System.System.Environment.SpecialFolder.MyVideos));
#else
moviesPath = "Movies directory path is not supported on this platform.";
#endif
return moviesPath;
}
}
Call this by using var moviesPath = LocalPathHelpers.GetMoviesDirectoryPath();
and you should get a path to /storage/emulated/0/Movies
on Android.
- Code for managing getting the url of the video, managing the videoplayer, and the audio
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using TMPro;
using UnityEngine;
using UnityEngine.Video;
using Random = UnityEngine.Random;
public class VideoPlayerManager2 : MonoBehaviour
{
[Header("Required components")]
[SerializeField] private VideoPlayer m_videoPlayer;
[Tooltip("Put this on a child gameobject, so we can move it in space, and the sound will move with it. \n " +
"E.g. if you have a person speaking, and they're in a more or less fixed position, note where that is, and (in the m_clips) set the audioLocation to that position.")]
[SerializeField] private AudioSource m_audioSource;
[SerializeField] private Material m_renderMaterial;
[Header("Repeat Settings")]
[SerializeField] private bool m_repeatInfinitely = true;
[SerializeField] private bool m_playRandom = true;
[Header("Clip Settings")]
[SerializeField] [Range(0, 60)] private float m_beforeFirstClipPauseDuration = 5f;
[SerializeField] private List<VideoSettingsCustom> m_clips;
[SerializeField] [Range(0, 60)] private float m_afterEachClipPauseDuration = 2.5f;
[Header("Debug: Show text")]
[SerializeField] private TextMeshProUGUI m_infoText;
private RenderTexture _renderTexture;
private float _currentClipDuration;
private float _currentClipTime;
private Coroutine _playerCR;
private Vector2Int _dimensions;
private void OnValidate()
{
m_videoPlayer.source = VideoSource.Url;
for (var index = 0; index < m_clips.Count; index++)
{
var videoSetting = m_clips[index];
if (videoSetting.Set2D && videoSetting.AudioLocation != Vector3.zero)
{
Debug.LogWarning("For Clips index" + index + "the audioLocation is set to" + videoSetting.AudioLocation +
"but you also indicated that this should be a 2D sound (because for this index 'Set2D = true'). This is probably not what you want.");
}
if (!string.IsNullOrEmpty(videoSetting.ClipName))
{
Debug.LogWarning("ClipName cannot be empty");
}
}
}
private void Awake()
{
if (m_videoPlayer == null)
{
m_videoPlayer = GetComponentInChildren<VideoPlayer>(); // Finds it either on this object or on a child object
}
if (m_audioSource == null)
{
m_audioSource = GetComponentInChildren<AudioSource>(); // Put it on a child, that's best
}
if (m_infoText == null)
{
m_infoText = GetComponentInChildren<TextMeshProUGUI>();
}
}
private void Start()
{
if (m_videoPlayer == null || m_clips == null || m_clips.Count == 0 || m_audioSource == null)
{
Debug.LogError("VideoPlayer, Clips, or AudioSource not assigned.");
return; }
_playerCR = StartCoroutine(PlayerCR());
}
private IEnumerator PlayerCR()
{
yield return new WaitForSeconds(m_beforeFirstClipPauseDuration);
do {
var clipList = m_playRandom ? m_clips.OrderBy(x => Random.value).ToList() : m_clips;
foreach (var clip in clipList)
{
Debug.Log("Playing clip" + clip.ClipName + "from" + clipList.Count + "clips.");
GetURLAndPrepare(clip);
while (!m_videoPlayer.isPrepared)
{
Debug.Log("Preparing clip");
yield return new WaitForSeconds(0.1f);
}
CreateNewRenderTexture(clip);
SetAudioSourceSettings(clip);
GetCurrentClipDuration();
PlayClip();
yield return new WaitForSeconds(_currentClipDuration);
StopPlayingClip();
yield return new WaitForSeconds(m_afterEachClipPauseDuration);
}
} while (m_repeatInfinitely);
}
private void GetURLAndPrepare(VideoSettingsCustom clip)
{
var moviesPath = LocalPathHelpers.GetMoviesDirectoryPath();
m_videoPlayer.url = moviesPath + "/" + clip.ClipName;
m_videoPlayer.Prepare();
}
private void SetAudioSourceSettings(VideoSettingsCustom clip)
{
m_videoPlayer.SetTargetAudioSource(0, m_audioSource);
m_audioSource.spatialBlend = clip.Set2D ? 0 : 1;
m_audioSource.transform.position = clip.Set2D ? Vector3.zero : clip.AudioLocation;
}
private void GetCurrentClipDuration()
{
if (!m_videoPlayer.isPrepared)
{
Debug.LogWarning("VideoPlayer is not prepared yet. Cannot get the current clip duration.");
return; }
_currentClipDuration = Mathf.Round((float) m_videoPlayer.length);
}
private void CreateNewRenderTexture(VideoSettingsCustom videoSettings)
{
_dimensions.x = (int) m_videoPlayer.width;
_dimensions.y = (int) m_videoPlayer.height;
_renderTexture = new RenderTexture(_dimensions.x, _dimensions.y, 24, RenderTextureFormat.Default);
_renderTexture.name = "RenderTexture: " + _dimensions;
m_renderMaterial.mainTexture = _renderTexture;
m_videoPlayer.targetTexture = _renderTexture;
}
private void PlayClip()
{
m_videoPlayer.Play();
}
private void StopPlayingClip()
{
m_videoPlayer.Stop();
m_videoPlayer.clip = null;
m_renderMaterial.mainTexture = new RenderTexture(0, 0, 0);
}
[ContextMenu(nameof(ReshuffleVideos))]
public void ReshuffleVideos()
{
m_playRandom = true;
if (_playerCR != null)
{
StopPlayingClip();
StopCoroutine(_playerCR);
_playerCR = null;
}
_playerCR = StartCoroutine(PlayerCR());
}
private void Update()
{
SetInfoText();
}
private void SetInfoText()
{
if (m_infoText == null)
{
return;
}
if (!m_videoPlayer.isPlaying)
{
_currentClipTime = 0;
m_infoText.text = "No video playing";
return; }
_currentClipTime = Mathf.Round((float) m_videoPlayer.clockTime);
m_infoText.text = m_videoPlayer.url + "\n" + _currentClipTime + "\n" + _currentClipDuration + "\n" + _dimensions + "\n" +
"Randomize: " + m_playRandom;
}
private void OnDisable()
{
StopAllCoroutines();
}
}
[Serializable]
public class VideoSettingsCustom
{
[Header("Full name, without path, but with extension. E.g. 'myVideo.mp4'")]
public string ClipName;
public bool Set2D;
public Vector3 AudioLocation;
}
- In the Inspector, set the name of the clips in the ‘Clips’ list. Don’t put the full path there, but do list the extension: “Movie_1.mp4”
- Drop the movie(s) in the Movies folder of your device (I use SideQuest for Android). Make sure the name matches perfectly with the one you set in the inspector.
Make sure the videos are able to be loaded / rendered. I don’t know how strict Unity is. Mine were in h265 (recoded using Handbrake).
Good luck!