Browse Source

Playlist downloading (#18)

* Version bump.
* Merged in a Downloader functionality for playlists.
* Playlists now only work if using versioned song folders.
* Add missing count to the playlist description.
* Display queue count in song queue.
* Cancel downloads when leaving playlist selector.
Stephen Damm 6 years ago
parent
commit
736665098d

+ 252 - 0
SongBrowserPlugin/DataAccess/BeatSaverApi/BeatSaverApiResults.cs

@@ -0,0 +1,252 @@
+using System;
+using SimpleJSON;
+using SongLoaderPlugin;
+using SongLoaderPlugin.OverrideClasses;
+
+
+// From: https://github.com/andruzzzhka/BeatSaverDownloader
+namespace SongBrowserPlugin.DataAccess.BeatSaverApi
+{
+    public enum SongQueueState { Available, Queued, Downloading, Downloaded, Error };
+
+    [Serializable]
+    public class DifficultyLevel
+    {
+        public string difficulty;
+        public int difficultyRank;
+        public string jsonPath;
+        public int? offset;
+
+        public DifficultyLevel(CustomSongInfo.DifficultyLevel difficultyLevel)
+        {
+            difficulty = difficultyLevel.difficulty;
+            difficultyRank = difficultyLevel.difficultyRank;
+            jsonPath = difficultyLevel.jsonPath;
+        }
+
+        /*
+        public DifficultyLevel(CustomLevelInfo.DifficultyLevelInfo difficultyLevel)
+        {
+            difficulty = difficultyLevel.difficulty;
+            difficultyRank = difficultyLevel.difficultyRank;
+        }*/
+
+        public DifficultyLevel(string Difficulty, int DifficultyRank, string JsonPath, int Offset = 0)
+        {
+            difficulty = Difficulty;
+            difficultyRank = DifficultyRank;
+            jsonPath = JsonPath;
+            offset = Offset;
+
+        }
+
+    }
+    [Serializable]
+    public class Song
+    {
+        public string id;
+        public string beatname;
+        public string ownerid;
+        public string downloads;
+        public string upvotes;
+        public string plays;
+        public string beattext;
+        public string uploadtime;
+        public string songName;
+        public string songSubName;
+        public string authorName;
+        public string beatsPerMinute;
+        public string downvotes;
+        public string coverUrl;
+        public string downloadUrl;
+        public DifficultyLevel[] difficultyLevels;
+        public string img;
+        public string hash;
+
+        public string path;
+
+        public SongQueueState songQueueState = SongQueueState.Available;
+
+        public float downloadingProgress = 0f;
+
+        public Song()
+        {
+
+        }
+
+        public Song(JSONNode jsonNode)
+        {
+            id = jsonNode["key"];
+            beatname = jsonNode["name"];
+            ownerid = jsonNode["uploaderId"];
+            downloads = jsonNode["downloadCount"];
+            upvotes = jsonNode["upVotes"];
+            downvotes = jsonNode["downVotes"];
+            plays = jsonNode["playedCount"];
+            beattext = jsonNode["description"];
+            uploadtime = jsonNode["createdAt"];
+            songName = jsonNode["songName"];
+            songSubName = jsonNode["songSubName"];
+            authorName = jsonNode["authorName"];
+            beatsPerMinute = jsonNode["bpm"];
+            coverUrl = jsonNode["coverUrl"];
+            downloadUrl = jsonNode["downloadUrl"];
+            hash = jsonNode["hashMd5"];
+
+            var difficultyNode = jsonNode["difficulties"];
+
+            difficultyLevels = new DifficultyLevel[difficultyNode.Count];
+
+            for (int i = 0; i < difficultyNode.Count; i++)
+            {
+                difficultyLevels[i] = new DifficultyLevel(difficultyNode[i]["difficulty"], difficultyNode[i]["difficultyRank"], difficultyNode[i]["audioPath"], difficultyNode[i]["jsonPath"]);
+            }
+        }
+
+        public static Song FromSearchNode(JSONNode mainNode)
+        {
+            Song buffer = new Song();
+            buffer.id = mainNode["key"];
+            buffer.beatname = mainNode["name"];
+            buffer.ownerid = mainNode["uploaderId"];
+            buffer.downloads = mainNode["downloadCount"];
+            buffer.upvotes = mainNode["upVotes"];
+            buffer.downvotes = mainNode["downVotes"];
+            buffer.plays = mainNode["playedCount"];
+            buffer.uploadtime = mainNode["createdAt"];
+            buffer.songName = mainNode["songName"];
+            buffer.songSubName = mainNode["songSubName"];
+            buffer.authorName = mainNode["authorName"];
+            buffer.beatsPerMinute = mainNode["bpm"];
+            buffer.coverUrl = mainNode["coverUrl"];
+            buffer.downloadUrl = mainNode["downloadUrl"];
+            buffer.hash = mainNode["hashMd5"];
+
+            var difficultyNode = mainNode["difficulties"];
+
+            buffer.difficultyLevels = new DifficultyLevel[difficultyNode.Count];
+
+            for (int i = 0; i < difficultyNode.Count; i++)
+            {
+                buffer.difficultyLevels[i] = new DifficultyLevel(difficultyNode[i]["difficulty"], difficultyNode[i]["difficultyRank"], difficultyNode[i]["audioPath"], difficultyNode[i]["jsonPath"]);
+            }
+
+            return buffer;
+        }
+
+        public Song(JSONNode jsonNode, JSONNode difficultyNode)
+        {
+
+            id = jsonNode["key"];
+            beatname = jsonNode["name"];
+            ownerid = jsonNode["uploaderId"];
+            downloads = jsonNode["downloadCount"];
+            upvotes = jsonNode["upVotes"];
+            downvotes = jsonNode["downVotes"];
+            plays = jsonNode["playedCount"];
+            beattext = jsonNode["description"];
+            uploadtime = jsonNode["createdAt"];
+            songName = jsonNode["songName"];
+            songSubName = jsonNode["songSubName"];
+            authorName = jsonNode["authorName"];
+            beatsPerMinute = jsonNode["bpm"];
+            coverUrl = jsonNode["coverUrl"];
+            downloadUrl = jsonNode["downloadUrl"];
+            hash = jsonNode["hashMd5"];
+
+            difficultyLevels = new DifficultyLevel[difficultyNode.Count];
+
+            for (int i = 0; i < difficultyNode.Count; i++)
+            {
+                difficultyLevels[i] = new DifficultyLevel(difficultyNode[i]["difficulty"], difficultyNode[i]["difficultyRank"], difficultyNode[i]["audioPath"], difficultyNode[i]["jsonPath"]);
+            }
+        }
+
+        public bool Compare(Song compareTo)
+        {
+            if (compareTo != null && songName == compareTo.songName)
+            {
+                if (difficultyLevels != null && compareTo.difficultyLevels != null)
+                {
+                    return (songSubName == compareTo.songSubName && authorName == compareTo.authorName && difficultyLevels.Length == compareTo.difficultyLevels.Length);
+                }
+                else
+                {
+                    return (songSubName == compareTo.songSubName && authorName == compareTo.authorName);
+                }
+            }
+            else
+            {
+                return false;
+            }
+        }
+
+
+
+        public Song(CustomLevel _data)
+        {
+            songName = _data.songName;
+            songSubName = _data.songSubName;
+            authorName = _data.songAuthorName;
+            difficultyLevels = ConvertDifficultyLevels(_data.difficultyBeatmaps);
+            path = _data.customSongInfo.path;
+        }
+
+        public Song(CustomSongInfo _song)
+        {
+
+            songName = _song.songName;
+            songSubName = _song.songSubName;
+            authorName = _song.songAuthorName;
+            difficultyLevels = ConvertDifficultyLevels(_song.difficultyLevels);
+            path = _song.path;
+        }
+
+        public DifficultyLevel[] ConvertDifficultyLevels(CustomSongInfo.DifficultyLevel[] _difficultyLevels)
+        {
+            if (_difficultyLevels != null && _difficultyLevels.Length > 0)
+            {
+                DifficultyLevel[] buffer = new DifficultyLevel[_difficultyLevels.Length];
+
+                for (int i = 0; i < _difficultyLevels.Length; i++)
+                {
+                    buffer[i] = new DifficultyLevel(_difficultyLevels[i]);
+                }
+
+
+                return buffer;
+            }
+            else
+            {
+                return null;
+            }
+        }
+
+
+        public DifficultyLevel[] ConvertDifficultyLevels(IStandardLevelDifficultyBeatmap[] _difficultyLevels)
+        {
+            if (_difficultyLevels != null && _difficultyLevels.Length > 0)
+            {
+                DifficultyLevel[] buffer = new DifficultyLevel[_difficultyLevels.Length];
+
+                for (int i = 0; i < _difficultyLevels.Length; i++)
+                {
+                    buffer[i] = new DifficultyLevel(_difficultyLevels[i].difficulty.ToString(), _difficultyLevels[i].difficultyRank, string.Empty);
+                }
+
+
+                return buffer;
+            }
+            else
+            {
+                return null;
+            }
+        }
+
+    }
+    [Serializable]
+    public class RootObject
+    {
+        public Song[] songs;
+    }
+}

+ 43 - 0
SongBrowserPlugin/DataAccess/LoadScripts.cs

@@ -0,0 +1,43 @@
+using HMUI;
+using System.Collections;
+using System.Collections.Generic;
+using System.Linq;
+using UnityEngine;
+
+// From: https://github.com/andruzzzhka/BeatSaverDownloader
+namespace SongBrowserPlugin.DataAccess
+{
+    class LoadScripts
+    {
+        static public Dictionary<string, Sprite> _cachedSprites = new Dictionary<string, Sprite>();
+
+        static public IEnumerator LoadSprite(string spritePath, TableCell obj)
+        {
+            Texture2D tex;
+
+            if (_cachedSprites.ContainsKey(spritePath))
+            {
+                obj.GetComponentsInChildren<UnityEngine.UI.Image>(true).First(x => x.name == "CoverImage").sprite = _cachedSprites[spritePath];
+                yield break;
+            }
+
+            using (WWW www = new WWW(spritePath))
+            {
+                yield return www;
+                tex = www.texture;
+                var newSprite = Sprite.Create(tex, new Rect(0, 0, tex.width, tex.height), Vector2.one * 0.5f, 100, 1);
+                _cachedSprites.Add(spritePath, newSprite);
+                obj.GetComponentsInChildren<UnityEngine.UI.Image>(true).First(x => x.name == "CoverImage").sprite = newSprite;
+            }
+        }
+
+        static public IEnumerator LoadAudio(string audioPath, object obj, string fieldName)
+        {
+            using (var www = new WWW(audioPath))
+            {
+                yield return www;
+                SongLoaderPlugin.ReflectionUtil.SetPrivateField(obj, fieldName, www.GetAudioClip(true, true, AudioType.UNKNOWN));
+            }
+        }
+    }
+}

+ 9 - 1
SongBrowserPlugin/DataAccess/PlaylistSong.cs

@@ -8,7 +8,15 @@ namespace SongBrowserPlugin.DataAccess
 {
     public class PlaylistSong
     {
-        public int Key { get; set; }
+        public String Key { get; set; }
         public String SongName { get; set; }
+
+        // Set by playlist downloading
+        [NonSerialized]
+        public IStandardLevel Level;
+        [NonSerialized]
+        public bool OneSaber;
+        [NonSerialized]
+        public string Path;
     }
 }

+ 13 - 10
SongBrowserPlugin/DataAccess/SongBrowserModel.cs

@@ -242,7 +242,6 @@ namespace SongBrowserPlugin
             Stopwatch timer = new Stopwatch();
             timer.Start();
 
-
             // Get the level collection from song loader
             LevelCollectionsForGameplayModes levelCollections = Resources.FindObjectsOfTypeAll<LevelCollectionsForGameplayModes>().FirstOrDefault();
             List<LevelCollectionsForGameplayModes.LevelCollectionForGameplayMode> levelCollectionsForGameModes = ReflectionUtil.GetPrivateField<LevelCollectionsForGameplayModes.LevelCollectionForGameplayMode[]>(levelCollections, "_collections").ToList();
@@ -292,7 +291,8 @@ namespace SongBrowserPlugin
                         _levelIdToSongVersion.Add(level.levelID, version);
                     }
                 }
