feat: SongSelect panel — NAS song list with download/play in Menu.unity
- Add DownloadManager, SongLibrary, SongSelectManager, SongDetailPanel scripts - Rebuild SongSelect panel inside Menu.unity using VRBeatsKit style: left scroll list (ALL/OWNED tabs) + right detail panel (diff buttons, Download/Delete/Play) - SceneBuilder replaced: only ③ Menu — Rebuild SongSelect Panel remains - All UI text in English Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.IO;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Networking;
|
||||
|
||||
public class DownloadManager : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private string baseUrl = "http://whdwo798.synology.me/beatsaber";
|
||||
|
||||
private static string CacheRoot => Path.Combine(Application.temporaryCachePath, "beatsaber");
|
||||
|
||||
// ── Public API ───────────────────────────────────────────
|
||||
|
||||
public void FetchSongsList(Action<SongsList> onSuccess, Action<string> onError = null)
|
||||
{
|
||||
StartCoroutine(GetText($"{baseUrl}/songs.json", json =>
|
||||
{
|
||||
SongsList list = JsonUtility.FromJson<SongsList>(json);
|
||||
if (list == null)
|
||||
onError?.Invoke("songs.json 파싱 실패");
|
||||
else
|
||||
onSuccess?.Invoke(list);
|
||||
}, onError));
|
||||
}
|
||||
|
||||
public void DownloadSong(SongInfo song, string difficulty,
|
||||
Action<float> onProgress, Action onComplete, Action<string> onError = null)
|
||||
{
|
||||
StartCoroutine(DownloadSongCoroutine(song, difficulty, onProgress, onComplete, onError));
|
||||
}
|
||||
|
||||
public void DeleteSong(string songId)
|
||||
{
|
||||
string dir = SongDir(songId);
|
||||
if (Directory.Exists(dir))
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
Debug.Log($"[DownloadManager] 삭제: {songId}");
|
||||
}
|
||||
}
|
||||
|
||||
public void DeleteDifficulty(SongInfo song, string difficulty)
|
||||
{
|
||||
string path = MapPath(song, difficulty);
|
||||
if (path != null && File.Exists(path))
|
||||
File.Delete(path);
|
||||
}
|
||||
|
||||
public bool IsSongDownloaded(string songId)
|
||||
=> File.Exists(AudioPath(songId));
|
||||
|
||||
public bool IsDifficultyDownloaded(SongInfo song, string difficulty)
|
||||
{
|
||||
string path = MapPath(song, difficulty);
|
||||
return path != null && File.Exists(path);
|
||||
}
|
||||
|
||||
public string AudioPath(string songId)
|
||||
=> Path.Combine(SongDir(songId), $"{songId}.mp3");
|
||||
|
||||
public string MapPath(SongInfo song, string difficulty)
|
||||
{
|
||||
DifficultyInfo info = song.difficulties.Get(difficulty);
|
||||
if (info == null || string.IsNullOrEmpty(info.mapFile)) return null;
|
||||
string fileName = Path.GetFileName(info.mapFile);
|
||||
if (string.IsNullOrEmpty(fileName)) return null;
|
||||
return Path.Combine(SongDir(song.id), fileName);
|
||||
}
|
||||
|
||||
// ── 내부 구현 ─────────────────────────────────────────────
|
||||
|
||||
private IEnumerator DownloadSongCoroutine(SongInfo song, string difficulty,
|
||||
Action<float> onProgress, Action onComplete, Action<string> onError)
|
||||
{
|
||||
string songDir = Path.GetFullPath(SongDir(song.id));
|
||||
Directory.CreateDirectory(songDir);
|
||||
|
||||
// 1단계: 오디오 (70%)
|
||||
string audioPath = Path.Combine(songDir, $"{song.id}.mp3");
|
||||
if (!File.Exists(audioPath))
|
||||
{
|
||||
bool failed = false;
|
||||
yield return DownloadFile(
|
||||
$"{baseUrl}/{song.audioFile}", audioPath,
|
||||
p => onProgress?.Invoke(p * 0.7f),
|
||||
err => { onError?.Invoke(err); failed = true; });
|
||||
if (failed) yield break;
|
||||
}
|
||||
|
||||
// 2단계: 맵 파일 (30%)
|
||||
DifficultyInfo diffInfo = song.difficulties.Get(difficulty);
|
||||
if (diffInfo == null)
|
||||
{
|
||||
onError?.Invoke($"난이도 '{difficulty}' 없음");
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(diffInfo.mapFile))
|
||||
{
|
||||
onError?.Invoke($"'{difficulty}' 맵 파일 정보 없음 — Creator에서 곡을 다시 생성해주세요");
|
||||
yield break;
|
||||
}
|
||||
|
||||
string mapPath = MapPath(song, difficulty);
|
||||
if (mapPath != null) mapPath = Path.GetFullPath(mapPath);
|
||||
if (mapPath == null)
|
||||
{
|
||||
onError?.Invoke($"'{difficulty}' 맵 경로 계산 실패");
|
||||
yield break;
|
||||
}
|
||||
if (!File.Exists(mapPath))
|
||||
{
|
||||
bool failed = false;
|
||||
yield return DownloadFile(
|
||||
$"{baseUrl}/{diffInfo.mapFile}", mapPath,
|
||||
p => onProgress?.Invoke(0.7f + p * 0.3f),
|
||||
err => { onError?.Invoke(err); failed = true; });
|
||||
if (failed) yield break;
|
||||
}
|
||||
|
||||
onProgress?.Invoke(1f);
|
||||
onComplete?.Invoke();
|
||||
Debug.Log($"[DownloadManager] 완료: {song.title} ({difficulty})");
|
||||
}
|
||||
|
||||
private IEnumerator DownloadFile(string url, string savePath,
|
||||
Action<float> onProgress, Action<string> onError)
|
||||
{
|
||||
using var req = UnityWebRequest.Get(url);
|
||||
req.downloadHandler = new DownloadHandlerFile(savePath);
|
||||
req.SendWebRequest();
|
||||
|
||||
while (!req.isDone)
|
||||
{
|
||||
onProgress?.Invoke(req.downloadProgress);
|
||||
yield return null;
|
||||
}
|
||||
|
||||
if (req.result != UnityWebRequest.Result.Success)
|
||||
{
|
||||
if (File.Exists(savePath)) File.Delete(savePath);
|
||||
onError?.Invoke($"다운로드 실패: {url} — {req.error}");
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerator GetText(string url, Action<string> onSuccess, Action<string> onError)
|
||||
{
|
||||
using var req = UnityWebRequest.Get(url);
|
||||
yield return req.SendWebRequest();
|
||||
|
||||
if (req.result != UnityWebRequest.Result.Success)
|
||||
onError?.Invoke($"요청 실패: {url} — {req.error}");
|
||||
else
|
||||
onSuccess?.Invoke(req.downloadHandler.text);
|
||||
}
|
||||
|
||||
private static string SongDir(string songId)
|
||||
=> Path.Combine(CacheRoot, songId);
|
||||
}
|
||||
Reference in New Issue
Block a user