c335995a9a
- SongSelectManager/SongDetailPanel: 곡 선택 및 상세 패널 개선 - SongCreatorManager: 곡 생성 기능 추가 - FinalScoreLabel/ScoreManager: 결과 화면 점수 UI 업데이트 - MarqueeText: 마퀴 텍스트 컴포넌트 개선 - NoteData/SongController: 노트 데이터 및 컨트롤러 보완 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
425 lines
16 KiB
C#
425 lines
16 KiB
C#
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 readonly Color NeonBg = new Color(0.05f, 0.82f, 0.95f, 0.42f);
|
|
private static readonly Color DarkButtonBg = new Color(0.09f, 0.22f, 0.27f, 0.66f);
|
|
private static readonly Color DisabledBg = new Color(0.06f, 0.12f, 0.15f, 0.48f);
|
|
private static readonly Color ButtonText = new Color(0.92f, 1.0f, 1.0f, 1.0f);
|
|
private static readonly Color MutedText = new Color(0.66f, 0.80f, 0.84f, 0.76f);
|
|
private static readonly Color NeonOutline = new Color(0.25f, 0.96f, 1.0f, 0.42f);
|
|
|
|
private static string InputPath =>
|
|
Path.Combine(Application.persistentDataPath, "input");
|
|
|
|
private readonly List<string> audioFiles = new();
|
|
private string _pendingFilePath;
|
|
|
|
private void OnValidate()
|
|
{
|
|
ApplyButtonStyles();
|
|
}
|
|
|
|
private void Start()
|
|
{
|
|
ApplyButtonStyles();
|
|
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;
|
|
ApplyButtonStyles();
|
|
}
|
|
|
|
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;
|
|
ApplyButtonStyles();
|
|
|
|
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;
|
|
ApplyButtonStyles();
|
|
|
|
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; }
|
|
|
|
private void ApplyButtonStyles()
|
|
{
|
|
ApplyCreatorButtonStyle(generateButton, true);
|
|
ApplyCreatorButtonStyle(urlDownloadBtn, true);
|
|
ApplyCreatorButtonStyle(refreshBtn, false);
|
|
ApplyCreatorButtonStyle(filePickerBtn, false);
|
|
ApplyCreatorButtonStyle(backButton, false);
|
|
}
|
|
|
|
private static void ApplyCreatorButtonStyle(Button btn, bool primary)
|
|
{
|
|
if (btn == null)
|
|
return;
|
|
|
|
Color bg = btn.interactable ? (primary ? NeonBg : DarkButtonBg) : DisabledBg;
|
|
if (btn.targetGraphic is Image img)
|
|
{
|
|
img.color = bg;
|
|
img.raycastTarget = true;
|
|
}
|
|
|
|
var colors = btn.colors;
|
|
colors.normalColor = bg;
|
|
colors.highlightedColor = btn.interactable
|
|
? new Color(0.10f, 0.95f, 1.0f, primary ? 0.58f : 0.48f)
|
|
: DisabledBg;
|
|
colors.pressedColor = btn.interactable
|
|
? new Color(0.02f, 0.58f, 0.72f, 0.80f)
|
|
: DisabledBg;
|
|
colors.selectedColor = colors.highlightedColor;
|
|
colors.disabledColor = DisabledBg;
|
|
colors.fadeDuration = 0.08f;
|
|
btn.colors = colors;
|
|
|
|
TMP_Text label = btn.GetComponentInChildren<TMP_Text>(true);
|
|
if (label != null)
|
|
{
|
|
label.color = btn.interactable ? ButtonText : MutedText;
|
|
label.raycastTarget = false;
|
|
}
|
|
|
|
Outline outline = btn.GetComponent<Outline>() ?? btn.gameObject.AddComponent<Outline>();
|
|
outline.enabled = btn.interactable;
|
|
outline.effectColor = NeonOutline;
|
|
outline.effectDistance = new Vector2(0.0f, -0.28f);
|
|
}
|
|
}
|