-            } 
+            }
+
             lastWriteTimer.Stop();
             _log.Info("Determining song download time and determining mappings took {0}ms", lastWriteTimer.ElapsedMilliseconds);
 
@@ -716,17 +716,20 @@ namespace SongBrowserPlugin
                 return;
             }
 
-            _log.Debug("Filtering songs for playlist: {0}", this.CurrentPlaylist);
-            List<String> playlistNameListOrdered = this.CurrentPlaylist.songs.Select(x => x.SongName).Distinct().ToList();
-            Dictionary<String, int> songNameToIndex = playlistNameListOrdered.Select((val, index) => new { Index = index, Value = val }).ToDictionary(i => i.Value, i => i.Index);
-            HashSet<String> songNames = new HashSet<String>(playlistNameListOrdered);
+            _log.Debug("Filtering songs for playlist: {0}", this.CurrentPlaylist.playlistTitle);            
+            List<String> playlistKeysOrdered = this.CurrentPlaylist.songs.Select(x => x.Key).Distinct().ToList();
+            Dictionary<String, int>playlistKeyToIndex = playlistKeysOrdered.Select((val, index) => new { Index = index, Value = val }).ToDictionary(i => i.Value, i => i.Index);
             LevelCollectionsForGameplayModes levelCollections = Resources.FindObjectsOfTypeAll<LevelCollectionsForGameplayModes>().FirstOrDefault();
