SHA256
720 lines
33 KiB
C#
720 lines
33 KiB
C#
using System;
|
|
using ADOFAIRenderer.Localization;
|
|
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;
|
|
|
|
/// <summary>
|
|
/// Modal dialog that installs the FFmpeg shared libraries into MelonLoader's UserLibs folder.
|
|
/// <para>
|
|
/// Shown by <c>FirstUpdateHook</c> when <see cref="CheckFFmpegExists"/> reports missing libraries. On Windows and Linux
|
|
/// the "Download" button downloads a prebuilt BtbN FFmpeg build (GPL, shared libraries) and extracts the libraries
|
|
/// into UserLibs; on macOS the button opens https://ffmpeg.org, because the dialog text tells the user to build FFmpeg
|
|
/// manually there. The libraries are found by FFmpeg.AutoGen through <c>ffmpeg.RootPath</c> (set to UserLibs in
|
|
/// <c>RenderMod.OnInitializeMelon</c>).
|
|
/// </para>
|
|
/// <para>
|
|
/// Threads: UI construction and all Unity object access happen on the Unity main thread. The download and extraction
|
|
/// run on thread-pool threads (<see cref="Task.Run(Func{Task})"/>); they post UI updates to
|
|
/// <see cref="_mainThreadQueue"/>, which <see cref="Update"/> drains on the main thread.
|
|
/// </para>
|
|
/// <para>
|
|
/// Flow: buttons -> (Download) progress bar + status text -> download to a temporary archive in UserLibs -> extract
|
|
/// libraries -> "complete" for 1 second -> dialog destroyed. On failure the status shows the error and the buttons
|
|
/// return so the user can retry or cancel. Cancel destroys the dialog at once and cancels a running download.
|
|
/// </para>
|
|
/// </summary>
|
|
internal class FFmpegDownloadUI : MonoBehaviour
|
|
{
|
|
/// <summary>Row with the Download/Open Website and Cancel buttons; hidden while a download runs.</summary>
|
|
private GameObject _buttonContainerObj = null!;
|
|
/// <summary>Progress bar background (rounded, theme colors); shown when the download starts.</summary>
|
|
private GameObject _progressBarObj = null!;
|
|
/// <summary>Progress bar fill object.</summary>
|
|
private GameObject _progressBarFillObj = null!;
|
|
/// <summary>Fill rect; its <c>anchorMax.x</c> is the progress fraction 0..1 (see <see cref="UpdateProgress"/>).</summary>
|
|
private RectTransform _fillRect = null!;
|
|
/// <summary>Text centered on the progress bar (percentage or "Extracting...").</summary>
|
|
private TextMeshProUGUI _progressText = null!;
|
|
/// <summary>Status line below the progress bar; shown when the download starts.</summary>
|
|
private GameObject _statusTextObj = null!;
|
|
/// <summary>Status message, colored by state (gray starting, cyan downloading, yellow extracting, green done, red failed).</summary>
|
|
private TextMeshProUGUI _statusText = null!;
|
|
|
|
/// <summary>O5Kit window holding the dialog contents; disposed in <see cref="OnDestroy"/>.</summary>
|
|
private O5Window? _window;
|
|
/// <summary>True from the Download click until the download fails; prevents a second concurrent download.</summary>
|
|
private bool _isDownloading;
|
|
|
|
/// <summary>
|
|
/// Archive URL for the current platform: a fixed BtbN FFmpeg-Builds autobuild (2026-05-31, FFmpeg master
|
|
/// N-124714-g49a77d37be, GPL, shared libraries). Windows gets the win64 .zip; every other platform gets the
|
|
/// linux64 .tar.xz (macOS never downloads, see <see cref="Start"/>).
|
|
/// </summary>
|
|
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"
|
|
};
|
|
|
|
/// <summary>Cancellation for the running download; created per download, cancelled by Cancel, disposed in OnDestroy.</summary>
|
|
private System.Threading.CancellationTokenSource? _cts;
|
|
/// <summary>Actions posted from background threads, run in order on the main thread by <see cref="Update"/>.</summary>
|
|
private readonly System.Collections.Concurrent.ConcurrentQueue<Action> _mainThreadQueue = new();
|
|
|
|
/// <summary>
|
|
/// Whether UserLibs contains at least one file for each of the five FFmpeg libraries the mod uses
|
|
/// (avcodec, avformat, avutil, swscale, swresample), matched by platform-specific file name patterns:
|
|
/// <c>avcodec-*.dll</c> on Windows, <c>libavcodec.*.dylib</c> on macOS, <c>libavcodec.so*</c> elsewhere.
|
|
/// Only file presence is checked, not the library version or whether it loads.
|
|
/// </summary>
|
|
/// <returns><c>false</c> when UserLibs does not exist or any library is missing.</returns>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates the dialog on a new GameObject kept across scene loads, unless the libraries are already present.
|
|
/// Main thread only.
|
|
/// </summary>
|
|
public static void ShowDownloadUI()
|
|
{
|
|
|
|
if (CheckFFmpegExists()) return;
|
|
|
|
var obj = new GameObject("FFmpegDownloadUI");
|
|
DontDestroyOnLoad(obj);
|
|
obj.AddComponent<FFmpegDownloadUI>();
|
|
}
|
|
|
|
/// <summary>
|
|
/// The open dialog, or <c>null</c> after it is destroyed. <c>FirstUpdateHook</c> waits while it is set and checks
|
|
/// the libraries again after it closes.
|
|
/// </summary>
|
|
public static FFmpegDownloadUI? Instance { get; private set; }
|
|
|
|
/// <summary>Registers <see cref="Instance"/>; destroys the dialog again when the libraries already exist.</summary>
|
|
private void Awake()
|
|
{
|
|
Instance = this;
|
|
if (CheckFFmpegExists())
|
|
{
|
|
Destroy(gameObject);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds the canvas, backdrop and window: description, button row, and the hidden progress bar and status line.
|
|
/// Layout values are canvas units at the 1920x1080 reference resolution.
|
|
/// </summary>
|
|
private void Start()
|
|
{
|
|
// Set UI layer (5, Unity's built-in "UI" layer)
|
|
gameObject.layer = 5;
|
|
|
|
// Canvas setup
|
|
var canvas = gameObject.AddComponent<Canvas>();
|
|
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
|
|
canvas.sortingOrder = 32767; // Topmost
|
|
|
|
// Canvas Scaler: 1920x1080 reference resolution, width and height matched equally (0.5)
|
|
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>();
|
|
|
|
var ctx = RendererUIManager.Context;
|
|
|
|
// 1. Create full screen backdrop: black at 65% opacity, blocks clicks to everything below
|
|
var bgPanelObj = new GameObject("Backdrop");
|
|
bgPanelObj.transform.SetParent(transform, false);
|
|
bgPanelObj.layer = 5;
|
|
|
|
var bgImage = bgPanelObj.AddComponent<Image>();
|
|
bgImage.color = new Color(0f, 0f, 0f, 0.65f);
|
|
bgImage.raycastTarget = true;
|
|
|
|
var bgRect = bgPanelObj.GetComponent<RectTransform>();
|
|
bgRect.anchorMin = Vector2.zero;
|
|
bgRect.anchorMax = Vector2.one;
|
|
bgRect.offsetMin = Vector2.zero;
|
|
bgRect.offsetMax = Vector2.zero;
|
|
|
|
// 2. Create the O5Kit window: 580x270 units, draggable, fixed size, no close button (Cancel closes it).
|
|
// The title is localized once here; the dialog does not follow later language changes.
|
|
_window = O5Window.Create(ctx, bgPanelObj.transform, new O5WindowOptions
|
|
{
|
|
Id = "RendererMod_FFmpegDownloadDialog",
|
|
Title = LocalizationManager.Get("ui.ffmpeg_download.title"),
|
|
Size = new Vector2(580, 270),
|
|
Draggable = true,
|
|
Resizable = false,
|
|
ShowCloseButton = false,
|
|
ShowOutline = true,
|
|
Padding = new RectOffset(16, 16, 16, 16)
|
|
});
|
|
|
|
// Vertical stack: description (takes the remaining height), buttons (40), progress bar (24), status (22),
|
|
// 14 units apart
|
|
var content = _window.Content;
|
|
|
|
var vLayout = content.gameObject.AddComponent<VerticalLayoutGroup>();
|
|
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: "required" line, then either the macOS build/website lines or the download/size lines
|
|
var descText = O5Factory.ControlText(ctx, content, 18f, noPad: true);
|
|
var descStrBuilder = new StringBuilder();
|
|
descStrBuilder.AppendLine(LocalizationManager.Get("ui.ffmpeg_download.desc_required"));
|
|
if (Application.platform == RuntimePlatform.OSXPlayer)
|
|
{
|
|
descStrBuilder.AppendLine(LocalizationManager.Get("ui.ffmpeg_download.desc_mac_build"));
|
|
descStrBuilder.AppendLine(LocalizationManager.Get("ui.ffmpeg_download.desc_mac_open"));
|
|
}
|
|
else
|
|
{
|
|
descStrBuilder.AppendLine(LocalizationManager.Get("ui.ffmpeg_download.desc_download"));
|
|
descStrBuilder.AppendLine(LocalizationManager.Get("ui.ffmpeg_download.desc_size"));
|
|
}
|
|
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<LayoutElement>();
|
|
descLE.flexibleWidth = 1f;
|
|
descLE.flexibleHeight = 1f;
|
|
|
|
// 4. Button container: two equal-width 40-unit buttons, 10 units apart
|
|
_buttonContainerObj = new GameObject("ButtonContainer");
|
|
_buttonContainerObj.transform.SetParent(content, false);
|
|
_buttonContainerObj.layer = 5;
|
|
|
|
var btnContainerLE = _buttonContainerObj.AddComponent<LayoutElement>();
|
|
btnContainerLE.preferredHeight = 40f;
|
|
btnContainerLE.flexibleWidth = 1f;
|
|
|
|
var btnLayout = _buttonContainerObj.AddComponent<HorizontalLayoutGroup>();
|
|
btnLayout.spacing = 10f;
|
|
btnLayout.childAlignment = TextAnchor.MiddleCenter;
|
|
btnLayout.childControlWidth = true;
|
|
btnLayout.childControlHeight = true;
|
|
btnLayout.childForceExpandWidth = true;
|
|
btnLayout.childForceExpandHeight = true;
|
|
|
|
// Primary button: download and install (Windows/Linux), or open the FFmpeg website and close the dialog (macOS)
|
|
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: LocalizationManager.Get(Application.platform != RuntimePlatform.OSXPlayer ? "ui.ffmpeg_download.btn_download" : "ui.ffmpeg_download.btn_open_site"),
|
|
id: "btn_ffmpeg_download",
|
|
height: 40f
|
|
);
|
|
downloadBtn.NormalColor = ctx.Theme.ObjectActiveBright;
|
|
downloadBtn.UpdateVisual();
|
|
|
|
var downloadBtnLE = downloadBtn.Rect.gameObject.AddComponent<LayoutElement>();
|
|
downloadBtnLE.flexibleWidth = 1f;
|
|
downloadBtnLE.preferredHeight = 40f;
|
|
|
|
var cancelBtn = O5Factory.Button(
|
|
ctx: ctx,
|
|
parent: _buttonContainerObj.transform,
|
|
onClick: CancelProcess,
|
|
text: LocalizationManager.Get("ui.ffmpeg_download.btn_cancel"),
|
|
id: "btn_ffmpeg_cancel",
|
|
height: 40f
|
|
);
|
|
var cancelBtnLE = cancelBtn.Rect.gameObject.AddComponent<LayoutElement>();
|
|
cancelBtnLE.flexibleWidth = 1f;
|
|
cancelBtnLE.preferredHeight = 40f;
|
|
|
|
// 5. Progress bar container (hidden until the download starts): rounded background in the theme object color
|
|
// with a rounded outline drawn over it
|
|
_progressBarObj = new GameObject("ProgressBarContainer");
|
|
_progressBarObj.transform.SetParent(content, false);
|
|
_progressBarObj.layer = 5;
|
|
_progressBarObj.SetActive(false);
|
|
|
|
var pbLE = _progressBarObj.AddComponent<LayoutElement>();
|
|
pbLE.preferredHeight = 24f;
|
|
pbLE.flexibleWidth = 1f;
|
|
|
|
var barBgImg = _progressBarObj.AddComponent<Image>();
|
|
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<Image>();
|
|
barOutlineImg.sprite = ctx.Sprites.RoundedOutline;
|
|
barOutlineImg.type = Image.Type.Sliced;
|
|
barOutlineImg.color = ctx.Theme.ControlOutlineIdle;
|
|
barOutlineImg.raycastTarget = false;
|
|
|
|
var barOutlineRect = barOutlineObj.GetComponent<RectTransform>();
|
|
barOutlineRect.anchorMin = Vector2.zero;
|
|
barOutlineRect.anchorMax = Vector2.one;
|
|
barOutlineRect.offsetMin = Vector2.zero;
|
|
barOutlineRect.offsetMax = Vector2.zero;
|
|
|
|
// Progress bar fill image: anchored at the left edge with zero size offset, so its width is set entirely by
|
|
// anchorMax.x (0 = empty, 1 = full) in UpdateProgress
|
|
_progressBarFillObj = new GameObject("ProgressBarFill");
|
|
_progressBarFillObj.transform.SetParent(_progressBarObj.transform, false);
|
|
_progressBarFillObj.layer = 5;
|
|
|
|
var fillImg = _progressBarFillObj.AddComponent<Image>();
|
|
fillImg.sprite = ctx.Sprites.RoundedControl;
|
|
fillImg.type = Image.Type.Sliced;
|
|
fillImg.color = ctx.Theme.ObjectActiveBright;
|
|
|
|
_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;
|
|
|
|
// Progress text: 13 pt white, centered over the bar
|
|
var progressTextObj = new GameObject("ProgressBarText");
|
|
progressTextObj.transform.SetParent(_progressBarObj.transform, false);
|
|
progressTextObj.layer = 5;
|
|
|
|
_progressText = progressTextObj.AddComponent<TextMeshProUGUI>();
|
|
_progressText.fontSize = 13;
|
|
_progressText.font = ctx.Fonts.Regular;
|
|
_progressText.color = Color.white;
|
|
_progressText.alignment = TextAlignmentOptions.Center;
|
|
_progressText.raycastTarget = false;
|
|
|
|
var progressTextRect = progressTextObj.GetComponent<RectTransform>();
|
|
progressTextRect.anchorMin = Vector2.zero;
|
|
progressTextRect.anchorMax = Vector2.one;
|
|
progressTextRect.offsetMin = Vector2.zero;
|
|
progressTextRect.offsetMax = Vector2.zero;
|
|
|
|
// 6. Status text (hidden until the download starts): 14 pt, centered
|
|
_statusTextObj = new GameObject("StatusText");
|
|
_statusTextObj.transform.SetParent(content, false);
|
|
_statusTextObj.layer = 5;
|
|
_statusTextObj.SetActive(false);
|
|
|
|
var statusLE = _statusTextObj.AddComponent<LayoutElement>();
|
|
statusLE.preferredHeight = 22f;
|
|
statusLE.flexibleWidth = 1f;
|
|
|
|
_statusText = _statusTextObj.AddComponent<TextMeshProUGUI>();
|
|
_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<RectTransform>();
|
|
statusRect.anchorMin = Vector2.zero;
|
|
statusRect.anchorMax = Vector2.one;
|
|
statusRect.offsetMin = Vector2.zero;
|
|
statusRect.offsetMax = Vector2.zero;
|
|
}
|
|
|
|
/// <summary>Runs every action posted by the background download task, in posting order.</summary>
|
|
private void Update()
|
|
{
|
|
// Execute queued actions on main thread
|
|
while (_mainThreadQueue.TryDequeue(out var action))
|
|
{
|
|
action?.Invoke();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Posts an action to run on the main thread during the next <see cref="Update"/>. Safe to call from any thread.
|
|
/// Actions posted after the dialog is destroyed are never run.
|
|
/// </summary>
|
|
private void ExecuteOnMainThread(Action action)
|
|
{
|
|
_mainThreadQueue.Enqueue(action);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Download button handler (Windows/Linux): switches the dialog to progress mode and starts the background task
|
|
/// that downloads the archive, extracts the libraries and closes the dialog. Ignored while a download runs.
|
|
/// </summary>
|
|
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. Everything inside runs on thread-pool threads: Unity objects are only
|
|
// touched through UpdateStatus/UpdateProgress/ExecuteOnMainThread. The task is not awaited; its outcome is
|
|
// reported through the status text.
|
|
Task.Run(async () =>
|
|
{
|
|
string tempZipPath = "";
|
|
try
|
|
{
|
|
UpdateStatus(LocalizationManager.Get("ui.ffmpeg_download.status_starting"), new Color(0.8f, 0.8f, 0.8f));
|
|
UpdateProgress(0f, "0%");
|
|
|
|
var userLibsDir = MelonEnvironment.UserLibsDirectory;
|
|
if (!Directory.Exists(userLibsDir))
|
|
{
|
|
Directory.CreateDirectory(userLibsDir);
|
|
}
|
|
|
|
// Temporary archive path in UserLibs: ffmpeg_temp.zip (Windows, macOS) or ffmpeg_temp.tar.xz (others).
|
|
// It is deleted after extraction (ExtractDllsAsync) or on cancellation.
|
|
string extension = Application.platform switch
|
|
{
|
|
RuntimePlatform.WindowsPlayer => ".zip",
|
|
RuntimePlatform.OSXPlayer => ".zip",
|
|
_ => ".tar.xz"
|
|
};
|
|
tempZipPath = Path.Combine(userLibsDir, "ffmpeg_temp" + extension);
|
|
|
|
// percent is the downloaded fraction 0..1. Progress<T> created on a thread-pool thread (no
|
|
// SynchronizationContext) invokes this callback on thread-pool threads; it only posts UI updates.
|
|
var progressReporter = new Progress<float>(percent =>
|
|
{
|
|
UpdateProgress(percent, $"{percent * 100f:F1}%");
|
|
UpdateStatus(LocalizationManager.Get("ui.ffmpeg_download.status_downloading", $"{percent * 100f:F1}"), new Color(0f, 0.8f, 1f));
|
|
});
|
|
|
|
await DownloadFileAsync(DownloadUrl, tempZipPath, progressReporter, _cts.Token);
|
|
|
|
// Extract after download complete; the bar is held at 99% while extracting
|
|
UpdateStatus(LocalizationManager.Get("ui.ffmpeg_download.status_extracting"), new Color(0.9f, 0.9f, 0.2f));
|
|
UpdateProgress(0.99f, LocalizationManager.Get("ui.ffmpeg_download.progress_extracting"));
|
|
|
|
await ExtractDllsAsync(tempZipPath, userLibsDir);
|
|
|
|
// Update completion status
|
|
UpdateStatus(LocalizationManager.Get("ui.ffmpeg_download.status_complete"), new Color(0.2f, 0.9f, 0.2f));
|
|
UpdateProgress(1f, "100%");
|
|
|
|
// Keep the "complete" message visible for 1000 ms before closing
|
|
await Task.Delay(1000);
|
|
|
|
// Destroy UI on main thread. FirstUpdateHook then sees Instance == null and checks the libraries again.
|
|
ExecuteOnMainThread(() =>
|
|
{
|
|
Destroy(gameObject);
|
|
});
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// Clean up temporary file on cancellation. Cancellation comes from CancelProcess, which has already
|
|
// destroyed the dialog, so the status update below is posted but never shown.
|
|
if (!string.IsNullOrEmpty(tempZipPath) && File.Exists(tempZipPath))
|
|
{
|
|
try { File.Delete(tempZipPath); }
|
|
catch
|
|
{
|
|
// ignored
|
|
}
|
|
}
|
|
UpdateStatus(LocalizationManager.Get("ui.ffmpeg_download.status_cancelled"), new Color(0.9f, 0.5f, 0.2f));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Melon<RenderMod>.Logger.Error($"Error during FFmpeg download/installation: {ex}");
|
|
UpdateStatus(LocalizationManager.Get("ui.ffmpeg_download.status_failed", ex.Message), new Color(1f, 0.3f, 0.3f));
|
|
|
|
// Reset buttons on failure so the user can retry (Download) or close the dialog (Cancel).
|
|
// The label is set to "Retry" only if a child at "DownloadButton/DownloadText" exists. The button made by
|
|
// O5Factory.Button in Start is a GameObject named "O5Button", which this path does not match.
|
|
ExecuteOnMainThread(() =>
|
|
{
|
|
_isDownloading = false;
|
|
_buttonContainerObj.SetActive(true);
|
|
var downloadBtnText = _buttonContainerObj.transform.Find("DownloadButton/DownloadText")?.GetComponent<TextMeshProUGUI>();
|
|
if (downloadBtnText)
|
|
{
|
|
downloadBtnText.text = LocalizationManager.Get("ui.ffmpeg_download.btn_retry");
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Cancel button handler: requests cancellation of a running download and destroys the dialog immediately.
|
|
/// An extraction already in progress is not cancellable and finishes in the background.
|
|
/// </summary>
|
|
private void CancelProcess()
|
|
{
|
|
if (_isDownloading)
|
|
{
|
|
_cts?.Cancel();
|
|
}
|
|
Destroy(gameObject);
|
|
}
|
|
|
|
/// <summary>Posts a status text and color change to the main thread. Callable from any thread.</summary>
|
|
private void UpdateStatus(string message, Color color)
|
|
{
|
|
ExecuteOnMainThread(() =>
|
|
{
|
|
if (_statusText)
|
|
{
|
|
_statusText.text = message;
|
|
_statusText.color = color;
|
|
}
|
|
});
|
|
}
|
|
|
|
/// <summary>Posts a progress bar update to the main thread. Callable from any thread.</summary>
|
|
/// <param name="percent">Filled fraction 0..1 (not 0..100); becomes the fill's <c>anchorMax.x</c>.</param>
|
|
/// <param name="text">Text shown on the bar.</param>
|
|
private void UpdateProgress(float percent, string text)
|
|
{
|
|
ExecuteOnMainThread(() =>
|
|
{
|
|
if (_fillRect)
|
|
{
|
|
_fillRect.anchorMax = new Vector2(percent, 1f);
|
|
}
|
|
if (_progressText)
|
|
{
|
|
_progressText.text = text;
|
|
}
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Streams the HTTP response body of <paramref name="url"/> to <paramref name="destinationPath"/> (created or
|
|
/// overwritten) in 8 KiB chunks. Runs on a thread-pool thread.
|
|
/// </summary>
|
|
/// <param name="url">Archive URL.</param>
|
|
/// <param name="destinationPath">Output file path.</param>
|
|
/// <param name="progress">
|
|
/// Receives the downloaded fraction 0..1 after every chunk; not reported when the server sends no Content-Length.
|
|
/// </param>
|
|
/// <param name="cancellationToken">Cancels the request and the copy loop (<see cref="OperationCanceledException"/>).</param>
|
|
/// <exception cref="HttpRequestException">The server returned a non-success status code.</exception>
|
|
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 by servers that reject requests without one
|
|
client.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) ADOFAIRenderer/1.0");
|
|
|
|
// ResponseHeadersRead returns after the headers, so the body is streamed instead of buffered in memory
|
|
using var response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
|
response.EnsureSuccessStatusCode();
|
|
|
|
// Total size from Content-Length, or -1 when unknown. File stream: 8192-byte buffer, asynchronous I/O.
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Extracts the shared libraries from the downloaded archive into <paramref name="outputDirectory"/>, overwriting
|
|
/// files of the same name, and deletes the archive afterwards (also on failure; a failed delete is logged as a
|
|
/// warning). Runs on thread-pool threads and is not cancellable.
|
|
/// <list type="bullet">
|
|
/// <item>Windows/macOS (.zip): every entry ending in .dll (Windows) or .dylib (macOS) is extracted flat, by its file
|
|
/// name only, ignoring its folder inside the archive.</item>
|
|
/// <item>Other platforms (.tar.xz): the archive is unpacked with the system <c>tar</c> command into the temporary
|
|
/// folder "ffmpeg_temp_extracted" in <paramref name="outputDirectory"/>; every file matching <c>*.so*</c> in any
|
|
/// subfolder is copied flat to <paramref name="outputDirectory"/>, then the temporary folder is deleted.</item>
|
|
/// </list>
|
|
/// </summary>
|
|
/// <param name="zipPath">Downloaded archive path (.zip or .tar.xz despite the parameter name).</param>
|
|
/// <param name="outputDirectory">UserLibs folder.</param>
|
|
/// <exception cref="Exception"><c>tar</c> exited with a non-zero code (message includes its standard error).</exception>
|
|
private async Task ExtractDllsAsync(string zipPath, string outputDirectory)
|
|
{
|
|
try
|
|
{
|
|
if (Application.platform == RuntimePlatform.OSXPlayer || Application.platform == RuntimePlatform.WindowsPlayer)
|
|
{
|
|
// .zip: extract matching library entries flat into outputDirectory
|
|
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);
|
|
|
|
// tar receives forward-slash paths, quoted for spaces: tar -xf "<archive>" -C "<temp dir>"
|
|
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 (including versioned names such as libavcodec.so.<major>) and copy them flat 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}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Disposes the window and the cancellation source and clears <see cref="Instance"/>. A download that was not
|
|
/// cancelled keeps running in the background; its UI updates are posted but no longer drained.
|
|
/// </summary>
|
|
private void OnDestroy()
|
|
{
|
|
_window?.Dispose();
|
|
_window = null;
|
|
_cts?.Dispose();
|
|
if (Instance == this)
|
|
{
|
|
Instance = null;
|
|
}
|
|
}
|
|
} |