Files
ADOFAIRenderer/UI/FFmpegDownloadUI.cs
T

692 lines
27 KiB
C#

using System;
using System.IO;
using System.IO.Compression;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
using MelonLoader;
using MelonLoader.Utils;
namespace ADOFAIRenderer.UI;
internal class FFmpegDownloadUI : MonoBehaviour
{
private GameObject _buttonContainerObj = null!;
private GameObject _progressBarObj = null!;
private GameObject _progressBarFillObj = null!;
private RectTransform _fillRect = null!;
private TextMeshProUGUI _progressText = null!;
private GameObject _statusTextObj = null!;
private TextMeshProUGUI _statusText = null!;
private bool _isDownloading;
private string DownloadUrl => Application.platform switch
{
RuntimePlatform.WindowsPlayer => "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-05-31-13-22/ffmpeg-N-124714-g49a77d37be-win64-gpl-shared.zip",
_ => "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-05-31-13-22/ffmpeg-N-124714-g49a77d37be-linux64-gpl-shared.tar.xz"
};
private System.Threading.CancellationTokenSource? _cts;
private readonly System.Collections.Concurrent.ConcurrentQueue<Action> _mainThreadQueue = new();
public static bool CheckFFmpegExists()
{
var userLibs = MelonEnvironment.UserLibsDirectory;
if (!Directory.Exists(userLibs)) return false;
string[] searchPatterns = Application.platform switch
{
RuntimePlatform.WindowsPlayer => new[]
{
"avcodec-*.dll", "avformat-*.dll", "avutil-*.dll", "swscale-*.dll", "swresample-*.dll"
},
RuntimePlatform.OSXPlayer => new[]
{
"libavcodec.*.dylib", "libavformat.*.dylib", "libavutil.*.dylib", "libswscale.*.dylib", "libswresample.*.dylib"
},
_ => new[]
{
"libavcodec.so*", "libavformat.so*", "libavutil.so*", "libswscale.so*", "libswresample.so*"
}
};
foreach (var pattern in searchPatterns)
{
if (Directory.GetFiles(userLibs, pattern).Length == 0)
{
return false;
}
}
return true;
}
public static void ShowDownloadUI()
{
if (CheckFFmpegExists()) return;
var obj = new GameObject("FFmpegDownloadUI");
DontDestroyOnLoad(obj);
obj.AddComponent<FFmpegDownloadUI>();
}
public static FFmpegDownloadUI? Instance { get; private set; }
private void Awake()
{
Instance = this;
if (CheckFFmpegExists())
{
Destroy(gameObject);
}
}
private static TMP_FontAsset GetNanumSquareNeoBdFontAsset()
{
TMP_FontAsset? font = null;
if (RenderMod.FontBundle)
{
try
{
font = RenderMod.FontBundle.LoadAsset<TMP_FontAsset>("NanumSquareNeoOTF-Bd SDF");
}
catch
{
// Loading failed
}
}
// Fallback processing
if (font == null)
{
try
{
font = RDString.fontData.fontTMP;
}
catch
{
// ignored
}
}
if (font == null)
{
// ReSharper disable once Unity.UnknownResource
font = Resources.Load<TMP_FontAsset>("Fonts & Materials/LiberationSans SDF");
}
if (font) return font;
var fonts = Resources.FindObjectsOfTypeAll<TMP_FontAsset>();
if (fonts.Length > 0) font = fonts[0];
return font!;
}
private void Start()
{
// Set UI layer (5)
gameObject.layer = 5;
// Canvas setup
var canvas = gameObject.AddComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
canvas.sortingOrder = 32767; // Topmost
// Responsive Canvas Scaler setup
var scaler = gameObject.AddComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1920, 1080);
scaler.screenMatchMode = CanvasScaler.ScreenMatchMode.MatchWidthOrHeight;
scaler.matchWidthOrHeight = 0.5f;
gameObject.AddComponent<GraphicRaycaster>();
// 1. Create full screen overlay background
var bgPanelObj = new GameObject("BackgroundPanel");
bgPanelObj.transform.SetParent(transform, false);
bgPanelObj.layer = 5;
var bgImage = bgPanelObj.AddComponent<Image>();
bgImage.color = new Color(0f, 0f, 0f, 0.65f); // Dark transparent overlay
bgImage.raycastTarget = true;
var bgRect = bgPanelObj.GetComponent<RectTransform>();
bgRect.anchorMin = Vector2.zero;
bgRect.anchorMax = Vector2.one;
bgRect.offsetMin = Vector2.zero;
bgRect.offsetMax = Vector2.zero;
var font = GetNanumSquareNeoBdFontAsset();
// 2. Create modal container
var modalContainerObj = new GameObject("ModalContainer");
modalContainerObj.transform.SetParent(bgPanelObj.transform, false);
modalContainerObj.layer = 5;
var modalImage = modalContainerObj.AddComponent<Image>();
modalImage.color = new Color(0.08f, 0.08f, 0.1f, 0.88f);
var modalOutline = modalContainerObj.AddComponent<Outline>();
modalOutline.effectColor = Color.white;
modalOutline.effectDistance = new Vector2(3.0f, -3.0f);
var modalRect = modalContainerObj.GetComponent<RectTransform>();
modalRect.anchorMin = new Vector2(0.5f, 0.5f);
modalRect.anchorMax = new Vector2(0.5f, 0.5f);
modalRect.pivot = new Vector2(0.5f, 0.5f);
modalRect.sizeDelta = new Vector2(600, 320);
// 4. Create title text
var titleObj = new GameObject("TitleText");
titleObj.transform.SetParent(modalContainerObj.transform, false);
titleObj.layer = 5;
var titleText = titleObj.AddComponent<TextMeshProUGUI>();
titleText.text = "FFmpeg Library Installation Required";
titleText.fontSize = 26;
titleText.font = font;
titleText.color = Color.white;
titleText.alignment = TextAlignmentOptions.Center;
var titleRect = titleObj.GetComponent<RectTransform>();
titleRect.anchorMin = new Vector2(0, 1);
titleRect.anchorMax = new Vector2(1, 1);
titleRect.pivot = new Vector2(0.5f, 1);
titleRect.anchoredPosition = new Vector2(0, -35);
titleRect.sizeDelta = new Vector2(0, 40);
// 5. Create description text
var descObj = new GameObject("DescriptionText");
descObj.transform.SetParent(modalContainerObj.transform, false);
descObj.layer = 5;
var descText = descObj.AddComponent<TextMeshProUGUI>();
var descStrBuilder = new StringBuilder();
descStrBuilder.AppendLine("FFmpeg shared libraries are required to render video.");
if (Application.platform == RuntimePlatform.OSXPlayer)
{
descStrBuilder.AppendLine("For macOS, reliable public builds are unavailable, so FFmpeg 8.1 must be built manually.");
descStrBuilder.AppendLine("Click the button below to open the official FFmpeg website.");
}
else
{
descStrBuilder.AppendLine("Click the button below to automatically download and install from the internet.");
descStrBuilder.AppendLine("<color=#aaaaaa><size=13>(Download size: ~70MB, extracted to UserLibs folder)</size></color>");
}
descText.text = descStrBuilder.ToString();
descText.fontSize = 16;
descText.font = font;
descText.color = new Color(0.85f, 0.85f, 0.85f, 1f);
descText.alignment = TextAlignmentOptions.Center;
descText.lineSpacing = 10;
var descRect = descObj.GetComponent<RectTransform>();
descRect.anchorMin = new Vector2(0, 0.5f);
descRect.anchorMax = new Vector2(1, 1);
descRect.pivot = new Vector2(0.5f, 0.5f);
descRect.anchoredPosition = new Vector2(0, -60);
descRect.sizeDelta = new Vector2(-40, 100);
// 6. Create button container
_buttonContainerObj = new GameObject("ButtonContainer", typeof(RectTransform));
_buttonContainerObj.transform.SetParent(modalContainerObj.transform, false);
_buttonContainerObj.layer = 5;
var btnContainerRect = _buttonContainerObj.GetComponent<RectTransform>();
btnContainerRect.anchorMin = new Vector2(0, 0);
btnContainerRect.anchorMax = new Vector2(1, 0);
btnContainerRect.pivot = new Vector2(0.5f, 0);
btnContainerRect.anchoredPosition = new Vector2(0, 35);
btnContainerRect.sizeDelta = new Vector2(0, 50);
// 7. Create download button
var downloadBtnObj = new GameObject("DownloadButton");
downloadBtnObj.transform.SetParent(_buttonContainerObj.transform, false);
downloadBtnObj.layer = 5;
var downloadImg = downloadBtnObj.AddComponent<Image>();
downloadImg.color = new Color(0.0f, 0.8f, 1.0f, 1.0f); // Sleek cyan/blue
downloadImg.raycastTarget = true;
var downloadBtn = downloadBtnObj.AddComponent<Button>();
SetupButtonStyle(downloadBtn, new Color(0.0f, 0.8f, 1.0f, 1.0f), new Color(0.2f, 0.9f, 1.0f, 1.0f), new Color(0.0f, 0.6f, 0.8f, 1.0f));
if (Application.platform != RuntimePlatform.OSXPlayer) downloadBtn.onClick.AddListener(StartDownloadProcess);
else
{
downloadBtn.onClick.AddListener(OpenFFmpegWebsite);
void OpenFFmpegWebsite() { Application.OpenURL("https://ffmpeg.org"); Destroy(gameObject); }
}
var downloadBtnRect = downloadBtnObj.GetComponent<RectTransform>();
downloadBtnRect.anchorMin = new Vector2(0.5f, 0.5f);
downloadBtnRect.anchorMax = new Vector2(0.5f, 0.5f);
downloadBtnRect.pivot = new Vector2(1f, 0.5f);
downloadBtnRect.anchoredPosition = new Vector2(-15, 0);
downloadBtnRect.sizeDelta = new Vector2(180, 45);
var downloadTextObj = new GameObject("DownloadText");
downloadTextObj.transform.SetParent(downloadBtnObj.transform, false);
downloadTextObj.layer = 5;
var downloadText = downloadTextObj.AddComponent<TextMeshProUGUI>();
downloadText.text = Application.platform != RuntimePlatform.OSXPlayer ? "Download & Install" : "Open Website";
downloadText.fontSize = 20;
downloadText.font = font;
downloadText.color = Color.white;
downloadText.alignment = TextAlignmentOptions.Center;
var downloadTextRect = downloadTextObj.GetComponent<RectTransform>();
downloadTextRect.anchorMin = Vector2.zero;
downloadTextRect.anchorMax = Vector2.one;
downloadTextRect.offsetMin = Vector2.zero;
downloadTextRect.offsetMax = Vector2.zero;
// 8. Create cancel button
var cancelBtnObj = new GameObject("CancelButton");
cancelBtnObj.transform.SetParent(_buttonContainerObj.transform, false);
cancelBtnObj.layer = 5;
var cancelImg = cancelBtnObj.AddComponent<Image>();
cancelImg.color = new Color(0.2f, 0.2f, 0.23f, 1f);
cancelImg.raycastTarget = true;
var cancelBtn = cancelBtnObj.AddComponent<Button>();
SetupButtonStyle(cancelBtn, new Color(0.2f, 0.2f, 0.23f, 1f), new Color(0.25f, 0.25f, 0.28f, 1f), new Color(0.15f, 0.15f, 0.18f, 1f));
cancelBtn.onClick.AddListener(CancelProcess);
var cancelBtnRect = cancelBtnObj.GetComponent<RectTransform>();
cancelBtnRect.anchorMin = new Vector2(0.5f, 0.5f);
cancelBtnRect.anchorMax = new Vector2(0.5f, 0.5f);
cancelBtnRect.pivot = new Vector2(0f, 0.5f);
cancelBtnRect.anchoredPosition = new Vector2(15, 0);
cancelBtnRect.sizeDelta = new Vector2(180, 45);
var cancelTextObj = new GameObject("CancelText");
cancelTextObj.transform.SetParent(cancelBtnObj.transform, false);
cancelTextObj.layer = 5;
var cancelText = cancelTextObj.AddComponent<TextMeshProUGUI>();
cancelText.text = "Cancel";
cancelText.fontSize = 20;
cancelText.font = font;
cancelText.color = new Color(0.9f, 0.9f, 0.9f, 1f);
cancelText.alignment = TextAlignmentOptions.Center;
var cancelTextRect = cancelTextObj.GetComponent<RectTransform>();
cancelTextRect.anchorMin = Vector2.zero;
cancelTextRect.anchorMax = Vector2.one;
cancelTextRect.offsetMin = Vector2.zero;
cancelTextRect.offsetMax = Vector2.zero;
// 9. Create progress bar container
_progressBarObj = new GameObject("ProgressBarContainer");
_progressBarObj.transform.SetParent(modalContainerObj.transform, false);
_progressBarObj.layer = 5;
_progressBarObj.SetActive(false);
var barBgImg = _progressBarObj.AddComponent<Image>();
barBgImg.color = new Color(0.08f, 0.08f, 0.1f, 1.0f);
var barBgOutline = _progressBarObj.AddComponent<Outline>();
barBgOutline.effectColor = Color.white;
barBgOutline.effectDistance = new Vector2(3.0f, -3.0f);
var barRect = _progressBarObj.GetComponent<RectTransform>();
barRect.anchorMin = new Vector2(0.5f, 0);
barRect.anchorMax = new Vector2(0.5f, 0);
barRect.pivot = new Vector2(0.5f, 0);
barRect.anchoredPosition = new Vector2(0, 75);
barRect.sizeDelta = new Vector2(480, 24);
// 10. Create progress bar fill image
_progressBarFillObj = new GameObject("ProgressBarFill");
_progressBarFillObj.transform.SetParent(_progressBarObj.transform, false);
_progressBarFillObj.layer = 5;
var fillImg = _progressBarFillObj.AddComponent<Image>();
fillImg.color = new Color(0.0f, 0.8f, 1.0f, 1.0f);
_fillRect = _progressBarFillObj.GetComponent<RectTransform>();
_fillRect.anchorMin = new Vector2(0, 0);
_fillRect.anchorMax = new Vector2(0, 1);
_fillRect.pivot = new Vector2(0, 0.5f);
_fillRect.anchoredPosition = Vector2.zero;
_fillRect.sizeDelta = Vector2.zero;
// 11. Create progress text
var progressTextObj = new GameObject("ProgressBarText");
progressTextObj.transform.SetParent(_progressBarObj.transform, false);
progressTextObj.layer = 5;
_progressText = progressTextObj.AddComponent<TextMeshProUGUI>();
_progressText.fontSize = 16;
_progressText.font = font;
_progressText.color = Color.white;
_progressText.alignment = TextAlignmentOptions.Center;
var progressTextOutline = progressTextObj.AddComponent<Outline>();
progressTextOutline.effectColor = new Color(0, 0, 0, 0.8f);
progressTextOutline.effectDistance = new Vector2(1, -1);
var progressTextRect = progressTextObj.GetComponent<RectTransform>();
progressTextRect.anchorMin = Vector2.zero;
progressTextRect.anchorMax = Vector2.one;
progressTextRect.offsetMin = Vector2.zero;
progressTextRect.offsetMax = Vector2.zero;
// 12. Create status text
_statusTextObj = new GameObject("StatusText");
_statusTextObj.transform.SetParent(modalContainerObj.transform, false);
_statusTextObj.layer = 5;
_statusTextObj.SetActive(false);
_statusText = _statusTextObj.AddComponent<TextMeshProUGUI>();
_statusText.fontSize = 16;
_statusText.font = font;
_statusText.color = new Color(0.8f, 0.8f, 0.8f, 1f);
_statusText.alignment = TextAlignmentOptions.Center;
var statusRect = _statusTextObj.GetComponent<RectTransform>();
statusRect.anchorMin = new Vector2(0, 0);
statusRect.anchorMax = new Vector2(1, 0);
statusRect.pivot = new Vector2(0.5f, 0);
statusRect.anchoredPosition = new Vector2(0, 35);
statusRect.sizeDelta = new Vector2(0, 30);
}
private void SetupButtonStyle(Button button, Color normalColor, Color hoverColor, Color pressedColor)
{
button.transition = Selectable.Transition.ColorTint;
var colors = button.colors;
colors.normalColor = normalColor;
colors.highlightedColor = hoverColor;
colors.pressedColor = pressedColor;
colors.disabledColor = new Color(0.3f, 0.3f, 0.3f, 1f);
button.colors = colors;
}
private void Update()
{
// Execute queued actions on main thread
while (_mainThreadQueue.TryDequeue(out var action))
{
action?.Invoke();
}
}
private void ExecuteOnMainThread(Action action)
{
_mainThreadQueue.Enqueue(action);
}
private void StartDownloadProcess()
{
if (_isDownloading) return;
_isDownloading = true;
// Hide buttons and show progress bar & status text
_buttonContainerObj.SetActive(false);
_progressBarObj.SetActive(true);
_statusTextObj.SetActive(true);
_cts = new System.Threading.CancellationTokenSource();
// Start download in background thread
Task.Run(async () =>
{
string tempZipPath = "";
try
{
UpdateStatus("Starting download...", new Color(0.8f, 0.8f, 0.8f));
UpdateProgress(0f, "0%");
var userLibsDir = MelonEnvironment.UserLibsDirectory;
if (!Directory.Exists(userLibsDir))
{
Directory.CreateDirectory(userLibsDir);
}
string extension = Application.platform switch
{
RuntimePlatform.WindowsPlayer => ".zip",
RuntimePlatform.OSXPlayer => ".zip",
_ => ".tar.xz"
};
tempZipPath = Path.Combine(userLibsDir, "ffmpeg_temp" + extension);
var progressReporter = new Progress<float>(percent =>
{
UpdateProgress(percent, $"{percent * 100f:F1}%");
UpdateStatus($"Downloading... ({(percent * 100f):F1}%)", new Color(0f, 0.8f, 1f));
});
await DownloadFileAsync(DownloadUrl, tempZipPath, progressReporter, _cts.Token);
// Extract after download complete
UpdateStatus("Extracting and copying library files...", new Color(0.9f, 0.9f, 0.2f));
UpdateProgress(0.99f, "Extracting...");
await ExtractDllsAsync(tempZipPath, userLibsDir);
// Update completion status
UpdateStatus("FFmpeg installation complete!", new Color(0.2f, 0.9f, 0.2f));
UpdateProgress(1f, "100%");
await Task.Delay(1000);
// Destroy UI on main thread
ExecuteOnMainThread(() =>
{
Destroy(gameObject);
});
}
catch (OperationCanceledException)
{
// Clean up temporary file on cancellation
if (!string.IsNullOrEmpty(tempZipPath) && File.Exists(tempZipPath))
{
try { File.Delete(tempZipPath); }
catch
{
// ignored
}
}
UpdateStatus("Download was cancelled.", new Color(0.9f, 0.5f, 0.2f));
}
catch (Exception ex)
{
Melon<RenderMod>.Logger.Error($"Error during FFmpeg download/installation: {ex}");
UpdateStatus($"Installation failed: {ex.Message}", new Color(1f, 0.3f, 0.3f));
// Reset buttons on failure
ExecuteOnMainThread(() =>
{
_isDownloading = false;
_buttonContainerObj.SetActive(true);
var downloadBtnText = _buttonContainerObj.transform.Find("DownloadButton/DownloadText")?.GetComponent<TextMeshProUGUI>();
if (downloadBtnText)
{
downloadBtnText.text = "Retry";
}
});
}
});
}
private void CancelProcess()
{
if (_isDownloading)
{
_cts?.Cancel();
}
Destroy(gameObject);
}
private void UpdateStatus(string message, Color color)
{
ExecuteOnMainThread(() =>
{
if (_statusText)
{
_statusText.text = message;
_statusText.color = color;
}
});
}
private void UpdateProgress(float percent, string text)
{
ExecuteOnMainThread(() =>
{
if (_fillRect)
{
_fillRect.anchorMax = new Vector2(percent, 1f);
}
if (_progressText)
{
_progressText.text = text;
}
});
}
private async Task DownloadFileAsync(string url, string destinationPath, IProgress<float> progress, System.Threading.CancellationToken cancellationToken)
{
using var client = new HttpClient();
// Add User-Agent header to prevent blocking
client.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) ADOFAIRenderer/1.0");
using var response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
response.EnsureSuccessStatusCode();
var totalBytes = response.Content.Headers.ContentLength ?? -1L;
await using var contentStream = await response.Content.ReadAsStreamAsync();
await using var fileStream = new FileStream(destinationPath, FileMode.Create, FileAccess.Write, FileShare.None, 8192, true);
var buffer = new byte[8192];
var totalRead = 0L;
int read;
while ((read = await contentStream.ReadAsync(buffer, 0, buffer.Length, cancellationToken)) > 0)
{
cancellationToken.ThrowIfCancellationRequested();
await fileStream.WriteAsync(buffer, 0, read, cancellationToken);
totalRead += read;
if (totalBytes == -1) continue;
var progressPercent = (float)totalRead / totalBytes;
progress.Report(progressPercent);
}
}
private async Task ExtractDllsAsync(string zipPath, string outputDirectory)
{
try
{
if (Application.platform == RuntimePlatform.OSXPlayer || Application.platform == RuntimePlatform.WindowsPlayer)
{
await Task.Run(() =>
{
var targetExtension = Application.platform == RuntimePlatform.OSXPlayer ? ".dylib" : ".dll";
using var archive = ZipFile.OpenRead(zipPath);
foreach (var entry in archive.Entries)
{
if (entry.FullName.EndsWith(targetExtension, StringComparison.OrdinalIgnoreCase))
{
var targetPath = Path.Combine(outputDirectory, entry.Name);
var dir = Path.GetDirectoryName(targetPath);
if (dir != null && !Directory.Exists(dir))
{
Directory.CreateDirectory(dir);
}
entry.ExtractToFile(targetPath, overwrite: true);
}
}
});
}
else
{
// Linux (or other platforms) - extract tar.xz
await Task.Run(() =>
{
var tempExtractDir = Path.Combine(outputDirectory, "ffmpeg_temp_extracted");
if (Directory.Exists(tempExtractDir))
{
Directory.Delete(tempExtractDir, true);
}
Directory.CreateDirectory(tempExtractDir);
var archiveUnixPath = zipPath.Replace('\\', '/');
var tempExtractUnixDir = tempExtractDir.Replace('\\', '/');
var startInfo = new System.Diagnostics.ProcessStartInfo
{
FileName = "tar",
Arguments = $"-xf \"{archiveUnixPath}\" -C \"{tempExtractUnixDir}\"",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using (var process = System.Diagnostics.Process.Start(startInfo))
{
process!.WaitForExit();
if (process.ExitCode != 0)
{
var error = process.StandardError.ReadToEnd();
throw new Exception($"tar extraction failed (ExitCode: {process.ExitCode}): {error}");
}
}
// Find .so files and copy to outputDirectory
var soFiles = Directory.GetFiles(tempExtractDir, "*.so*", SearchOption.AllDirectories);
foreach (var soFile in soFiles)
{
var fileName = Path.GetFileName(soFile);
var targetPath = Path.Combine(outputDirectory, fileName);
File.Copy(soFile, targetPath, true);
}
// Clean up temporary directory
try
{
Directory.Delete(tempExtractDir, true);
}
catch
{
// ignored
}
});
}
}
finally
{
if (File.Exists(zipPath))
{
try
{
File.Delete(zipPath);
}
catch (Exception ex)
{
Melon<RenderMod>.Logger.Warning($"Failed to delete temporary zip file: {ex.Message}");
}
}
}
}
private void OnDestroy()
{
_cts?.Dispose();
if (Instance == this)
{
Instance = null;
}
}
}