-            List<StandardLevelSO> songList = levelCollections.GetLevels(_currentGamePlayMode).Where(x => songNames.Contains(x.songName)).ToList();
-            _log.Debug("\tMatching songs found for playlist: {0}", songList.Count);
+            var levels = levelCollections.GetLevels(_currentGamePlayMode);
+
+            var songList = levels.Where(x => !x.levelID.StartsWith("Level_") && _levelIdToSongVersion.ContainsKey(x.levelID) && playlistKeyToIndex.ContainsKey(_levelIdToSongVersion[x.levelID])).ToList();
+            
             _originalSongs = songList;
-            _filteredSongs = songList
-                .OrderBy(x => songNameToIndex[x.songName])
+            _filteredSongs = _originalSongs
+                .OrderBy(x => playlistKeyToIndex[_levelIdToSongVersion[x.levelID]])
                 .ToList();
+            
+            _log.Debug("Playlist filtered song count: {0}", _filteredSongs.Count);
         }
 
         private void SortOriginal(List<StandardLevelSO> levels)

+ 193 - 0
SongBrowserPlugin/Downloader.cs

@@ -0,0 +1,193 @@
+using SongBrowserPlugin.DataAccess.BeatSaverApi;
+using SongBrowserPlugin.UI;
+using SongLoaderPlugin;
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.IO;
+using System.IO.Compression;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using TMPro;
+using UnityEngine;
+using UnityEngine.Networking;
+
+namespace SongBrowserPlugin
+{
+    class Downloader : MonoBehaviour
+    {
+        private Logger _log = new Logger("Downloader");
+
+        public static Downloader Instance;
+
+        private StandardLevelDetailViewController _levelDetailViewController;
+
+        public Action<Song> downloadStarted;
+        public Action<Song> downloadFinished;
+
+        /// <summary>
+        /// Load this.
+        /// </summary>
+        internal static void OnLoad()
+        {
+            if (Instance != null)
+            {
+                return;
+            }
+
+            new GameObject("SongBrowserDownloader").AddComponent<Downloader>();            
+        }
+
+        /// <summary>
+        /// Downloader has awoken.
+        /// </summary>
+        private void Awake()
+        {
+            _log.Trace("Awake()");
+
+            Instance = this;
+        }
+
+        /// <summary>
+        /// Acquire any UI elements from Beat saber that we need.  Wait for the song list to be loaded.
+        /// </summary>
+        public void Start()
+        {
+            _log.Trace("Start()");
+
+            StandardLevelSelectionFlowCoordinator levelSelectionFlowCoordinator = Resources.FindObjectsOfTypeAll<StandardLevelSelectionFlowCoordinator>().First();
+            _levelDetailViewController = levelSelectionFlowCoordinator.GetPrivateField<StandardLevelDetailViewController>("_levelDetailViewController");            
+        }
+
+        /// <summary>
+        /// Handle downloading a song.  
+        /// Ported from: https://github.com/andruzzzhka/BeatSaverDownloader/blob/master/BeatSaverDownloader/PluginUI/PluginUI.cs
+        /// </summary>
+        /// <param name="songInfo"></param>
+        /// <returns></returns>
+        public IEnumerator DownloadSongCoroutine(Song songInfo)
+        {
+            songInfo.songQueueState = SongQueueState.Downloading;
+
+            downloadStarted?.Invoke(songInfo);
+
+            UnityWebRequest www;
+            bool timeout = false;
+            float time = 0f;
+            UnityWebRequestAsyncOperation asyncRequest;
+
+            try
+            {
+                www = UnityWebRequest.Get(songInfo.downloadUrl);
+
+                asyncRequest = www.SendWebRequest();
+            }
+            catch
+            {
+                songInfo.songQueueState = SongQueueState.Error;
+                songInfo.downloadingProgress = 1f;
+
+                yield break;
+            }
+
+            while ((!asyncRequest.isDone || songInfo.downloadingProgress != 1f) && songInfo.songQueueState != SongQueueState.Error)
+            {
+                yield return null;
+
+                time += Time.deltaTime;
+
+                if ((time >= 15f && asyncRequest.progress == 0f) || songInfo.songQueueState == SongQueueState.Error)
+                {
+                    www.Abort();
+                    timeout = true;
+                }
+
+                songInfo.downloadingProgress = asyncRequest.progress;
+            }
+        
+            if (www.isNetworkError || www.isHttpError || timeout || songInfo.songQueueState == SongQueueState.Error)
+            {
+                if (timeout)
+                {
+                    songInfo.songQueueState = SongQueueState.Error;
+                    TextMeshProUGUI _errorText = UIBuilder.CreateText(_levelDetailViewController.rectTransform, "Request timeout", new Vector2(18f, -64f), new Vector2(60f, 10f));
+                    Destroy(_errorText.gameObject, 2f);
+                }
+                else
+                {
+                    songInfo.songQueueState = SongQueueState.Error;
+                    _log.Error($"Downloading error: {www.error}");
+                    TextMeshProUGUI _errorText = UIBuilder.CreateText(_levelDetailViewController.rectTransform, www.error, new Vector2(18f, -64f), new Vector2(60f, 10f));
+                    Destroy(_errorText.gameObject, 2f);
+                }
+            }
+            else
+            {
+
+                _log.Debug("Received response from BeatSaver.com...");
+
+                string zipPath = "";
+                string docPath = "";
+                string customSongsPath = "";
+
+                byte[] data = www.downloadHandler.data;
+
+                try
+                {
+
+                    docPath = Application.dataPath;
+                    docPath = docPath.Substring(0, docPath.Length - 5);
+                    docPath = docPath.Substring(0, docPath.LastIndexOf("/"));
+                    customSongsPath = docPath + "/CustomSongs/" + songInfo.id + "/";
+                    zipPath = customSongsPath + songInfo.id + ".zip";
+                    if (!Directory.Exists(customSongsPath))
+                    {
+                        Directory.CreateDirectory(customSongsPath);
+                    }
+                    File.WriteAllBytes(zipPath, data);
+                    _log.Debug("Downloaded zip file!");
+                }
+                catch (Exception e)
+                {
+                    _log.Exception("EXCEPTION: ", e);
+                    songInfo.songQueueState = SongQueueState.Error;
+                    yield break;
+                }
+
+                _log.Debug("Extracting...");
+
+                try
+                {
+                    ZipFile.ExtractToDirectory(zipPath, customSongsPath);
+                }
+                catch (Exception e)
+                {
+                    _log.Exception("Can't extract ZIP! Exception: ", e);
+                }
+
+                songInfo.path = Directory.GetDirectories(customSongsPath).FirstOrDefault();
+
+                if (string.IsNullOrEmpty(songInfo.path))
+                {
+                    songInfo.path = customSongsPath;
+                }
+
+                try
+                {
+                    File.Delete(zipPath);
+                }
+                catch (IOException e)
+                {
+                    _log.Warning($"Can't delete zip! Exception: {e}");
+                }
+
+                songInfo.songQueueState = SongQueueState.Downloaded;
+
+                _log.Debug("Downloaded!");
+
+                downloadFinished?.Invoke(songInfo);
+            }
+        }
+    }
+}

