using System; using System.IO; using System.IO.Compression; using System.Net.Http; using System.Text; using System.Threading.Tasks; using MelonLoader; using MelonLoader.Utils; using O5Kit.Core; using O5Kit.Factory; using TMPro; using UnityEngine; using UnityEngine.UI; 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 O5Window? _window; 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 _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(); } public static FFmpegDownloadUI? Instance { get; private set; } private void Awake() { Instance = this; if (CheckFFmpegExists()) { Destroy(gameObject); } } private void Start() { // Set UI layer (5) gameObject.layer = 5; // Canvas setup var canvas = gameObject.AddComponent(); canvas.renderMode = RenderMode.ScreenSpaceOverlay; canvas.sortingOrder = 32767; // Topmost // Responsive Canvas Scaler setup var scaler = gameObject.AddComponent(); scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize; scaler.referenceResolution = new Vector2(1920, 1080); scaler.screenMatchMode = CanvasScaler.ScreenMatchMode.MatchWidthOrHeight; scaler.matchWidthOrHeight = 0.5f; gameObject.AddComponent(); var ctx = RendererUIManager.Context; // 1. Create full screen backdrop var bgPanelObj = new GameObject("Backdrop"); bgPanelObj.transform.SetParent(transform, false); bgPanelObj.layer = 5; var bgImage = bgPanelObj.AddComponent(); bgImage.color = new Color(0f, 0f, 0f, 0.65f); bgImage.raycastTarget = true; var bgRect = bgPanelObj.GetComponent(); bgRect.anchorMin = Vector2.zero; bgRect.anchorMax = Vector2.one; bgRect.offsetMin = Vector2.zero; bgRect.offsetMax = Vector2.zero; // 2. Create actual native O5Window _window = O5Window.Create(ctx, bgPanelObj.transform, new O5WindowOptions { Id = "RendererMod_FFmpegDownloadDialog", Title = "FFmpeg Library Installation Required", Size = new Vector2(580, 270), Draggable = true, Resizable = false, ShowCloseButton = false, ShowOutline = true, Padding = new RectOffset(16, 16, 16, 16) }); var content = _window.Content; var vLayout = content.gameObject.AddComponent(); vLayout.spacing = 14f; vLayout.padding = new RectOffset(16, 16, 10, 10); vLayout.childControlWidth = true; vLayout.childControlHeight = true; vLayout.childForceExpandWidth = true; vLayout.childForceExpandHeight = false; // 3. Description text var descText = O5Factory.ControlText(ctx, content, 18f, noPad: true); 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("(Download size: ~70MB, extracted to UserLibs folder)"); } descText.text = descStrBuilder.ToString(); descText.alignment = TextAlignmentOptions.Center; descText.textWrappingMode = TextWrappingModes.Normal; descText.lineSpacing = 5; descText.color = new Color(0.85f, 0.85f, 0.85f, 1f); var descLE = descText.gameObject.AddComponent(); descLE.flexibleWidth = 1f; descLE.flexibleHeight = 1f; // 4. Button container _buttonContainerObj = new GameObject("ButtonContainer"); _buttonContainerObj.transform.SetParent(content, false); _buttonContainerObj.layer = 5; var btnContainerLE = _buttonContainerObj.AddComponent(); btnContainerLE.preferredHeight = 40f; btnContainerLE.flexibleWidth = 1f; var btnLayout = _buttonContainerObj.AddComponent(); btnLayout.spacing = 10f; btnLayout.childAlignment = TextAnchor.MiddleCenter; btnLayout.childControlWidth = true; btnLayout.childControlHeight = true; btnLayout.childForceExpandWidth = true; btnLayout.childForceExpandHeight = true; Action onDownloadClick = Application.platform != RuntimePlatform.OSXPlayer ? StartDownloadProcess : () => { Application.OpenURL("https://ffmpeg.org"); Destroy(gameObject); }; var downloadBtn = O5Factory.Button( ctx: ctx, parent: _buttonContainerObj.transform, onClick: onDownloadClick, text: Application.platform != RuntimePlatform.OSXPlayer ? "Download & Install" : "Open Website", id: "btn_ffmpeg_download", height: 40f ); downloadBtn.NormalColor = ctx.Theme.ObjectActiveBright; downloadBtn.UpdateVisual(); var downloadBtnLE = downloadBtn.Rect.gameObject.AddComponent(); downloadBtnLE.flexibleWidth = 1f; downloadBtnLE.preferredHeight = 40f; var cancelBtn = O5Factory.Button( ctx: ctx, parent: _buttonContainerObj.transform, onClick: CancelProcess, text: "Cancel", id: "btn_ffmpeg_cancel", height: 40f ); var cancelBtnLE = cancelBtn.Rect.gameObject.AddComponent(); cancelBtnLE.flexibleWidth = 1f; cancelBtnLE.preferredHeight = 40f; // 5. Progress bar container _progressBarObj = new GameObject("ProgressBarContainer"); _progressBarObj.transform.SetParent(content, false); _progressBarObj.layer = 5; _progressBarObj.SetActive(false); var pbLE = _progressBarObj.AddComponent(); pbLE.preferredHeight = 24f; pbLE.flexibleWidth = 1f; var barBgImg = _progressBarObj.AddComponent(); barBgImg.sprite = ctx.Sprites.RoundedControl; barBgImg.type = Image.Type.Sliced; barBgImg.color = ctx.Theme.ObjectBG; var barOutlineObj = new GameObject("BarOutline"); barOutlineObj.transform.SetParent(_progressBarObj.transform, false); barOutlineObj.layer = 5; var barOutlineImg = barOutlineObj.AddComponent(); barOutlineImg.sprite = ctx.Sprites.RoundedOutline; barOutlineImg.type = Image.Type.Sliced; barOutlineImg.color = ctx.Theme.ControlOutlineIdle; barOutlineImg.raycastTarget = false; var barOutlineRect = barOutlineObj.GetComponent(); barOutlineRect.anchorMin = Vector2.zero; barOutlineRect.anchorMax = Vector2.one; barOutlineRect.offsetMin = Vector2.zero; barOutlineRect.offsetMax = Vector2.zero; // Progress bar fill image _progressBarFillObj = new GameObject("ProgressBarFill"); _progressBarFillObj.transform.SetParent(_progressBarObj.transform, false); _progressBarFillObj.layer = 5; var fillImg = _progressBarFillObj.AddComponent(); fillImg.sprite = ctx.Sprites.RoundedControl; fillImg.type = Image.Type.Sliced; fillImg.color = ctx.Theme.ObjectActiveBright; _fillRect = _progressBarFillObj.GetComponent(); _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; // Progress text var progressTextObj = new GameObject("ProgressBarText"); progressTextObj.transform.SetParent(_progressBarObj.transform, false); progressTextObj.layer = 5; _progressText = progressTextObj.AddComponent(); _progressText.fontSize = 13; _progressText.font = ctx.Fonts.Regular; _progressText.color = Color.white; _progressText.alignment = TextAlignmentOptions.Center; _progressText.raycastTarget = false; var progressTextRect = progressTextObj.GetComponent(); progressTextRect.anchorMin = Vector2.zero; progressTextRect.anchorMax = Vector2.one; progressTextRect.offsetMin = Vector2.zero; progressTextRect.offsetMax = Vector2.zero; // 6. Status text _statusTextObj = new GameObject("StatusText"); _statusTextObj.transform.SetParent(content, false); _statusTextObj.layer = 5; _statusTextObj.SetActive(false); var statusLE = _statusTextObj.AddComponent(); statusLE.preferredHeight = 22f; statusLE.flexibleWidth = 1f; _statusText = _statusTextObj.AddComponent(); _statusText.fontSize = 14; _statusText.font = ctx.Fonts.Regular; _statusText.color = new Color(0.8f, 0.8f, 0.8f, 1f); _statusText.alignment = TextAlignmentOptions.Center; _statusText.raycastTarget = false; var statusRect = _statusTextObj.GetComponent(); statusRect.anchorMin = Vector2.zero; statusRect.anchorMax = Vector2.one; statusRect.offsetMin = Vector2.zero; statusRect.offsetMax = Vector2.zero; } 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(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.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(); 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 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.Logger.Warning($"Failed to delete temporary zip file: {ex.Message}"); } } } } private void OnDestroy() { _window?.Dispose(); _window = null; _cts?.Dispose(); if (Instance == this) { Instance = null; } } }