feat: SongCreator 씬 완성 — Beat Sage URL 지원, info.dat 메타데이터 자동 추출
- BeatSageUploader: audio_url 지원(UploadFromUrl), PollAndDownload 공통화, ZIP 500 오류 3회 재시도 - BeatSageConverter: info.dat 파싱(SongMetadata), BPM 자동 감지 → 노트 타이밍 변환에 적용 - SongCreatorManager: title/BPM 필수 입력 제거, 난이도 4개 자동 선택, GenerateFlowFromUrl 버그 수정 - NasPublisher: audioPath null 허용(URL 흐름에서 로컬 파일 없는 경우 스킵) - .gitignore/.gitattributes 초기 설정 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
[System.Serializable]
|
||||
public class SongMetadata
|
||||
{
|
||||
public string title;
|
||||
public string artist;
|
||||
public float bpm;
|
||||
}
|
||||
|
||||
[System.Serializable]
|
||||
public class BeatSageInfoDat
|
||||
{
|
||||
public string _songName;
|
||||
public string _songAuthorName;
|
||||
public float _beatsPerMinute;
|
||||
}
|
||||
|
||||
[System.Serializable]
|
||||
public class BeatSageRoot
|
||||
{
|
||||
public string _version;
|
||||
public List<BeatSageNote> _notes;
|
||||
}
|
||||
|
||||
[System.Serializable]
|
||||
public class BeatSageNote
|
||||
{
|
||||
public float _time;
|
||||
public int _lineIndex;
|
||||
public int _lineLayer;
|
||||
public int _type;
|
||||
public int _cutDirection;
|
||||
}
|
||||
|
||||
public static class BeatSageConverter
|
||||
{
|
||||
public static List<NoteData> Convert(string rawJson, float bpm)
|
||||
{
|
||||
var result = new List<NoteData>();
|
||||
|
||||
var root = JsonUtility.FromJson<BeatSageRoot>(rawJson);
|
||||
if (root?._notes == null)
|
||||
{
|
||||
Debug.LogWarning("[BeatSageConverter] Parse failed or no notes.");
|
||||
return result;
|
||||
}
|
||||
|
||||
foreach (var note in root._notes)
|
||||
{
|
||||
// Only process normal notes (0=red, 1=blue); skip bombs (3) etc.
|
||||
if (note._type != 0 && note._type != 1) continue;
|
||||
|
||||
result.Add(new NoteData
|
||||
{
|
||||
time = (note._time * 60f) / bpm,
|
||||
position = note._lineIndex,
|
||||
lineLayer = note._lineLayer,
|
||||
colorType = note._type,
|
||||
cutDirection = note._cutDirection,
|
||||
});
|
||||
}
|
||||
|
||||
Debug.Log($"[BeatSageConverter] Converted {result.Count} notes.");
|
||||
return result;
|
||||
}
|
||||
|
||||
public static string ToMapJson(List<NoteData> notes)
|
||||
{
|
||||
return JsonUtility.ToJson(new MapData { target = notes }, true);
|
||||
}
|
||||
|
||||
public static SongMetadata ParseInfoDat(string json)
|
||||
{
|
||||
var info = JsonUtility.FromJson<BeatSageInfoDat>(json);
|
||||
if (info == null) return null;
|
||||
return new SongMetadata
|
||||
{
|
||||
title = (info._songName ?? "").Trim(),
|
||||
artist = (info._songAuthorName ?? "").Trim(),
|
||||
bpm = info._beatsPerMinute,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1eca2ce555fd76e44ab91d0aea717fad
|
||||
@@ -0,0 +1,320 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Networking;
|
||||
|
||||
public class BeatSageUploader : MonoBehaviour
|
||||
{
|
||||
private const string BASE_URL = "https://beatsage.com";
|
||||
private const string CREATE_EP = "/beatsaber_custom_level_create";
|
||||
private const string HEARTBEAT_EP = "/beatsaber_custom_level_heartbeat/{0}";
|
||||
private const string DOWNLOAD_EP = "/beatsaber_custom_level_download/{0}";
|
||||
|
||||
private const float POLL_INTERVAL = 5f;
|
||||
private const float POLL_TIMEOUT = 300f;
|
||||
|
||||
private static readonly Dictionary<string, string> DiffNames = new()
|
||||
{
|
||||
{ "normal", "Normal" },
|
||||
{ "hard", "Hard" },
|
||||
{ "expert", "Expert" },
|
||||
{ "expertplus", "ExpertPlus" },
|
||||
};
|
||||
|
||||
private static readonly Dictionary<string, string> DatFileNames = new()
|
||||
{
|
||||
{ "normal", "Normal.dat" },
|
||||
{ "hard", "Hard.dat" },
|
||||
{ "expert", "Expert.dat" },
|
||||
{ "expertplus", "ExpertPlus.dat" },
|
||||
};
|
||||
|
||||
public string CurrentStatus { get; private set; } = "";
|
||||
public SongMetadata LastMetadata { get; private set; }
|
||||
|
||||
// Upload from local file path
|
||||
public IEnumerator Upload(
|
||||
string audioPath,
|
||||
List<string> difficulties,
|
||||
float bpm,
|
||||
Action<float> onProgress,
|
||||
Action<Dictionary<string, List<NoteData>>> onComplete,
|
||||
Action<string> onError)
|
||||
{
|
||||
SetStatus("[1/4] Uploading audio...");
|
||||
string levelId = null;
|
||||
|
||||
yield return CreateLevel(audioPath, difficulties, id => levelId = id, onError);
|
||||
if (levelId == null) yield break;
|
||||
onProgress?.Invoke(0.15f);
|
||||
|
||||
yield return PollAndDownload(levelId, difficulties, bpm, onProgress, onComplete, onError);
|
||||
}
|
||||
|
||||
// Upload from direct audio URL (Beat Sage downloads it server-side)
|
||||
public IEnumerator UploadFromUrl(
|
||||
string audioUrl,
|
||||
List<string> difficulties,
|
||||
float bpm,
|
||||
Action<float> onProgress,
|
||||
Action<Dictionary<string, List<NoteData>>> onComplete,
|
||||
Action<string> onError)
|
||||
{
|
||||
SetStatus("[1/4] Sending URL to Beat Sage...");
|
||||
string levelId = null;
|
||||
|
||||
yield return CreateLevelFromUrl(audioUrl, difficulties, id => levelId = id, onError);
|
||||
if (levelId == null) yield break;
|
||||
onProgress?.Invoke(0.15f);
|
||||
|
||||
yield return PollAndDownload(levelId, difficulties, bpm, onProgress, onComplete, onError);
|
||||
}
|
||||
|
||||
// Shared poll + download + convert phase
|
||||
private IEnumerator PollAndDownload(
|
||||
string levelId,
|
||||
List<string> difficulties,
|
||||
float bpm,
|
||||
Action<float> onProgress,
|
||||
Action<Dictionary<string, List<NoteData>>> onComplete,
|
||||
Action<string> onError)
|
||||
{
|
||||
SetStatus("[2/4] Generating beatmap...");
|
||||
bool ready = false;
|
||||
float elapsed = 0f;
|
||||
|
||||
while (!ready && elapsed < POLL_TIMEOUT)
|
||||
{
|
||||
yield return new WaitForSeconds(POLL_INTERVAL);
|
||||
elapsed += POLL_INTERVAL;
|
||||
|
||||
bool error = false;
|
||||
yield return PollHeartbeat(levelId,
|
||||
status =>
|
||||
{
|
||||
ready = string.Equals(status, "generated", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(status, "done", StringComparison.OrdinalIgnoreCase);
|
||||
error = string.Equals(status, "error", StringComparison.OrdinalIgnoreCase);
|
||||
},
|
||||
onError);
|
||||
|
||||
if (error) { onError?.Invoke("Beat Sage generation failed (error status)"); yield break; }
|
||||
|
||||
onProgress?.Invoke(0.15f + Mathf.Clamp01(elapsed / POLL_TIMEOUT) * 0.6f);
|
||||
SetStatus($"[2/4] Generating... {(int)elapsed}s elapsed");
|
||||
}
|
||||
|
||||
if (!ready) { onError?.Invoke("Beat Sage timeout (>5 min)"); yield break; }
|
||||
|
||||
SetStatus("[3/4] Downloading result...");
|
||||
byte[] zipBytes = null;
|
||||
yield return DownloadZip(levelId, b => zipBytes = b, onError);
|
||||
if (zipBytes == null) yield break;
|
||||
onProgress?.Invoke(0.9f);
|
||||
|
||||
SetStatus("[3/4] Converting map data...");
|
||||
Dictionary<string, List<NoteData>> maps = null;
|
||||
try { maps = ExtractAndConvert(zipBytes, difficulties, bpm); }
|
||||
catch (Exception e) { onError?.Invoke($"Conversion failed: {e.Message}"); yield break; }
|
||||
|
||||
onProgress?.Invoke(1f);
|
||||
SetStatus("[3/4] Conversion complete.");
|
||||
onComplete?.Invoke(maps);
|
||||
}
|
||||
|
||||
private IEnumerator CreateLevelFromUrl(string audioUrl, List<string> difficulties,
|
||||
Action<string> onSuccess, Action<string> onError)
|
||||
{
|
||||
var mappedDiffs = new List<string>();
|
||||
foreach (string d in difficulties)
|
||||
if (DiffNames.TryGetValue(d, out var n)) mappedDiffs.Add(n);
|
||||
|
||||
if (mappedDiffs.Count == 0)
|
||||
{
|
||||
onError?.Invoke("No supported difficulties selected.");
|
||||
yield break;
|
||||
}
|
||||
|
||||
var form = new List<IMultipartFormSection>
|
||||
{
|
||||
new MultipartFormDataSection("audio_url", audioUrl),
|
||||
new MultipartFormDataSection("audio_metadata_title", " "),
|
||||
new MultipartFormDataSection("audio_metadata_artist", " "),
|
||||
new MultipartFormDataSection("difficulties", string.Join(",", mappedDiffs)),
|
||||
new MultipartFormDataSection("modes", "Standard"),
|
||||
new MultipartFormDataSection("events", "DotBlocks,Obstacles,Bombs"),
|
||||
new MultipartFormDataSection("environment", "DefaultEnvironment"),
|
||||
new MultipartFormDataSection("system_tag", "v2"),
|
||||
};
|
||||
|
||||
using var req = UnityWebRequest.Post(BASE_URL + CREATE_EP, form);
|
||||
req.SetRequestHeader("Accept", "*/*");
|
||||
req.SetRequestHeader("User-Agent", "VRBeatSaber/1.0");
|
||||
yield return req.SendWebRequest();
|
||||
|
||||
if (req.result != UnityWebRequest.Result.Success)
|
||||
{
|
||||
onError?.Invoke($"Level create (URL) failed: {req.error}");
|
||||
yield break;
|
||||
}
|
||||
|
||||
string levelId = ParseJsonString(req.downloadHandler.text, "id");
|
||||
if (string.IsNullOrEmpty(levelId))
|
||||
{
|
||||
onError?.Invoke($"Failed to parse levelId. Response: {req.downloadHandler.text}");
|
||||
yield break;
|
||||
}
|
||||
|
||||
onSuccess?.Invoke(levelId);
|
||||
}
|
||||
|
||||
private IEnumerator CreateLevel(string audioPath, List<string> difficulties,
|
||||
Action<string> onSuccess, Action<string> onError)
|
||||
{
|
||||
byte[] audioBytes = File.ReadAllBytes(audioPath);
|
||||
string fileName = Path.GetFileName(audioPath);
|
||||
|
||||
var mappedDiffs = new List<string>();
|
||||
foreach (string d in difficulties)
|
||||
if (DiffNames.TryGetValue(d, out var n)) mappedDiffs.Add(n);
|
||||
|
||||
if (mappedDiffs.Count == 0)
|
||||
{
|
||||
onError?.Invoke("No supported difficulties selected (use Normal/Hard/Expert/ExpertPlus).");
|
||||
yield break;
|
||||
}
|
||||
|
||||
var form = new List<IMultipartFormSection>
|
||||
{
|
||||
new MultipartFormFileSection("audio_file", audioBytes, fileName, "audio/mpeg"),
|
||||
new MultipartFormDataSection("audio_metadata_title", " "),
|
||||
new MultipartFormDataSection("audio_metadata_artist", " "),
|
||||
new MultipartFormDataSection("difficulties", string.Join(",", mappedDiffs)),
|
||||
new MultipartFormDataSection("modes", "Standard"),
|
||||
new MultipartFormDataSection("events", "DotBlocks,Obstacles,Bombs"),
|
||||
new MultipartFormDataSection("environment", "DefaultEnvironment"),
|
||||
new MultipartFormDataSection("system_tag", "v2"),
|
||||
};
|
||||
|
||||
using var req = UnityWebRequest.Post(BASE_URL + CREATE_EP, form);
|
||||
req.SetRequestHeader("Accept", "*/*");
|
||||
req.SetRequestHeader("User-Agent", "VRBeatSaber/1.0");
|
||||
yield return req.SendWebRequest();
|
||||
|
||||
if (req.result != UnityWebRequest.Result.Success)
|
||||
{
|
||||
onError?.Invoke($"Level create request failed: {req.error}");
|
||||
yield break;
|
||||
}
|
||||
|
||||
string levelId = ParseJsonString(req.downloadHandler.text, "id");
|
||||
if (string.IsNullOrEmpty(levelId))
|
||||
{
|
||||
onError?.Invoke($"Failed to parse levelId. Response: {req.downloadHandler.text}");
|
||||
yield break;
|
||||
}
|
||||
|
||||
onSuccess?.Invoke(levelId);
|
||||
}
|
||||
|
||||
private IEnumerator PollHeartbeat(string levelId,
|
||||
Action<string> onStatus, Action<string> onError)
|
||||
{
|
||||
using var req = UnityWebRequest.Get(BASE_URL + string.Format(HEARTBEAT_EP, levelId));
|
||||
yield return req.SendWebRequest();
|
||||
|
||||
if (req.result != UnityWebRequest.Result.Success)
|
||||
{
|
||||
onError?.Invoke($"Heartbeat check failed: {req.error}");
|
||||
yield break;
|
||||
}
|
||||
|
||||
onStatus?.Invoke(ParseJsonString(req.downloadHandler.text, "status") ?? "");
|
||||
}
|
||||
|
||||
private IEnumerator DownloadZip(string levelId,
|
||||
Action<byte[]> onSuccess, Action<string> onError)
|
||||
{
|
||||
string url = BASE_URL + string.Format(DOWNLOAD_EP, levelId);
|
||||
|
||||
for (int attempt = 1; attempt <= 3; attempt++)
|
||||
{
|
||||
using var req = UnityWebRequest.Get(url);
|
||||
yield return req.SendWebRequest();
|
||||
|
||||
if (req.result == UnityWebRequest.Result.Success)
|
||||
{
|
||||
onSuccess?.Invoke(req.downloadHandler.data);
|
||||
yield break;
|
||||
}
|
||||
|
||||
// 500 오류는 Beat Sage 처리 지연일 수 있으므로 재시도
|
||||
if (req.responseCode == 500 && attempt < 3)
|
||||
{
|
||||
SetStatus($"[3/4] Server error, retrying ({attempt}/3)...");
|
||||
yield return new WaitForSeconds(5f);
|
||||
continue;
|
||||
}
|
||||
|
||||
onError?.Invoke($"ZIP download failed: {req.error} (HTTP {req.responseCode})");
|
||||
yield break;
|
||||
}
|
||||
}
|
||||
|
||||
private Dictionary<string, List<NoteData>> ExtractAndConvert(
|
||||
byte[] zipBytes, List<string> difficulties, float fallbackBpm)
|
||||
{
|
||||
var result = new Dictionary<string, List<NoteData>>();
|
||||
|
||||
using var ms = new MemoryStream(zipBytes);
|
||||
using var archive = new ZipArchive(ms, ZipArchiveMode.Read);
|
||||
|
||||
// Read info.dat first to get auto-detected BPM and metadata
|
||||
float bpm = fallbackBpm;
|
||||
foreach (var e in archive.Entries)
|
||||
{
|
||||
if (!string.Equals(e.Name, "info.dat", StringComparison.OrdinalIgnoreCase)) continue;
|
||||
using var r = new StreamReader(e.Open(), Encoding.UTF8);
|
||||
var meta = BeatSageConverter.ParseInfoDat(r.ReadToEnd());
|
||||
if (meta != null)
|
||||
{
|
||||
LastMetadata = meta;
|
||||
if (meta.bpm > 0) bpm = meta.bpm;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
foreach (string diff in difficulties)
|
||||
{
|
||||
if (!DatFileNames.TryGetValue(diff, out string datName)) continue;
|
||||
|
||||
ZipArchiveEntry entry = null;
|
||||
foreach (var e in archive.Entries)
|
||||
if (string.Equals(e.Name, datName, StringComparison.OrdinalIgnoreCase))
|
||||
{ entry = e; break; }
|
||||
|
||||
if (entry == null) { Debug.LogWarning($"[BeatSageUploader] {datName} not found — skipped."); continue; }
|
||||
|
||||
using var reader = new StreamReader(entry.Open(), Encoding.UTF8);
|
||||
result[diff] = BeatSageConverter.Convert(reader.ReadToEnd(), bpm);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string ParseJsonString(string json, string key)
|
||||
{
|
||||
string search = $"\"{key}\":\"";
|
||||
int start = json.IndexOf(search, StringComparison.Ordinal);
|
||||
if (start < 0) return null;
|
||||
start += search.Length;
|
||||
int end = json.IndexOf('"', start);
|
||||
return end > start ? json.Substring(start, end - start) : null;
|
||||
}
|
||||
|
||||
private void SetStatus(string msg) => CurrentStatus = msg;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 313c2722c0b3ff845a6d014c821e3660
|
||||
@@ -0,0 +1,110 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
using UnityEngine.SceneManagement;
|
||||
using UnityEngine.UI;
|
||||
|
||||
// Editor/PC-only helper — auto-injects at runtime, no need to place in scene.
|
||||
// On Quest builds this entire class is stripped.
|
||||
//
|
||||
// Features:
|
||||
// 1. Replaces TrackedDeviceGraphicRaycaster → GraphicRaycaster (enables mouse clicks)
|
||||
// 2. Keeps worldCamera up to date on all World Space canvases
|
||||
// 3. ESC key navigates back
|
||||
public class DesktopUIMode : MonoBehaviour
|
||||
{
|
||||
#if !UNITY_ANDROID || UNITY_EDITOR
|
||||
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
|
||||
private static void AutoCreate()
|
||||
{
|
||||
if (FindObjectOfType<DesktopUIMode>() != null) return;
|
||||
new GameObject("[DesktopUIMode]").AddComponent<DesktopUIMode>();
|
||||
}
|
||||
|
||||
private static readonly System.Collections.Generic.Dictionary<string, string> BackMap =
|
||||
new()
|
||||
{
|
||||
{ "SongSelect", "Menu" },
|
||||
{ "SongCreator", "Menu" },
|
||||
{ "MapEditorScene", "SongCreator" },
|
||||
{ "Game", "SongSelect" },
|
||||
};
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (FindObjectsByType<DesktopUIMode>(FindObjectsSortMode.None).Length > 1)
|
||||
{ Destroy(gameObject); return; }
|
||||
|
||||
DontDestroyOnLoad(gameObject);
|
||||
SceneManager.sceneLoaded += OnSceneLoaded;
|
||||
PatchCanvases();
|
||||
}
|
||||
|
||||
private void OnDestroy() => SceneManager.sceneLoaded -= OnSceneLoaded;
|
||||
|
||||
private void OnSceneLoaded(Scene s, LoadSceneMode m) => StartCoroutine(PatchAfterFrame());
|
||||
|
||||
private System.Collections.IEnumerator PatchAfterFrame()
|
||||
{ yield return null; PatchCanvases(); }
|
||||
|
||||
private void Update()
|
||||
{
|
||||
RefreshCanvasCameras();
|
||||
if (Keyboard.current?.escapeKey.wasPressedThisFrame == true) GoBack();
|
||||
}
|
||||
|
||||
private static void PatchCanvases()
|
||||
{
|
||||
foreach (var canvas in FindObjectsByType<Canvas>(FindObjectsSortMode.None))
|
||||
{
|
||||
if (canvas.renderMode != RenderMode.WorldSpace) continue;
|
||||
|
||||
var tracked = canvas.GetComponent("TrackedDeviceGraphicRaycaster");
|
||||
if (tracked != null)
|
||||
{
|
||||
DestroyImmediate(tracked);
|
||||
if (canvas.GetComponent<GraphicRaycaster>() == null)
|
||||
canvas.gameObject.AddComponent<GraphicRaycaster>();
|
||||
}
|
||||
}
|
||||
|
||||
RemoveDuplicateAudioListeners();
|
||||
RefreshCanvasCameras();
|
||||
}
|
||||
|
||||
private static void RemoveDuplicateAudioListeners()
|
||||
{
|
||||
var listeners = FindObjectsByType<AudioListener>(FindObjectsSortMode.None);
|
||||
if (listeners.Length <= 1) return;
|
||||
|
||||
AudioListener keep = null;
|
||||
foreach (var al in listeners)
|
||||
if (al.gameObject.scene.name != "DontDestroyOnLoad") { keep = al; break; }
|
||||
keep ??= listeners[0];
|
||||
|
||||
foreach (var al in listeners)
|
||||
if (al != keep) DestroyImmediate(al);
|
||||
}
|
||||
|
||||
private static void RefreshCanvasCameras()
|
||||
{
|
||||
Camera cam = Camera.main;
|
||||
if (cam == null)
|
||||
foreach (var c in FindObjectsByType<Camera>(FindObjectsSortMode.None))
|
||||
if (c.enabled && c.gameObject.scene.name != "DontDestroyOnLoad") { cam = c; break; }
|
||||
cam ??= FindObjectOfType<Camera>();
|
||||
if (cam == null) return;
|
||||
|
||||
foreach (var canvas in FindObjectsByType<Canvas>(FindObjectsSortMode.None))
|
||||
if (canvas.renderMode == RenderMode.WorldSpace && canvas.worldCamera != cam)
|
||||
canvas.worldCamera = cam;
|
||||
}
|
||||
|
||||
private static void GoBack()
|
||||
{
|
||||
if (BackMap.TryGetValue(SceneManager.GetActiveScene().name, out string target))
|
||||
SceneManager.LoadScene(target);
|
||||
}
|
||||
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c0afc29d40bc9cc4486fc0c8078d2cb7
|
||||
@@ -0,0 +1,6 @@
|
||||
// Static container — passes selected song/difficulty between scenes
|
||||
public static class GameSession
|
||||
{
|
||||
public static SongInfo SelectedSong;
|
||||
public static string SelectedDifficulty;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4794ac1142dcc254fa53e2c8d7c1512a
|
||||
@@ -0,0 +1,217 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Networking;
|
||||
|
||||
public class NasPublisher : MonoBehaviour
|
||||
{
|
||||
[Header("NAS Connection")]
|
||||
[SerializeField] private string nasBaseUrl = "http://192.168.55.3:5000";
|
||||
[SerializeField] private string nasAccount = "admin";
|
||||
[SerializeField] private string nasRootPath = "/web/beatsaber";
|
||||
|
||||
[Header("Static Server URL (for reading songs.json)")]
|
||||
[SerializeField] private string staticBaseUrl = "http://whdwo798.synology.me/beatsaber";
|
||||
|
||||
private string _sid = "";
|
||||
private string _synoToken = "";
|
||||
private string _password = "";
|
||||
|
||||
private void Awake() => LoadConfig();
|
||||
|
||||
private void LoadConfig()
|
||||
{
|
||||
string path = Path.Combine(Application.streamingAssetsPath, "nas_config.json");
|
||||
if (!File.Exists(path)) { Debug.LogWarning("[NasPublisher] nas_config.json not found: " + path); return; }
|
||||
var cfg = JsonUtility.FromJson<NasConfig>(File.ReadAllText(path));
|
||||
if (cfg == null) return;
|
||||
_password = cfg.password ?? "";
|
||||
if (!string.IsNullOrEmpty(cfg.host)) nasBaseUrl = cfg.host;
|
||||
if (!string.IsNullOrEmpty(cfg.account)) nasAccount = cfg.account;
|
||||
if (!string.IsNullOrEmpty(cfg.rootPath)) nasRootPath = cfg.rootPath;
|
||||
if (!string.IsNullOrEmpty(cfg.staticUrl)) staticBaseUrl = cfg.staticUrl;
|
||||
}
|
||||
|
||||
[Serializable] private class NasConfig
|
||||
{
|
||||
public string host;
|
||||
public string account;
|
||||
public string rootPath;
|
||||
public string staticUrl;
|
||||
public string password;
|
||||
}
|
||||
|
||||
public IEnumerator Publish(
|
||||
SongInfo song,
|
||||
string audioPath,
|
||||
Dictionary<string, List<NoteData>> maps,
|
||||
Action<float> onProgress,
|
||||
Action onComplete,
|
||||
Action<string> onError)
|
||||
{
|
||||
bool failed = false;
|
||||
void OnErr(string e) { onError?.Invoke(e); failed = true; }
|
||||
|
||||
yield return Login(OnErr);
|
||||
if (string.IsNullOrEmpty(_sid)) yield break;
|
||||
onProgress?.Invoke(0.1f);
|
||||
|
||||
if (!string.IsNullOrEmpty(audioPath))
|
||||
{
|
||||
yield return UploadFile(audioPath, $"{nasRootPath}/music", $"{song.id}.mp3", OnErr);
|
||||
if (failed) { yield return Logout(); yield break; }
|
||||
}
|
||||
onProgress?.Invoke(0.4f);
|
||||
|
||||
int total = maps.Count, done = 0;
|
||||
foreach (var kv in maps)
|
||||
{
|
||||
string fileName = $"Map_{song.id}_{kv.Key}.json";
|
||||
byte[] bytes = Encoding.UTF8.GetBytes(BeatSageConverter.ToMapJson(kv.Value));
|
||||
AssignMapFile(song, kv.Key, fileName);
|
||||
|
||||
yield return UploadBytes(bytes, fileName, $"{nasRootPath}/maps", OnErr);
|
||||
if (failed) { yield return Logout(); yield break; }
|
||||
|
||||
done++;
|
||||
onProgress?.Invoke(0.4f + (float)done / total * 0.3f);
|
||||
}
|
||||
|
||||
yield return PatchSongsJson(song, OnErr);
|
||||
if (failed) { yield return Logout(); yield break; }
|
||||
onProgress?.Invoke(0.95f);
|
||||
|
||||
yield return Logout();
|
||||
onProgress?.Invoke(1f);
|
||||
onComplete?.Invoke();
|
||||
Debug.Log($"[NasPublisher] Upload complete: '{song.title}'");
|
||||
}
|
||||
|
||||
private IEnumerator Login(Action<string> onError)
|
||||
{
|
||||
string url = $"{nasBaseUrl}/webapi/auth.cgi" +
|
||||
$"?api=SYNO.API.Auth&version=6&method=login" +
|
||||
$"&account={UnityWebRequest.EscapeURL(nasAccount)}" +
|
||||
$"&passwd={UnityWebRequest.EscapeURL(_password)}" +
|
||||
$"&session=FileStation&format=sid&enable_syno_token=yes";
|
||||
|
||||
using var req = UnityWebRequest.Get(url);
|
||||
yield return req.SendWebRequest();
|
||||
|
||||
if (req.result != UnityWebRequest.Result.Success)
|
||||
{
|
||||
onError?.Invoke($"DSM login failed: {req.error}");
|
||||
yield break;
|
||||
}
|
||||
|
||||
string resp = req.downloadHandler.text;
|
||||
_sid = ParseJsonString(resp, "sid");
|
||||
_synoToken = ParseJsonString(resp, "synotoken");
|
||||
|
||||
if (string.IsNullOrEmpty(_sid))
|
||||
onError?.Invoke("DSM sid parse failed — check credentials.");
|
||||
}
|
||||
|
||||
private IEnumerator Logout()
|
||||
{
|
||||
string url = $"{nasBaseUrl}/webapi/auth.cgi" +
|
||||
$"?api=SYNO.API.Auth&version=1&method=logout&session=FileStation&_sid={_sid}";
|
||||
using var req = UnityWebRequest.Get(url);
|
||||
yield return req.SendWebRequest();
|
||||
_sid = "";
|
||||
}
|
||||
|
||||
private IEnumerator UploadFile(string localPath, string nasFolder,
|
||||
string fileName, Action<string> onError)
|
||||
{
|
||||
yield return UploadBytes(File.ReadAllBytes(localPath), fileName, nasFolder, onError);
|
||||
}
|
||||
|
||||
private IEnumerator UploadBytes(byte[] bytes, string fileName,
|
||||
string nasFolder, Action<string> onError)
|
||||
{
|
||||
string uploadUrl = $"{nasBaseUrl}/webapi/entry.cgi" +
|
||||
$"?api=SYNO.FileStation.Upload&version=2&method=upload" +
|
||||
$"&_sid={UnityWebRequest.EscapeURL(_sid)}";
|
||||
|
||||
string boundary = Guid.NewGuid().ToString("N");
|
||||
const string CRLF = "\r\n";
|
||||
|
||||
using var body = new MemoryStream();
|
||||
void WriteText(string s) { var b = Encoding.UTF8.GetBytes(s); body.Write(b, 0, b.Length); }
|
||||
void WriteField(string name, string value)
|
||||
{
|
||||
WriteText($"--{boundary}{CRLF}");
|
||||
WriteText($"Content-Disposition: form-data; name=\"{name}\"{CRLF}{CRLF}");
|
||||
WriteText(value + CRLF);
|
||||
}
|
||||
|
||||
WriteField("path", nasFolder);
|
||||
WriteField("create_parents", "true");
|
||||
WriteField("overwrite", "true");
|
||||
WriteText($"--{boundary}{CRLF}");
|
||||
WriteText($"Content-Disposition: form-data; name=\"file\"; filename=\"{fileName}\"{CRLF}");
|
||||
WriteText($"Content-Type: application/octet-stream{CRLF}{CRLF}");
|
||||
body.Write(bytes, 0, bytes.Length);
|
||||
WriteText(CRLF + $"--{boundary}--{CRLF}");
|
||||
|
||||
using var req = new UnityWebRequest(uploadUrl, "POST");
|
||||
req.uploadHandler = new UploadHandlerRaw(body.ToArray());
|
||||
req.downloadHandler = new DownloadHandlerBuffer();
|
||||
req.SetRequestHeader("Content-Type", $"multipart/form-data; boundary={boundary}");
|
||||
if (!string.IsNullOrEmpty(_synoToken))
|
||||
req.SetRequestHeader("X-SYNO-TOKEN", _synoToken);
|
||||
|
||||
yield return req.SendWebRequest();
|
||||
|
||||
if (req.result != UnityWebRequest.Result.Success)
|
||||
{
|
||||
onError?.Invoke($"Upload failed ({fileName}): {req.error}");
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (req.downloadHandler.text.Contains("\"success\":false"))
|
||||
onError?.Invoke($"Upload rejected ({fileName}): {req.downloadHandler.text}");
|
||||
}
|
||||
|
||||
private IEnumerator PatchSongsJson(SongInfo newSong, Action<string> onError)
|
||||
{
|
||||
SongsList list = null;
|
||||
|
||||
using (var req = UnityWebRequest.Get($"{staticBaseUrl}/songs.json"))
|
||||
{
|
||||
yield return req.SendWebRequest();
|
||||
if (req.result == UnityWebRequest.Result.Success)
|
||||
list = JsonUtility.FromJson<SongsList>(req.downloadHandler.text);
|
||||
}
|
||||
|
||||
list ??= new SongsList { version = "1.0", songs = new List<SongInfo>() };
|
||||
|
||||
int idx = list.songs.FindIndex(s => s.id == newSong.id);
|
||||
if (idx >= 0) list.songs[idx] = newSong;
|
||||
else list.songs.Add(newSong);
|
||||
|
||||
yield return UploadBytes(
|
||||
Encoding.UTF8.GetBytes(JsonUtility.ToJson(list, true)),
|
||||
"songs.json", nasRootPath, onError);
|
||||
}
|
||||
|
||||
private static string ParseJsonString(string json, string key)
|
||||
{
|
||||
string search = $"\"{key}\":\"";
|
||||
int start = json.IndexOf(search, StringComparison.Ordinal);
|
||||
if (start < 0) return null;
|
||||
start += search.Length;
|
||||
int end = json.IndexOf('"', start);
|
||||
return end > start ? json.Substring(start, end - start) : null;
|
||||
}
|
||||
|
||||
private static void AssignMapFile(SongInfo song, string diff, string fileName)
|
||||
{
|
||||
var info = song.difficulties.Get(diff);
|
||||
if (info != null) info.mapFile = $"maps/{fileName}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2379e0d70040c994089638264e6e9934
|
||||
@@ -0,0 +1,67 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
[Serializable]
|
||||
public class NoteData
|
||||
{
|
||||
public float time;
|
||||
public int position; // column 0-3
|
||||
public int lineLayer; // row 0-2
|
||||
public int colorType; // 0=red, 1=blue
|
||||
public int cutDirection; // 0-8 (see Beat Saber spec)
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class MapData
|
||||
{
|
||||
public List<NoteData> target;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class SongsList
|
||||
{
|
||||
public string version;
|
||||
public List<SongInfo> songs;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class SongInfo
|
||||
{
|
||||
public string id;
|
||||
public string title;
|
||||
public string artist;
|
||||
public float bpm;
|
||||
public int duration;
|
||||
public string audioFile;
|
||||
public long audioSize;
|
||||
public string coverImage;
|
||||
public DifficultyMap difficulties;
|
||||
public string addedAt;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class DifficultyMap
|
||||
{
|
||||
public DifficultyInfo normal;
|
||||
public DifficultyInfo hard;
|
||||
public DifficultyInfo expert;
|
||||
public DifficultyInfo expertplus;
|
||||
|
||||
public DifficultyInfo Get(string key) => key switch
|
||||
{
|
||||
"normal" => normal,
|
||||
"hard" => hard,
|
||||
"expert" => expert,
|
||||
"expertplus" => expertplus,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class DifficultyInfo
|
||||
{
|
||||
public string mapFile;
|
||||
public long mapSize;
|
||||
public int noteCount;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 16355c5f50bd642439e8ce4f61be6b92
|
||||
@@ -0,0 +1,361 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Networking;
|
||||
using UnityEngine.SceneManagement;
|
||||
using UnityEngine.UI;
|
||||
|
||||
public class SongCreatorManager : MonoBehaviour
|
||||
{
|
||||
[Header("Audio Source")]
|
||||
[SerializeField] private TMP_Dropdown audioDropdown;
|
||||
[SerializeField] private Button refreshBtn;
|
||||
[SerializeField] private TMP_Text inputPathHint;
|
||||
|
||||
[Header("Audio — Local File")]
|
||||
[SerializeField] private Button filePickerBtn;
|
||||
[SerializeField] private TMP_Text addStatusText;
|
||||
|
||||
[Header("Audio — URL")]
|
||||
[SerializeField] private TMP_InputField urlInput;
|
||||
[SerializeField] private Button urlDownloadBtn;
|
||||
|
||||
[Header("Metadata")]
|
||||
[SerializeField] private TMP_InputField titleInput;
|
||||
[SerializeField] private TMP_InputField artistInput;
|
||||
[SerializeField] private TMP_InputField bpmInput;
|
||||
|
||||
[Header("Difficulty")]
|
||||
[SerializeField] private Toggle toggleNormal;
|
||||
[SerializeField] private Toggle toggleHard;
|
||||
[SerializeField] private Toggle toggleExpert;
|
||||
[SerializeField] private Toggle toggleExpertPlus;
|
||||
|
||||
[Header("Actions")]
|
||||
[SerializeField] private Button generateButton;
|
||||
[SerializeField] private Button manualEditorButton;
|
||||
[SerializeField] private Button backButton;
|
||||
[SerializeField] private string menuSceneName = "Menu";
|
||||
|
||||
[Header("Progress")]
|
||||
[SerializeField] private GameObject progressGroup;
|
||||
[SerializeField] private TMP_Text statusText;
|
||||
[SerializeField] private Slider progressSlider;
|
||||
|
||||
[Header("References")]
|
||||
[SerializeField] private BeatSageUploader beatSageUploader;
|
||||
[SerializeField] private NasPublisher nasPublisher;
|
||||
|
||||
private static string InputPath =>
|
||||
Path.Combine(Application.persistentDataPath, "input");
|
||||
|
||||
private readonly List<string> audioFiles = new();
|
||||
private string _pendingFilePath;
|
||||
|
||||
private void Start()
|
||||
{
|
||||
Directory.CreateDirectory(InputPath);
|
||||
|
||||
if (inputPathHint != null)
|
||||
inputPathHint.text = $"Path: {InputPath}";
|
||||
|
||||
refreshBtn?.onClick.AddListener(RefreshAudioList);
|
||||
generateButton?.onClick.AddListener(OnGenerateClicked);
|
||||
backButton?.onClick.AddListener(() => SceneManager.LoadScene(menuSceneName));
|
||||
filePickerBtn?.onClick.AddListener(OnFilePickerClicked);
|
||||
urlDownloadBtn?.onClick.AddListener(OnUrlDownloadClicked);
|
||||
|
||||
if (progressGroup != null) progressGroup.SetActive(false);
|
||||
RefreshAudioList();
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (_pendingFilePath != null)
|
||||
{
|
||||
CopyToInput(_pendingFilePath);
|
||||
_pendingFilePath = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshAudioList()
|
||||
{
|
||||
audioFiles.Clear();
|
||||
audioDropdown?.ClearOptions();
|
||||
|
||||
var options = new List<string>();
|
||||
foreach (string f in Directory.GetFiles(InputPath, "*.mp3"))
|
||||
{
|
||||
audioFiles.Add(f);
|
||||
options.Add(Path.GetFileNameWithoutExtension(f));
|
||||
}
|
||||
|
||||
if (options.Count == 0) options.Add("-- no .mp3 files --");
|
||||
audioDropdown?.AddOptions(options);
|
||||
}
|
||||
|
||||
private void OnGenerateClicked()
|
||||
{
|
||||
string directUrl = urlInput != null ? urlInput.text.Trim() : "";
|
||||
bool hasUrl = !string.IsNullOrEmpty(directUrl);
|
||||
bool hasFile = audioFiles.Count > 0;
|
||||
|
||||
if (!hasUrl && !hasFile) { SetStatus("No audio source. Add a file or enter a URL."); return; }
|
||||
|
||||
// BPM input is optional — Beat Sage auto-detects from audio; use as fallback only
|
||||
float.TryParse(bpmInput?.text, out float bpmHint);
|
||||
|
||||
var diffs = new List<string> { "normal", "hard", "expert", "expertplus" };
|
||||
|
||||
if (hasUrl)
|
||||
{
|
||||
if (!Uri.TryCreate(directUrl, UriKind.Absolute, out var uri) ||
|
||||
(uri.Scheme != "http" && uri.Scheme != "https"))
|
||||
{ SetStatus("Invalid URL."); return; }
|
||||
|
||||
StartCoroutine(GenerateFlowFromUrl(uri.AbsoluteUri, bpmHint, diffs));
|
||||
}
|
||||
else
|
||||
{
|
||||
StartCoroutine(GenerateFlow(audioFiles[audioDropdown.value], bpmHint, diffs));
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerator GenerateFlowFromUrl(string audioUrl, float bpm, List<string> diffs)
|
||||
{
|
||||
SetInteractable(false);
|
||||
if (progressGroup != null) progressGroup.SetActive(true);
|
||||
|
||||
Dictionary<string, List<NoteData>> maps = null;
|
||||
bool failed = false;
|
||||
|
||||
yield return beatSageUploader.UploadFromUrl(
|
||||
audioUrl, diffs, bpm,
|
||||
onProgress: p =>
|
||||
{
|
||||
if (progressSlider != null) progressSlider.value = p * 0.8f;
|
||||
SetStatus($"{beatSageUploader.CurrentStatus} ({(int)(p * 80)}%)");
|
||||
},
|
||||
onComplete: result => maps = result,
|
||||
onError: err => { SetStatus($"Error: {err}"); failed = true; });
|
||||
|
||||
if (failed) { SetInteractable(true); yield break; }
|
||||
|
||||
SongInfo song = BuildSongInfo(audioUrl, bpm, maps);
|
||||
|
||||
yield return nasPublisher.Publish(
|
||||
song, null, maps,
|
||||
onProgress: p =>
|
||||
{
|
||||
if (progressSlider != null) progressSlider.value = 0.8f + p * 0.2f;
|
||||
SetStatus($"[4/4] Uploading to NAS... ({(int)((0.8f + p * 0.2f) * 100)}%)");
|
||||
},
|
||||
onComplete: () =>
|
||||
{
|
||||
if (progressSlider != null) progressSlider.value = 1f;
|
||||
SetStatus($"Done! '{song.title}' created successfully.");
|
||||
},
|
||||
onError: err => { SetStatus($"NAS upload failed: {err}"); failed = true; });
|
||||
|
||||
SetInteractable(true);
|
||||
}
|
||||
|
||||
private IEnumerator GenerateFlow(string audioPath, float bpm, List<string> diffs)
|
||||
{
|
||||
SetInteractable(false);
|
||||
if (progressGroup != null) progressGroup.SetActive(true);
|
||||
|
||||
Dictionary<string, List<NoteData>> maps = null;
|
||||
bool failed = false;
|
||||
|
||||
yield return beatSageUploader.Upload(
|
||||
audioPath, diffs, bpm,
|
||||
onProgress: p =>
|
||||
{
|
||||
if (progressSlider != null) progressSlider.value = p * 0.8f;
|
||||
SetStatus($"{beatSageUploader.CurrentStatus} ({(int)(p * 80)}%)");
|
||||
},
|
||||
onComplete: result => maps = result,
|
||||
onError: err => { SetStatus($"Error: {err}"); failed = true; });
|
||||
|
||||
if (failed) { SetInteractable(true); yield break; }
|
||||
|
||||
SongInfo song = BuildSongInfo(audioPath, bpm, maps);
|
||||
|
||||
yield return nasPublisher.Publish(
|
||||
song, audioPath, maps,
|
||||
onProgress: p =>
|
||||
{
|
||||
if (progressSlider != null) progressSlider.value = 0.8f + p * 0.2f;
|
||||
SetStatus($"[4/4] Uploading to NAS... ({(int)((0.8f + p * 0.2f) * 100)}%)");
|
||||
},
|
||||
onComplete: () =>
|
||||
{
|
||||
if (progressSlider != null) progressSlider.value = 1f;
|
||||
SetStatus($"Done! '{song.title}' created successfully.");
|
||||
},
|
||||
onError: err => { SetStatus($"NAS upload failed: {err}"); failed = true; });
|
||||
|
||||
SetInteractable(true);
|
||||
}
|
||||
|
||||
private SongInfo BuildSongInfo(string audioPath, float fallbackBpm,
|
||||
Dictionary<string, List<NoteData>> maps)
|
||||
{
|
||||
// Prefer values from info.dat (auto-detected by Beat Sage); UI inputs override if non-empty
|
||||
var meta = beatSageUploader != null ? beatSageUploader.LastMetadata : null;
|
||||
string uiTitle = titleInput?.text.Trim() ?? "";
|
||||
string uiArtist = artistInput?.text.Trim() ?? "";
|
||||
float.TryParse(bpmInput?.text, out float uiBpm);
|
||||
|
||||
string title = !string.IsNullOrEmpty(uiTitle) ? uiTitle : (meta?.title ?? "");
|
||||
string artist = !string.IsNullOrEmpty(uiArtist) ? uiArtist : (meta?.artist ?? "");
|
||||
float bpm = (meta != null && meta.bpm > 0) ? meta.bpm : (uiBpm > 0 ? uiBpm : fallbackBpm);
|
||||
|
||||
// Fallback id from filename if title is still empty
|
||||
if (string.IsNullOrEmpty(title) && !string.IsNullOrEmpty(audioPath))
|
||||
title = Path.GetFileNameWithoutExtension(audioPath);
|
||||
if (string.IsNullOrEmpty(title))
|
||||
title = $"song_{DateTime.Now:yyyyMMdd_HHmmss}";
|
||||
|
||||
string id = title.ToLower().Replace(" ", "_");
|
||||
|
||||
var diffMap = new DifficultyMap();
|
||||
foreach (var kv in maps)
|
||||
{
|
||||
var info = new DifficultyInfo { noteCount = kv.Value.Count };
|
||||
switch (kv.Key)
|
||||
{
|
||||
case "normal": diffMap.normal = info; break;
|
||||
case "hard": diffMap.hard = info; break;
|
||||
case "expert": diffMap.expert = info; break;
|
||||
case "expertplus": diffMap.expertplus = info; break;
|
||||
}
|
||||
}
|
||||
|
||||
return new SongInfo
|
||||
{
|
||||
id = id,
|
||||
title = title,
|
||||
artist = artist,
|
||||
bpm = bpm,
|
||||
audioFile = $"music/{id}.mp3",
|
||||
difficulties = diffMap,
|
||||
addedAt = DateTime.Now.ToString("yyyy-MM-dd"),
|
||||
};
|
||||
}
|
||||
|
||||
private void SetStatus(string msg) { if (statusText != null) statusText.text = msg; }
|
||||
|
||||
private void SetInteractable(bool value)
|
||||
{
|
||||
if (generateButton != null) generateButton.interactable = value;
|
||||
if (audioDropdown != null) audioDropdown.interactable = value;
|
||||
if (refreshBtn != null) refreshBtn.interactable = value;
|
||||
if (filePickerBtn != null) filePickerBtn.interactable = value;
|
||||
if (urlDownloadBtn != null) urlDownloadBtn.interactable = value;
|
||||
}
|
||||
|
||||
private void OnFilePickerClicked()
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
string path = UnityEditor.EditorUtility.OpenFilePanel("Select audio file", "", "mp3");
|
||||
if (!string.IsNullOrEmpty(path)) CopyToInput(path);
|
||||
#elif UNITY_STANDALONE_WIN
|
||||
var t = new Thread(() =>
|
||||
{
|
||||
var dlg = new System.Windows.Forms.OpenFileDialog { Filter = "MP3|*.mp3" };
|
||||
if (dlg.ShowDialog() == System.Windows.Forms.DialogResult.OK)
|
||||
_pendingFilePath = dlg.FileName;
|
||||
});
|
||||
t.SetApartmentState(ApartmentState.STA);
|
||||
t.Start();
|
||||
#else
|
||||
SetAddStatus($"Copy file via ADB:\n{InputPath}");
|
||||
#endif
|
||||
}
|
||||
|
||||
private void CopyToInput(string srcPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
string dest = Path.Combine(InputPath, Path.GetFileName(srcPath));
|
||||
File.Copy(srcPath, dest, overwrite: true);
|
||||
RefreshAudioList();
|
||||
string nameNoExt = Path.GetFileNameWithoutExtension(srcPath);
|
||||
int idx = audioFiles.FindIndex(f => Path.GetFileNameWithoutExtension(f) == nameNoExt);
|
||||
if (idx >= 0 && audioDropdown != null) audioDropdown.value = idx;
|
||||
SetAddStatus($"Added: {Path.GetFileName(srcPath)}");
|
||||
}
|
||||
catch (Exception e) { SetAddStatus($"File copy failed: {e.Message}"); }
|
||||
}
|
||||
|
||||
private void OnUrlDownloadClicked()
|
||||
{
|
||||
string url = urlInput != null ? urlInput.text.Trim() : "";
|
||||
if (string.IsNullOrEmpty(url)) { SetAddStatus("Please enter a URL."); return; }
|
||||
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) ||
|
||||
(uri.Scheme != "http" && uri.Scheme != "https"))
|
||||
{
|
||||
SetAddStatus($"Invalid URL: must start with http:// or https://");
|
||||
return;
|
||||
}
|
||||
|
||||
// Streaming service URLs cannot be downloaded directly
|
||||
string host = uri.Host.ToLower();
|
||||
if (host.Contains("youtube.com") || host.Contains("youtu.be") ||
|
||||
host.Contains("spotify.com") || host.Contains("soundcloud.com") ||
|
||||
host.Contains("music.apple.com"))
|
||||
{
|
||||
SetAddStatus("Streaming URLs not supported.\nUse a direct .mp3 download link or Browse File.");
|
||||
return;
|
||||
}
|
||||
|
||||
StartCoroutine(DownloadFromUrl(uri.AbsoluteUri));
|
||||
}
|
||||
|
||||
private IEnumerator DownloadFromUrl(string url)
|
||||
{
|
||||
SetAddStatus("Downloading...");
|
||||
if (urlDownloadBtn != null) urlDownloadBtn.interactable = false;
|
||||
|
||||
string fileName;
|
||||
try
|
||||
{
|
||||
string uriPath = new Uri(url).AbsolutePath;
|
||||
fileName = Path.GetFileName(uriPath);
|
||||
if (string.IsNullOrEmpty(fileName) || !fileName.EndsWith(".mp3", StringComparison.OrdinalIgnoreCase))
|
||||
fileName = "download.mp3";
|
||||
}
|
||||
catch { fileName = "download.mp3"; }
|
||||
|
||||
string savePath = Path.GetFullPath(Path.Combine(InputPath, fileName));
|
||||
|
||||
using var req = UnityWebRequest.Get(url);
|
||||
req.downloadHandler = new DownloadHandlerFile(savePath);
|
||||
yield return req.SendWebRequest();
|
||||
|
||||
if (urlDownloadBtn != null) urlDownloadBtn.interactable = true;
|
||||
|
||||
if (req.result == UnityWebRequest.Result.Success)
|
||||
{
|
||||
RefreshAudioList();
|
||||
int idx = audioFiles.FindIndex(
|
||||
f => Path.GetFileNameWithoutExtension(f) == Path.GetFileNameWithoutExtension(fileName));
|
||||
if (idx >= 0 && audioDropdown != null) audioDropdown.value = idx;
|
||||
SetAddStatus($"Downloaded: {fileName}");
|
||||
}
|
||||
else
|
||||
{
|
||||
if (File.Exists(savePath)) File.Delete(savePath);
|
||||
SetAddStatus($"Download failed: {req.error}");
|
||||
}
|
||||
}
|
||||
|
||||
private void SetAddStatus(string msg) { if (addStatusText != null) addStatusText.text = msg; }
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ad9984c644076724bb5507e3e9e73ed5
|
||||
@@ -0,0 +1,31 @@
|
||||
using UnityEngine;
|
||||
|
||||
// Automatically spawns the XR Interaction Simulator when running in the Editor or on PC.
|
||||
// Add this to any persistent GameObject in the Menu scene (e.g. VR_Manager).
|
||||
//
|
||||
// Setup:
|
||||
// 1. Attach this script to VR_Manager (or any root object) in Menu.unity
|
||||
// 2. In Package Manager → XR Interaction Toolkit → Samples → import "XR Interaction Simulator"
|
||||
// 3. Drag the imported prefab into the SimulatorPrefab field:
|
||||
// Assets/Samples/XR Interaction Toolkit/<version>/XR Interaction Simulator/XR Interaction Simulator.prefab
|
||||
//
|
||||
// Controls (XR Interaction Simulator):
|
||||
// Right-click drag — rotate head
|
||||
// G + mouse move — move right controller
|
||||
// Shift+G — left controller
|
||||
// Space — trigger (UI click)
|
||||
public class XRSimulatorLoader : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private GameObject simulatorPrefab;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
#if !UNITY_ANDROID || UNITY_EDITOR
|
||||
if (simulatorPrefab != null)
|
||||
Instantiate(simulatorPrefab);
|
||||
else
|
||||
Debug.LogWarning("[XRSimulatorLoader] simulatorPrefab is not assigned.\n" +
|
||||
"Import the XR Interaction Simulator sample via Package Manager and assign the prefab.");
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 39a535ec9b2d18e489709431e0c25086
|
||||
Reference in New Issue
Block a user