+ 3 - 2
SongBrowserPlugin/Plugin.cs

@@ -13,7 +13,7 @@ namespace SongBrowserPlugin
 
         public string Version
         {
-            get { return "v2.3.0"; }
+            get { return "v2.3.1"; }
         }
 
         public void OnApplicationStart()
@@ -28,7 +28,7 @@ namespace SongBrowserPlugin
 
         private void SceneManagerOnActiveSceneChanged(Scene arg0, Scene scene)
         {
-
+        
         }
 
         private void SceneManager_sceneLoaded(Scene arg0, LoadSceneMode arg1)
@@ -40,6 +40,7 @@ namespace SongBrowserPlugin
             if (SceneManager.GetSceneByBuildIndex(level).name == "Menu")
             {
                 SongBrowserApplication.OnLoad();
+                Downloader.OnLoad();
             }
         }
 

+ 3 - 2
SongBrowserPlugin/SongBrowserApplication.cs

@@ -25,8 +25,9 @@ namespace SongBrowserPlugin
 
         public static SongBrowserPlugin.UI.ProgressBar MainProgressBar;
 
+
         /// <summary>
-        /// 
+        /// Load the main song browser app.
         /// </summary>
         internal static void OnLoad()
         {            
@@ -39,7 +40,7 @@ namespace SongBrowserPlugin
         }
 
         /// <summary>
-        /// 
+        /// It has awaken!
         /// </summary>
         private void Awake()
         {

+ 6 - 0
SongBrowserPlugin/SongBrowserPlugin.csproj

@@ -48,6 +48,7 @@
     </Reference>
     <Reference Include="System" />
     <Reference Include="System.Data" />
+    <Reference Include="System.IO.Compression.FileSystem" />
     <Reference Include="System.Security" />
     <Reference Include="System.Xml" />
     <Reference Include="TextMeshPro-1.0.55.2017.1.0b12, Version=0.0.0.0, Culture=neutral, processorArchitecture=MSIL">
@@ -100,15 +101,20 @@
     </Reference>
   </ItemGroup>
   <ItemGroup>
+    <Compile Include="DataAccess\BeatSaverApi\BeatSaverApiResults.cs" />
     <Compile Include="DataAccess\FileSystem\Directory.cs" />
     <Compile Include="DataAccess\FileSystem\Folder.cs" />
+    <Compile Include="DataAccess\LoadScripts.cs" />
     <Compile Include="DataAccess\Network\CacheableDownloadHandler.cs" />
     <Compile Include="DataAccess\Network\CacheableDownloadHandlerScoreSaberData.cs" />
     <Compile Include="DataAccess\Playlist.cs" />
     <Compile Include="DataAccess\PlaylistReader.cs" />
     <Compile Include="DataAccess\PlaylistSong.cs" />
     <Compile Include="DataAccess\ScoreSaberDatabase.cs" />
+    <Compile Include="Downloader.cs" />
     <Compile Include="Logger.cs" />
+    <Compile Include="UI\DownloadQueue\DownloadQueueTableCell.cs" />
+    <Compile Include="UI\DownloadQueue\DownloadQueueViewController.cs" />
     <Compile Include="UI\ProgressBar.cs" />
     <Compile Include="UI\ScoreSaberDatabaseDownloader.cs" />
     <Compile Include="SongBrowserApplication.cs" />

+ 98 - 0
SongBrowserPlugin/UI/DownloadQueue/DownloadQueueTableCell.cs

@@ -0,0 +1,98 @@
+using SongBrowserPlugin.DataAccess;
+using SongBrowserPlugin.DataAccess.BeatSaverApi;
+using System.Reflection;
+using UnityEngine;
+
+// From: https://github.com/andruzzzhka/BeatSaverDownloader
+namespace SongBrowserPlugin.UI.DownloadQueue
+{
+    class DownloadQueueTableCell : StandardLevelListTableCell
+    {
+        Song song;
+
+
+        protected override void Awake()
+        {
+            base.Awake();
+        }
+
+        public void Init(Song _song)
+        {
+            StandardLevelListTableCell cell = GetComponent<StandardLevelListTableCell>();
+
+            foreach (FieldInfo info in cell.GetType().GetFields(BindingFlags.Public | BindingFlags.Instance | BindingFlags.NonPublic))
+            {
+                info.SetValue(this, info.GetValue(cell));
+            }
+
+            Destroy(cell);
+
+            reuseIdentifier = "DownloadCell";
+
+            song = _song;
+
+            songName = string.Format("{0}\n<size=80%>{1}</size>", song.songName, song.songSubName);
+            author = song.authorName;
+            StartCoroutine(LoadScripts.LoadSprite(song.coverUrl, this));
+
+            _bgImage.enabled = true;
+            _bgImage.sprite = Sprite.Create((new Texture2D(1, 1)), new Rect(0, 0, 1, 1), Vector2.one / 2f);
+            _bgImage.type = UnityEngine.UI.Image.Type.Filled;
+            _bgImage.fillMethod = UnityEngine.UI.Image.FillMethod.Horizontal;
+
+            switch (song.songQueueState)
+            {
+                case SongQueueState.Queued:
+                case SongQueueState.Downloading:
+                    {
+                        _bgImage.color = new Color(1f, 1f, 1f, 0.35f);
+                        _bgImage.fillAmount = song.downloadingProgress;
+                    }
+                    break;
+                case SongQueueState.Available:
+                case SongQueueState.Downloaded:
+                    {
+                        _bgImage.color = new Color(1f, 1f, 1f, 0.35f);
+                        _bgImage.fillAmount = 1f;
+                    }
+                    break;
+                case SongQueueState.Error:
+                    {
+                        _bgImage.color = new Color(1f, 0f, 0f, 0.35f);
+                        _bgImage.fillAmount = 1f;
+                    }
+                    break;
+            }
+        }
+
+        public void Update()
+        {
+
+            _bgImage.enabled = true;
+            switch (song.songQueueState)
+            {
+                case SongQueueState.Queued:
+                case SongQueueState.Downloading:
+                    {
+                        _bgImage.color = new Color(1f, 1f, 1f, 0.35f);
+                        _bgImage.fillAmount = song.downloadingProgress;
+                    }
+                    break;
+                case SongQueueState.Available:
+                case SongQueueState.Downloaded:
+                    {
+                        _bgImage.color = new Color(1f, 1f, 1f, 0.35f);
+                        _bgImage.fillAmount = 1f;
+                    }
+                    break;
+                case SongQueueState.Error:
+                    {
+                        _bgImage.color = new Color(1f, 0f, 0f, 0.35f);
+                        _bgImage.fillAmount = 1f;
+                    }
+                    break;
+            }
+        }
+
+    }
+}

+ 174 - 0
SongBrowserPlugin/UI/DownloadQueue/DownloadQueueViewController.cs

@@ -0,0 +1,174 @@
+using HMUI;
+using SongBrowserPlugin.DataAccess.BeatSaverApi;
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Linq;
+using TMPro;
+using UnityEngine;
+using UnityEngine.UI;
+using VRUI;
+
+
+// Modified From: https://github.com/andruzzzhka/BeatSaverDownloader
+// - Adding queue count
+namespace SongBrowserPlugin.UI.DownloadQueue
+{
+    public class DownloadQueueViewController : VRUIViewController, TableView.IDataSource
+    {
+        private Logger _log = new Logger("DownloadQueueViewController");
+
+        public Action allSongsDownloaded;
+        public List<Song> _queuedSongs = new List<Song>();
+
+        TextMeshProUGUI _titleText;
+        TextMeshProUGUI _queueCountText;
+
+        Button _abortButton;
+        TableView _queuedSongsTableView;
+        StandardLevelListTableCell _songListTableCellInstance;
+
+        protected override void DidActivate(bool firstActivation, ActivationType type)
+        {
+
+            _songListTableCellInstance = Resources.FindObjectsOfTypeAll<StandardLevelListTableCell>().First(x => (x.name == "StandardLevelListTableCell"));
+
+            if (_titleText == null)
+            {
+                _titleText = UIBuilder.CreateText(rectTransform, "DOWNLOAD QUEUE", new Vector2(0f, -6f), new Vector2(60f, 10f));
+                _titleText.alignment = TextAlignmentOptions.Top;
+                _titleText.fontSize = 8;
+            }
+
+            if (_queueCountText == null)
+            {
+                _queueCountText = UIBuilder.CreateText(rectTransform, "Queue Count", new Vector2(20f, -50f), new Vector2(60f, 10f));
+                _queueCountText.alignment = TextAlignmentOptions.Right;
+                _queueCountText.fontSize = 5;
+                _queueCountText.text = "Queue Count: 0";
+            }
+
+            if (_queuedSongsTableView == null)
+            {
+                _queuedSongsTableView = new GameObject().AddComponent<TableView>();
+
+                _queuedSongsTableView.transform.SetParent(rectTransform, false);
+
+                _queuedSongsTableView.dataSource = this;
+
+                (_queuedSongsTableView.transform as RectTransform).anchorMin = new Vector2(0.3f, 0.5f);
+                (_queuedSongsTableView.transform as RectTransform).anchorMax = new Vector2(0.7f, 0.5f);
+                (_queuedSongsTableView.transform as RectTransform).sizeDelta = new Vector2(0f, 60f);
+                (_queuedSongsTableView.transform as RectTransform).anchoredPosition = new Vector3(0f, -3f);
+            }
+
+            if (_abortButton == null)
+            {
+                _abortButton = UIBuilder.CreateUIButton(rectTransform, "SettingsButton");
+                UIBuilder.SetButtonText(ref _abortButton, "Abort All");
+
+                (_abortButton.transform as RectTransform).sizeDelta = new Vector2(30f, 10f);
+                (_abortButton.transform as RectTransform).anchoredPosition = new Vector2(-4f, 6f);
+
+                _abortButton.onClick.AddListener(delegate () {
+                    AbortDownloads();
+                });
+            }
+
+            AbortDownloads();
+        }
+
+        public void AbortDownloads()
+        {
+            _log.Info("Cancelling downloads...");
+            foreach (Song song in _queuedSongs.Where(x => x.songQueueState == SongQueueState.Downloading || x.songQueueState == SongQueueState.Queued))
+            {
+                song.songQueueState = SongQueueState.Error;
+                song.downloadingProgress = 1f;
+            }
+            Refresh();
+            allSongsDownloaded?.Invoke();
+        }
+
+        protected override void DidDeactivate(DeactivationType type)
+        {
+
+        }
+
+        public void EnqueueSong(Song song, bool startDownload = true)
+        {
+            _queuedSongs.Add(song);
+            
+            song.songQueueState = SongQueueState.Queued;
+
+            Refresh();
+
+            if (startDownload)
+                StartCoroutine(DownloadSong(song));
+        }
+
+        public void DownloadAllSongsFromQueue()
+        {
+            _log.Info("Downloading all songs from queue...");
+
+            for (int i = 0; i < Math.Min(_queuedSongs.Count(x => x.songQueueState == SongQueueState.Queued), 4); i++)
+            {
+                StartCoroutine(DownloadSong(_queuedSongs[i]));
+            }
+        }
+
+        IEnumerator DownloadSong(Song song)
+        {
+            yield return Downloader.Instance.DownloadSongCoroutine(song);
+
+            _queuedSongs.Remove(song);
+            song.songQueueState = SongQueueState.Available;
+            Refresh();
+
+            if (!_queuedSongs.Any(x => x.songQueueState == SongQueueState.Downloading || x.songQueueState == SongQueueState.Queued))
+            {
+                allSongsDownloaded?.Invoke();
+            }
+            else
+            {
+                if (_queuedSongs.Any(x => x.songQueueState == SongQueueState.Queued))
+                {
+                    StartCoroutine(DownloadSong(_queuedSongs.First(x => x.songQueueState == SongQueueState.Queued)));
+                }
+            }
+        }
+
+        public void Refresh()
+        {
+            int removed = _queuedSongs.RemoveAll(x => x.songQueueState != SongQueueState.Downloading && x.songQueueState != SongQueueState.Queued);
+
+            _log.Debug($"Removed {removed} songs from queue");
+
+            _queuedSongsTableView.ReloadData();
+            _queuedSongsTableView.ScrollToRow(0, true);
+
+            _queueCountText.text = "Queue Count: " + _queuedSongs.Count;
+        }
+
+        public float RowHeight()
+        {
+            return 10f;
+        }
+
+        public int NumberOfRows()
+        {
+            return _queuedSongs.Count;
+        }
+
+        public TableCell CellForRow(int row)
+        {
+            StandardLevelListTableCell _tableCell = Instantiate(_songListTableCellInstance);
+
+            DownloadQueueTableCell _queueCell = _tableCell.gameObject.AddComponent<DownloadQueueTableCell>();
+
+            _queueCell.Init(_queuedSongs[row]);
+
+            return _queueCell;
+        }
+    }
+}

+ 53 - 4
SongBrowserPlugin/UI/Playlists/PlaylistDetailViewController.cs

@@ -1,4 +1,5 @@
 using SongBrowserPlugin.DataAccess;
+using SongBrowserPlugin.UI.DownloadQueue;
 using System;
 using System.Collections.Generic;
 using System.Linq;
@@ -18,11 +19,32 @@ namespace SongBrowserPlugin.UI
         private TextMeshProUGUI _playlistTitleText;
         private TextMeshProUGUI _playlistAuthorText;
         private TextMeshProUGUI _playlistNumberOfSongs;
+        private TextMeshProUGUI _playlistMissingSongCount;
+
         private Button _selectButton;
+        Button _downloadButton;
 
         public Action<Playlist> didPressPlayPlaylist;
+        public event Action didPressDownloadPlaylist;
+       
+        /// <summary>
+        /// Override the left/right screen selector.
+        /// Put the Download Queue into the left screen.
+        /// </summary>
+        /// <param name="leftScreenViewController"></param>
+        /// <param name="rightScreenViewController"></param>
+        protected override void LeftAndRightScreenViewControllers(out VRUIViewController leftScreenViewController, out VRUIViewController rightScreenViewController)
+        {
+            PlaylistFlowCoordinator playlistFlowCoordinator = Resources.FindObjectsOfTypeAll<PlaylistFlowCoordinator>().First();
+            leftScreenViewController = playlistFlowCoordinator.DownloadQueueViewController;
+            rightScreenViewController = null;
+        }
 
-        public void Init(Playlist playlist)
+        /// <summary>
+        /// Initialize the UI Elements.
+        /// </summary>
+        /// <param name="playlist"></param>
+        public void Init(Playlist playlist, int missingCount)
         {
             _playlistTitleText = UIBuilder.CreateText(this.transform as RectTransform,
                 playlist.playlistTitle,
@@ -45,22 +67,49 @@ namespace SongBrowserPlugin.UI
             );
             _playlistNumberOfSongs.alignment = TextAlignmentOptions.Center;
 
+            _playlistMissingSongCount = UIBuilder.CreateText(this.transform as RectTransform,
+                missingCount.ToString(),
+                new Vector2(0, -50),
+                new Vector2(60f, 10f)
+            );
+            _playlistMissingSongCount.alignment = TextAlignmentOptions.Center;
+
             Button buttonTemplate = Resources.FindObjectsOfTypeAll<Button>().FirstOrDefault(x => x.name == "PlayButton");
-            _selectButton = UIBuilder.CreateButton(this.transform as RectTransform, buttonTemplate, "Select", 3, 0, 3.5f, 25, 6);
+            _selectButton = UIBuilder.CreateButton(this.transform as RectTransform, buttonTemplate, "Select Playlist", 3, 0, 3.5f, 45, 6);
             _selectButton.onClick.AddListener(delegate ()
             {
                 didPressPlayPlaylist.Invoke(_selectedPlaylist);
             });
 
-            SetContent(playlist);
+            _downloadButton = UIBuilder.CreateButton(this.transform as RectTransform, buttonTemplate, "Download All Songs", 3, 0, 11.5f, 45, 6);
+            UIBuilder.SetButtonText(ref _downloadButton, "Download");
+            _downloadButton.onClick.AddListener(delegate () { didPressDownloadPlaylist?.Invoke(); });
+
+            SetContent(playlist, missingCount);
         }
 
-        public virtual void SetContent(Playlist p)
+        /// <summary>
+        /// Set the content.
+        /// </summary>
+        /// <param name="p"></param>
+        public virtual void SetContent(Playlist p, int missingCount)
         {
             _selectedPlaylist = p;
             _playlistTitleText.text = _selectedPlaylist.playlistTitle;
             _playlistAuthorText.text = _selectedPlaylist.playlistAuthor;
             _playlistNumberOfSongs.text = "Song Count: " + _selectedPlaylist.songs.Count.ToString();
+            _playlistMissingSongCount.text = "Missing Count: " + missingCount;
+        }
+
+        /// <summary>
+        /// Disable / Enable the select and play buttons.
+        /// </summary>
+        /// <param name="enableSelect"></param>
+        /// <param name="enableDownload"></param>
+        public void UpdateButtons(bool enableSelect, bool enableDownload)
+        {
+            _selectButton.interactable = enableSelect;
+            _downloadButton.interactable = enableDownload;
         }
     }
 }

+ 199 - 7
SongBrowserPlugin/UI/Playlists/PlaylistFlowCoordinator.cs

@@ -1,13 +1,24 @@
-using SongBrowserPlugin.DataAccess;
+using SimpleJSON;
+using SongBrowserPlugin.DataAccess;
+using SongBrowserPlugin.DataAccess.BeatSaverApi;
+using SongBrowserPlugin.UI.DownloadQueue;
+using SongLoaderPlugin;
 using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
 using System.Reflection;
 using UnityEngine;
+using UnityEngine.Networking;
 using VRUI;
 
 namespace SongBrowserPlugin.UI
 {
     public class PlaylistFlowCoordinator : FlowCoordinator
     {
+        public static string beatsaverURL = "https://beatsaver.com";
+
         public const String Name = "PlaylistFlowCoordinator";
         private Logger _log = new Logger(Name);
 
@@ -15,17 +26,29 @@ namespace SongBrowserPlugin.UI
         private PlaylistSelectionListViewController _playlistListViewController;
         private PlaylistDetailViewController _playlistDetailViewController;
 
+        public DownloadQueueViewController DownloadQueueViewController;
+
         private bool _initialized;
 
+        private bool _downloadingPlaylist;
+
+        private Song _lastRequestedSong;
+
+        /// <summary>
+        /// User pressed "select" on the playlist.
+        /// </summary>
         public Action<Playlist> didSelectPlaylist;
 
+        /// <summary>
+        /// Destroy.
+        /// </summary>
         public virtual void OnDestroy()
         {
             _log.Trace("OnDestroy()");
         }
 
         /// <summary>
-        /// 
+        /// Present the playlist selector flow.
         /// </summary>
         /// <param name="parentViewController"></param>
         /// <param name="levels"></param>
@@ -39,13 +62,18 @@ namespace SongBrowserPlugin.UI
                 _playlistListViewController = UIBuilder.CreateViewController<PlaylistSelectionListViewController>("PlaylistSelectionListViewController");
                 _playlistDetailViewController = UIBuilder.CreateViewController<PlaylistDetailViewController>("PlaylistDetailViewController");
 
+                this.DownloadQueueViewController = UIBuilder.CreateViewController<DownloadQueueViewController>("DownloadQueueViewController");
+
                 // Set parent view controllers appropriately.
                 _playlistNavigationController.GetType().GetField("_parentViewController", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic).SetValue(_playlistNavigationController, parentViewController);
                 _playlistListViewController.GetType().GetField("_parentViewController", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic).SetValue(_playlistListViewController, _playlistNavigationController);
                 _playlistDetailViewController.GetType().GetField("_parentViewController", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic).SetValue(_playlistDetailViewController, _playlistListViewController);
 
                 this._playlistListViewController.didSelectPlaylistRowEvent += HandlePlaylistListDidSelectPlaylist;
+
                 this._playlistDetailViewController.didPressPlayPlaylist += HandleDidPlayPlaylist;
+                this._playlistDetailViewController.didPressDownloadPlaylist += HandleDownloadPressed;
+
                 this._playlistNavigationController.didDismissEvent += HandleDidFinish;
 
                 _playlistListViewController.rectTransform.anchorMin = new Vector2(0.3f, 0f);
@@ -68,14 +96,18 @@ namespace SongBrowserPlugin.UI
         public virtual void HandlePlaylistListDidSelectPlaylist(PlaylistSelectionListViewController playlistListViewController)
         {
             _log.Debug("Selected Playlist: {0}", playlistListViewController.SelectedPlaylist.playlistTitle);
+
+            int missingCount = CountMissingSongs(playlistListViewController.SelectedPlaylist);
+
             if (!this._playlistDetailViewController.isInViewControllerHierarchy)
             {
-                this._playlistDetailViewController.Init(playlistListViewController.SelectedPlaylist);
+                this._playlistDetailViewController.Init(playlistListViewController.SelectedPlaylist, missingCount);
                 this._playlistNavigationController.PushViewController(this._playlistDetailViewController, playlistListViewController.isRebuildingHierarchy);
             }
             else
             {
-                this._playlistDetailViewController.SetContent(playlistListViewController.SelectedPlaylist);
+                this._playlistDetailViewController.SetContent(playlistListViewController.SelectedPlaylist, missingCount);
+                this._playlistDetailViewController.UpdateButtons(!_downloadingPlaylist, !_downloadingPlaylist);
             }
         }
 
@@ -106,6 +138,12 @@ namespace SongBrowserPlugin.UI
         {
             try
             {
+                if (this.DownloadQueueViewController._queuedSongs.Any(x => x.songQueueState == SongQueueState.Queued || x.songQueueState == SongQueueState.Downloading))
+                {
+                    _log.Debug("Aborting downloads...");
+                    this.DownloadQueueViewController.AbortDownloads();
+                }
+
                 _log.Debug("Playlist selector dismissed...");
                 this._playlistNavigationController.DismissModalViewController(delegate ()
                 {
@@ -117,11 +155,165 @@ namespace SongBrowserPlugin.UI
                 _log.Exception("", e);
             }
         }
-        
+
+        /// <summary>
+        /// Filter songs that we don't have data for.
+        /// </summary>
+        /// <param name="songs"></param>
+        /// <param name="playlist"></param>
+        /// <returns></returns>
+        private void FilterSongsForPlaylist(Playlist playlist)
+        {
+            if (!playlist.songs.All(x => x.Level != null))
+            {
+                playlist.songs.ForEach(x =>
+                {
+                    if (x.Level == null)
+                    {
+                        x.Level = SongLoader.CustomLevels.FirstOrDefault(y => y.customSongInfo.path.Contains(x.Key) && Directory.Exists(y.customSongInfo.path));
+                    }
+                });
+            }
+        }
+
+        /// <summary>
+        /// Count missing songs for display.
+        /// </summary>
+        /// <param name="playlist"></param>
+        /// <returns></returns>
+        private int CountMissingSongs(Playlist playlist)
+        {
+            return playlist.songs.Count - playlist.songs.Count(x => SongLoader.CustomLevels.Any(y => y.customSongInfo.path.Contains(x.Key)));
+        }
+
+        /// <summary>
+        /// Download playlist button pressed.
+        /// </summary>
+        private void HandleDownloadPressed()
+        {
+            if (!_downloadingPlaylist)
+            {
+                StartCoroutine(DownloadPlaylist(this._playlistListViewController.SelectedPlaylist));
+            }
+            else
+            {
+                _log.Info("Already downloading playlist!");
+            }
+        }
+
+        /// <summary>
+        /// Download Playlist.
+        /// </summary>
+        /// <param name="playlist"></param>
+        /// <returns></returns>
+        public IEnumerator DownloadPlaylist(Playlist playlist)
+        {
+            Playlist selectedPlaylist = this._playlistListViewController.SelectedPlaylist;
+            this.FilterSongsForPlaylist(selectedPlaylist);
+            List<PlaylistSong> playlistSongsToDownload = selectedPlaylist.songs.Where(x => x.Level == null).ToList();
+
+            List<Song> beatSaverSongs = new List<Song>();
+
+            DownloadQueueViewController.AbortDownloads();
+            _downloadingPlaylist = true;
+            _playlistDetailViewController.UpdateButtons(!_downloadingPlaylist, !_downloadingPlaylist);
+
+            foreach (var item in playlistSongsToDownload)
+            {
+                _log.Debug("Obtaining hash and url for " + item.Key + ": " + item.SongName);
+                yield return GetSongByPlaylistSong(item);
+
+                _log.Debug("Song is null: " + (_lastRequestedSong == null) + "\n Level is downloaded: " + (SongLoader.CustomLevels.Any(x => x.levelID.Substring(0, 32) == _lastRequestedSong.hash.ToUpper())));
+
+                if (_lastRequestedSong != null && !SongLoader.CustomLevels.Any(x => x.levelID.Substring(0, 32) == _lastRequestedSong.hash.ToUpper()))
+                {
+                    _log.Debug(item.Key + ": " + item.SongName + "  -  " + _lastRequestedSong.hash);
+                    beatSaverSongs.Add(_lastRequestedSong);
+                    DownloadQueueViewController.EnqueueSong(_lastRequestedSong, false);
+                }
+            }
+
+            _log.Info($"Need to download {beatSaverSongs.Count(x => x.songQueueState == SongQueueState.Queued)} songs:");
+
+            if (!beatSaverSongs.Any(x => x.songQueueState == SongQueueState.Queued))
+            {
+                _downloadingPlaylist = false;
+                _playlistDetailViewController.UpdateButtons(!_downloadingPlaylist, !_downloadingPlaylist);
+            }
+
+            foreach (var item in beatSaverSongs.Where(x => x.songQueueState == SongQueueState.Queued))
+            {
+                _log.Debug(item.songName);
+            }
+
+            DownloadQueueViewController.allSongsDownloaded -= AllSongsDownloaded;
+            DownloadQueueViewController.allSongsDownloaded += AllSongsDownloaded;
+
+            DownloadQueueViewController.DownloadAllSongsFromQueue();
+        }
+
+        /// <summary>
+        /// Songs finished downloading.
+        /// </summary>
+        private void AllSongsDownloaded()
+        {
+            SongLoader.Instance.RefreshSongs(false);
+
+            this.FilterSongsForPlaylist(this._playlistListViewController.SelectedPlaylist);
+
+            _downloadingPlaylist = false;
+            _playlistDetailViewController.UpdateButtons(!_downloadingPlaylist, !_downloadingPlaylist);
+        }
+
+        /// <summary>
+        /// Fetch the song info from beat saver.
+        /// </summary>
+        /// <param name="song"></param>
+        /// <returns></returns>
+        public IEnumerator GetSongByPlaylistSong(PlaylistSong song)
+        {
+            UnityWebRequest wwwId = null;
+            try
+            {
+                wwwId = UnityWebRequest.Get($"{PlaylistFlowCoordinator.beatsaverURL}/api/songs/detail/" + song.Key);
+                wwwId.timeout = 10;
+            }
+            catch
+            {
+                _lastRequestedSong = new Song() { songName = song.SongName, songQueueState = SongQueueState.Error, downloadingProgress = 1f, hash = "" };
+
+                yield break;
+            }
+
+            yield return wwwId.SendWebRequest();
+
+            if (wwwId.isNetworkError || wwwId.isHttpError)
+            {
+                _log.Error(wwwId.error);
+                _log.Error($"Song {song.SongName} doesn't exist on BeatSaver!");
+                _lastRequestedSong = new Song() { songName = song.SongName, songQueueState = SongQueueState.Error, downloadingProgress = 1f, hash = "" };
+            }
+            else
+            {
+                JSONNode node = JSON.Parse(wwwId.downloadHandler.text);
+                Song _tempSong = new Song(node["song"]);
+
+                _lastRequestedSong = _tempSong;
+            }
+        }
+
+        /// <summary>
+        /// Useful playlist navigation.
+        /// Shift+Enter downloads.
+        /// Enter selects.
+        /// </summary>
         public void LateUpdate()
         {
-            // accept
-            if (Input.GetKeyDown(KeyCode.Return))
+            if (Input.GetKey(KeyCode.LeftShift) && Input.GetKeyDown(KeyCode.Return))
+            {
+                HandleDownloadPressed();
+            }
+            else if (Input.GetKeyDown(KeyCode.Return))
             {
                 HandleDidPlayPlaylist(this._playlistListViewController.SelectedPlaylist);
             }

+ 6 - 2
SongBrowserPlugin/UI/Playlists/PlaylistTableView.cs

@@ -185,7 +185,11 @@ namespace SongBrowserPlugin.UI
             else if (Input.GetKeyDown(KeyCode.N))
             {
                 _selectedRow = (_selectedRow - 1) % this._reader.Playlists.Count;
-                _tableView.SelectRow(_selectedRow);
+                if (_selectedRow < 0)
+                {
+                    _selectedRow = this._reader.Playlists.Count-1;
+                }
+                _tableView.ScrollToRow(_selectedRow, true);
                 this.HandleDidSelectRowEvent(_tableView, _selectedRow);
             }
 
@@ -196,7 +200,7 @@ namespace SongBrowserPlugin.UI
             else if (Input.GetKeyDown(KeyCode.M))
             {
                 _selectedRow = (_selectedRow + 1) % this._reader.Playlists.Count;
-                _tableView.SelectRow(_selectedRow);
+                _tableView.ScrollToRow(_selectedRow, true);
                 this.HandleDidSelectRowEvent(_tableView, _selectedRow);
             }
         }