Browse Source

Merge pull request #8 from halsafar/devel

Merge devel.
Stephen Damm 6 years ago
parent
commit
c91237be9a

+ 2 - 0
README.md

@@ -17,8 +17,10 @@ A plugin for customizing the in-game song browser.
   - Newest: Sort songs by their last write time.
   - PlayCount: Sort by playcount (sum of play counts across all difficulties for a given song).
   - Random: Randomize the song list each time.
+  - Search: Enter a search term.
 - Clicking a sorting method will resort the song list immediately.
 - Sort buttons can be pressed a second time to invert the sorting.
+- Fast scroll buttons (jumps 10% of your song list in each press).
 
 ## Status
 - Working!

+ 10 - 1
SongBrowserPlugin/DataAccess/SongBrowserSettings.cs

@@ -16,13 +16,15 @@ namespace SongBrowserPlugin.DataAccess
         Newest,
         PlayCount,
         Random,
+        Search
     }
 
     [Serializable]
     public class SongBrowserSettings
     {
         public SongSortMode sortMode = default(SongSortMode);
-        public List<String> favorites;
+        public List<String> favorites = default(List<String>);
+        public List<String> searchTerms = default(List<String>);
 
         [NonSerialized]
         private static Logger Log = new Logger("SongBrowserSettings");
@@ -33,6 +35,7 @@ namespace SongBrowserPlugin.DataAccess
         public SongBrowserSettings()
         {
             favorites = new List<String>();
+            searchTerms = new List<string>();
         }
 
         /// <summary>
@@ -93,6 +96,12 @@ namespace SongBrowserPlugin.DataAccess
         {            
             String settingsFilePath = SongBrowserSettings.SettingsPath();
 
+            // TODO - not here
+            if (searchTerms.Count > 10)
+            {
+                searchTerms.RemoveRange(10, searchTerms.Count - 10);
+            }
+
             FileStream fs = new FileStream(settingsFilePath, FileMode.Create, FileAccess.Write);
             
             XmlSerializer serializer = new XmlSerializer(typeof(SongBrowserSettings));           

+ 1 - 0
SongBrowserPlugin/Logger.cs

@@ -11,6 +11,7 @@ namespace SongBrowserPlugin
         Warn,
         Error
     }
+
     public class Logger
     {
         private string loggerName;

+ 3 - 5
SongBrowserPlugin/SongBrowserApplication.cs

@@ -13,6 +13,7 @@ namespace SongBrowserPlugin
 {
     public class SongBrowserApplication : MonoBehaviour
     {
+        // Which scene index to load into
         public const int MenuIndex = 1;
 
         public static SongBrowserApplication Instance;
@@ -22,7 +23,6 @@ namespace SongBrowserPlugin
         // Song Browser UI Elements
         private SongBrowserUI _songBrowserUI;
         public Dictionary<String, Sprite> CachedIcons;
-        //public Button PlayButtonTemplate;
 
         /// <summary>
         /// 
@@ -49,7 +49,7 @@ namespace SongBrowserPlugin
         }
 
         /// <summary>
-        /// 
+        /// Acquire any UI elements from Beat saber that we need.  Wait for the song list to be loaded.
         /// </summary>
         public void Start()
         {
@@ -122,8 +122,6 @@ namespace SongBrowserPlugin
                     CachedIcons.Add(sprite.name, sprite);
                 }
 
-                //PlayButtonTemplate = Resources.FindObjectsOfTypeAll<Button>().First(x => (x.name == "PlayButton"));
-
                 // Append our own event to appropriate events so we can refresh the song list before the user sees it.
                 MainFlowCoordinator mainFlow = Resources.FindObjectsOfTypeAll<MainFlowCoordinator>().First();
                 SoloModeSelectionViewController view = Resources.FindObjectsOfTypeAll<SoloModeSelectionViewController>().First();                
@@ -150,7 +148,7 @@ namespace SongBrowserPlugin
         /// Helper for invoking buttons.
         /// </summary>
         /// <param name="buttonName"></param>
-        private void InvokeBeatSaberButton(String buttonName)
+        public static void InvokeBeatSaberButton(String buttonName)
         {
             Button buttonInstance = Resources.FindObjectsOfTypeAll<Button>().First(x => (x.name == buttonName));
             buttonInstance.onClick.Invoke();

+ 136 - 72
SongBrowserPlugin/SongBrowserModel.cs

@@ -22,6 +22,7 @@ namespace SongBrowserPlugin
         private Dictionary<String, SongLoaderPlugin.OverrideClasses.CustomLevel> _levelIdToCustomLevel;
         private SongLoaderPlugin.OverrideClasses.CustomLevelCollectionSO _gameplayModeCollection;    
         private Dictionary<String, double> _cachedLastWriteTimes;
+        private Dictionary<string, int> _weights;
 
         public bool InvertingResults { get; private set; }
 
@@ -55,6 +56,24 @@ namespace SongBrowserPlugin
         public SongBrowserModel()
         {
             _cachedLastWriteTimes = new Dictionary<String, double>();
+
+            // Weights used for keeping the original songs in order
+            // Invert the weights from the game so we can order by descending and make LINQ work with us...
+            /*  Level4, Level2, Level9, Level5, Level10, Level6, Level7, Level1, Level3, Level8, Level11 */
+            _weights = new Dictionary<string, int>
+            {
+                ["Level4"] = 11,
+                ["Level2"] = 10,
+                ["Level9"] = 9,
+                ["Level5"] = 8,
+                ["Level10"] = 7,
+                ["Level6"] = 6,
+                ["Level7"] = 5,
+                ["Level1"] = 4,
+                ["Level3"] = 3,
+                ["Level8"] = 2,
+                ["Level11"] = 1
+            };
         }
 
         /// <summary>
@@ -99,8 +118,7 @@ namespace SongBrowserPlugin
             }
 
             // Update song Infos
-            this.UpdateSongInfos(gameplayMode);
-                                
+            this.UpdateSongInfos(gameplayMode);                                
             this.ProcessSongList(gameplayMode);                       
         }
 
@@ -127,24 +145,6 @@ namespace SongBrowserPlugin
         {
             _log.Trace("ProcessSongList()");
 
-            // Weights used for keeping the original songs in order
-            // Invert the weights from the game so we can order by descending and make LINQ work with us...
-            /*  Level4, Level2, Level9, Level5, Level10, Level6, Level7, Level1, Level3, Level8, Level11 */
-            Dictionary<string, int> weights = new Dictionary<string, int>
-            {
-                ["Level4"] = 11,
-                ["Level2"] = 10,
-                ["Level9"] = 9,
-                ["Level5"] = 8,
-                ["Level10"] = 7,
-                ["Level6"] = 6,
-                ["Level7"] = 5,
-                ["Level1"] = 4,
-                ["Level3"] = 3,
-                ["Level8"] = 2,
-                ["Level11"] = 1
-            };
-
             // This has come in handy many times for debugging issues with Newest.
             /*foreach (StandardLevelSO level in _originalSongs)
             {
@@ -157,75 +157,35 @@ namespace SongBrowserPlugin
                     _log.Debug("Missing KEY: {0}", level.levelID);
                 }
             }*/
-
-            PlayerDynamicData playerData = GameDataModel.instance.gameDynamicData.GetCurrentPlayerDynamicData();
-
+            
             Stopwatch stopwatch = Stopwatch.StartNew();
 
             switch (_settings.sortMode)
             {
                 case SongSortMode.Favorites:
-                    _log.Info("Sorting song list as favorites");
-                    _sortedSongs = _originalSongs
-                        .AsQueryable()
-                        .OrderBy(x => _settings.favorites.Contains(x.levelID) == false)
-                        .ThenBy(x => x.songName)
-                        .ThenBy(x => x.songAuthorName)
-                        .ToList();
+                    SortFavorites();
                     break;
                 case SongSortMode.Original:
-                    _log.Info("Sorting song list as original");
-                    _sortedSongs = _originalSongs
-                        .AsQueryable()
-                        .OrderByDescending(x => weights.ContainsKey(x.levelID) ? weights[x.levelID] : 0)
-                        .ThenBy(x => x.songName)
-                        .ToList();
+                    SortOriginal();
                     break;
                 case SongSortMode.Newest:
-                    _log.Info("Sorting song list as newest.");
-                    _sortedSongs = _originalSongs
-                        .AsQueryable()
-                        .OrderBy(x => weights.ContainsKey(x.levelID) ? weights[x.levelID] : 0)
-                        .ThenByDescending(x => x.levelID.StartsWith("Level") ? weights[x.levelID] : _cachedLastWriteTimes[_levelIdToCustomLevel[x.levelID].customSongInfo.path])
-                        .ToList();
+                    SortNewest();
                     break;
                 case SongSortMode.Author:
-                    _log.Info("Sorting song list by author");
-                    _sortedSongs = _originalSongs
-                        .AsQueryable()
-                        .OrderBy(x => x.songAuthorName)
-                        .ThenBy(x => x.songName)
-                        .ToList();
+                    SortAuthor();
                     break;
                 case SongSortMode.PlayCount:
-                    _log.Info("Sorting song list by playcount");
-                    // Build a map of levelId to sum of all playcounts and sort.
-                    IEnumerable<LevelDifficulty> difficultyIterator = Enum.GetValues(typeof(LevelDifficulty)).Cast<LevelDifficulty>();
-                    Dictionary<string, int> _levelIdToPlayCount = _originalSongs.ToDictionary(x => x.levelID, x => difficultyIterator.Sum(difficulty => playerData.GetPlayerLevelStatsData(x.levelID, difficulty, gameplayMode).playCount));
-                    _sortedSongs = _originalSongs
-                        .AsQueryable()
-                        .OrderByDescending(x => _levelIdToPlayCount[x.levelID])
-                        .ThenBy(x => x.songName)
-                        .ToList();
+                    SortPlayCount(gameplayMode);
                     break;
                 case SongSortMode.Random:
-                    _log.Info("Sorting song list by random");
-
-                    System.Random rnd = new System.Random(Guid.NewGuid().GetHashCode());
-
-                    _sortedSongs = _originalSongs
-                        .AsQueryable()
-                        .OrderBy(x => rnd.Next())
-                        .ToList();
+                    SortRandom();
+                    break;
+                case SongSortMode.Search:
+                    SortSearch();
                     break;
                 case SongSortMode.Default:
                 default:
-                    _log.Info("Sorting song list as default (songName)");
-                    _sortedSongs = _originalSongs
-                        .AsQueryable()
-                        .OrderBy(x => x.songName)
-                        .ThenBy(x => x.songAuthorName)
-                        .ToList();
+                    SortSongName();
                     break;
             }
 
@@ -236,6 +196,110 @@ namespace SongBrowserPlugin
 
             stopwatch.Stop();
             _log.Info("Sorting songs took {0}ms", stopwatch.ElapsedMilliseconds);
-        }        
+        }    
+        
+        private void SortFavorites()
+        {
+            _log.Info("Sorting song list as favorites");
+            _sortedSongs = _originalSongs
+                .AsQueryable()
+                .OrderBy(x => _settings.favorites.Contains(x.levelID) == false)
+                .ThenBy(x => x.songName)
+                .ThenBy(x => x.songAuthorName)
+                .ToList();
+        }
+
+        private void SortOriginal()
+        {
+            _log.Info("Sorting song list as original");
+            _sortedSongs = _originalSongs
+                .AsQueryable()
+                .OrderByDescending(x => _weights.ContainsKey(x.levelID) ? _weights[x.levelID] : 0)
+                .ThenBy(x => x.songName)
+                .ToList();
+        }
+
+        private void SortNewest()
+        {
+            _log.Info("Sorting song list as newest.");
+            _sortedSongs = _originalSongs
+                .AsQueryable()
+                .OrderBy(x => _weights.ContainsKey(x.levelID) ? _weights[x.levelID] : 0)
+                .ThenByDescending(x => x.levelID.StartsWith("Level") ? _weights[x.levelID] : _cachedLastWriteTimes[_levelIdToCustomLevel[x.levelID].customSongInfo.path])
+                .ToList();
+        }
+
+        private void SortAuthor()
+        {
+            _log.Info("Sorting song list by author");
+            _sortedSongs = _originalSongs
+                .AsQueryable()
+                .OrderBy(x => x.songAuthorName)
+                .ThenBy(x => x.songName)
+                .ToList();
+        }
+
+        private void SortPlayCount(GameplayMode gameplayMode)
+        {
+            _log.Info("Sorting song list by playcount");
+            // Build a map of levelId to sum of all playcounts and sort.
+            PlayerDynamicData playerData = GameDataModel.instance.gameDynamicData.GetCurrentPlayerDynamicData();
+            IEnumerable<LevelDifficulty> difficultyIterator = Enum.GetValues(typeof(LevelDifficulty)).Cast<LevelDifficulty>();
+            Dictionary<string, int> _levelIdToPlayCount = _originalSongs.ToDictionary(x => x.levelID, x => difficultyIterator.Sum(difficulty => playerData.GetPlayerLevelStatsData(x.levelID, difficulty, gameplayMode).playCount));
+            _sortedSongs = _originalSongs
+                .AsQueryable()
+                .OrderByDescending(x => _levelIdToPlayCount[x.levelID])
+                .ThenBy(x => x.songName)
+                .ToList();
+        }
+
+        private void SortRandom()
+        {
+            _log.Info("Sorting song list by random");
+
+            System.Random rnd = new System.Random(Guid.NewGuid().GetHashCode());
+
+            _sortedSongs = _originalSongs
+                .AsQueryable()
+                .OrderBy(x => rnd.Next())
+                .ToList();
+        }
+
+        private void SortSearch()
+        {
+            // Make sure we can actually search.
+            if (this._settings.searchTerms.Count <= 0)
+            {
+                _log.Error("Tried to search for a song with no valid search terms...");
+                SortSongName();
+                return;
+            }
+            string searchTerm = this._settings.searchTerms[0];
+            if (String.IsNullOrEmpty(searchTerm))
+            {
+                _log.Error("Empty search term entered.");
+                SortSongName();
+                return;
+            }
+
+            _log.Info("Sorting song list by search term: {0}", searchTerm);
+            //_originalSongs.ForEach(x => _log.Debug($"{x.songName} {x.songSubName} {x.songAuthorName}".ToLower().Contains(searchTerm.ToLower()).ToString()));
+
+            _sortedSongs = _originalSongs
+                .AsQueryable()
+                .Where(x => $"{x.songName} {x.songSubName} {x.songAuthorName}".ToLower().Contains(searchTerm.ToLower()))
+                .ToList();
+            //_sortedSongs.ForEach(x => _log.Debug(x.levelID));
+        }
+
+        private void SortSongName()
+        {
+            _log.Info("Sorting song list as default (songName)");
+            _sortedSongs = _originalSongs
+                .AsQueryable()
+                .OrderBy(x => x.songName)
+                .ThenBy(x => x.songAuthorName)
+                .ToList();
+        }
     }
 }

+ 6 - 0
SongBrowserPlugin/SongBrowserPlugin.csproj

@@ -66,6 +66,10 @@
     <Reference Include="UnityEngine.JSONSerializeModule, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null">
       <HintPath>D:\Games\Steam\SteamApps\common\Beat Saber\Beat Saber_Data\Managed\UnityEngine.JSONSerializeModule.dll</HintPath>
     </Reference>
+    <Reference Include="UnityEngine.TextRenderingModule, Version=0.0.0.0, Culture=neutral, processorArchitecture=MSIL">
+      <SpecificVersion>False</SpecificVersion>
+      <HintPath>D:\Games\Steam\SteamApps\common\Beat Saber\Beat Saber_Data\Managed\UnityEngine.TextRenderingModule.dll</HintPath>
+    </Reference>
     <Reference Include="UnityEngine.UI, Version=1.0.0.0, Culture=neutral, processorArchitecture=MSIL">
       <SpecificVersion>False</SpecificVersion>
       <HintPath>D:\Games\Steam\SteamApps\common\Beat Saber\Beat Saber_Data\Managed\UnityEngine.UI.dll</HintPath>
@@ -86,6 +90,8 @@
     <Compile Include="Properties\AssemblyInfo.cs" />
     <Compile Include="SongBrowserModel.cs" />
     <Compile Include="DataAccess\SongBrowserSettings.cs" />
+    <Compile Include="UI\CustomUIKeyboard.cs" />
+    <Compile Include="UI\SearchKeyboardViewController.cs" />
     <Compile Include="UI\SongBrowserUI.cs" />
     <Compile Include="UI\SongSortButton.cs" />
     <Compile Include="UI\UIBuilder.cs" />

+ 137 - 0
SongBrowserPlugin/UI/CustomUIKeyboard.cs

@@ -0,0 +1,137 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using UnityEngine;
+using UnityEngine.UI;
+
+namespace SongBrowserPlugin.UI
+{
+    // https://github.com/andruzzzhka/BeatSaverDownloader/blob/master/BeatSaverDownloader/PluginUI/UIElements/CustomUIKeyboard.cs
+    class CustomUIKeyboard : UIKeyboard
+    {
+        public void Awake()
+        {
+            UIKeyboard original = GetComponent<UIKeyboard>();
+
+            System.Reflection.FieldInfo[] fields = original.GetType().GetFields(System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+            foreach (System.Reflection.FieldInfo field in fields)
+            {
+                field.SetValue(this, field.GetValue(original));
+            }
+
+            Destroy(original);
+
+        }
+
+        public override void Start()
+        {
+            name = "CustomUIKeyboard";
+
+            (transform as RectTransform).anchoredPosition -= new Vector2(0f, 5f);
+
+            string[] array = new string[]
+            {
+                "q",
+                "w",
+                "e",
+                "r",
+                "t",
+                "y",
+                "u",
+                "i",
+                "o",
+                "p",
+                "a",
+                "s",
+                "d",
+                "f",
+                "g",
+                "h",
+                "j",
+                "k",
+                "l",
+                "z",
+                "x",
+                "c",
+                "v",
+                "b",
+                "n",
+                "m",
+                "<-",
+                "space"
+            };
+
+
+            for (int i = 0; i < array.Length; i++)
+            {
+                TextMeshProButton textButton = Instantiate(_keyButtonPrefab);
+                textButton.text.text = array[i];
+                if (i < array.Length - 2)
+                {
+                    string key = array[i];
+                    textButton.button.onClick.AddListener(delegate ()
+                    {
+                        KeyButtonWasPressed(key);
+                    });
+                }
+                else if (i == array.Length - 2)
+                {
+                    textButton.button.onClick.AddListener(delegate ()
+                    {
+                        DeleteButtonWasPressed();
+                    });
+                }
+                else
+                {
+                    textButton.button.onClick.AddListener(delegate ()
+                    {
+                        SpaceButtonWasPressed();
+                    });
+                }
+                RectTransform buttonRect = textButton.GetComponent<RectTransform>();
+                RectTransform component2 = transform.GetChild(i).gameObject.GetComponent<RectTransform>();
+                buttonRect.SetParent(component2, false);
+                buttonRect.localPosition = Vector2.zero;
+                buttonRect.localScale = Vector3.one;
+                buttonRect.anchoredPosition = Vector2.zero;
+                buttonRect.anchorMin = Vector2.zero;
+                buttonRect.anchorMax = Vector3.one;
+                buttonRect.offsetMin = Vector2.zero;
+                buttonRect.offsetMax = Vector2.zero;
+            }
+
+
+            for (int i = 1; i <= 10; i++)
+            {
+                TextMeshProButton textButton = Instantiate(_keyButtonPrefab);
+                textButton.text.text = i.ToString().Last().ToString();
+
+                string key = i.ToString().Last().ToString();
+                textButton.button.onClick.AddListener(delegate ()
+                {
+                    KeyButtonWasPressed(key);
+                });
+
+                RectTransform buttonRect = textButton.GetComponent<RectTransform>();
+                RectTransform component2 = transform.GetChild(i - 1).gameObject.GetComponent<RectTransform>();
+
+                RectTransform buttonHolder = Instantiate(component2, component2.parent, false);
+                Destroy(buttonHolder.GetComponentInChildren<Button>().gameObject);
+
+                buttonHolder.anchoredPosition -= new Vector2(0f, -10.5f);
+
+                buttonRect.SetParent(buttonHolder, false);
+
+                buttonRect.localPosition = Vector2.zero;
+                buttonRect.localScale = Vector3.one;
+                buttonRect.anchoredPosition = Vector2.zero;
+                buttonRect.anchorMin = Vector2.zero;
+                buttonRect.anchorMax = Vector3.one;
+                buttonRect.offsetMin = Vector2.zero;
+                buttonRect.offsetMax = Vector2.zero;
+            }
+
+        }
+    }
+}

+ 123 - 0
SongBrowserPlugin/UI/SearchKeyboardViewController.cs

@@ -0,0 +1,123 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using TMPro;
+using UnityEngine;
+using UnityEngine.UI;
+using VRUI;
+
+namespace SongBrowserPlugin.UI
+{
+    // https://github.com/andruzzzhka/BeatSaverDownloader/blob/master/BeatSaverDownloader/PluginUI/ViewControllers/SearchKeyboardViewController.cs
+    class SearchKeyboardViewController : VRUIViewController
+    {
+        GameObject _searchKeyboardGO;
+
+        CustomUIKeyboard _searchKeyboard;
+
+        Button _searchButton;
+        Button _backButton;
+
+        TextMeshProUGUI _inputText;
+        public string _inputString = "";
+
+        public event Action<string> searchButtonPressed;
+        public event Action backButtonPressed;
+
+        protected override void DidActivate(bool firstActivation, ActivationType type)
+        {
+            if (_searchKeyboard == null)
+            {
+                _searchKeyboardGO = Instantiate(Resources.FindObjectsOfTypeAll<UIKeyboard>().First(x => x.name != "CustomUIKeyboard"), rectTransform, false).gameObject;
+
+                _searchKeyboard = _searchKeyboardGO.AddComponent<CustomUIKeyboard>();
+
+                _searchKeyboard.uiKeyboardKeyEvent = delegate (char input) { _inputString += input; UpdateInputText(); };
+                _searchKeyboard.uiKeyboardDeleteEvent = delegate () { _inputString = _inputString.Substring(0, _inputString.Length - 1); UpdateInputText(); };
+            }
+
+            if (_inputText == null)
+            {
+                _inputText = UIBuilder.CreateText(rectTransform, "Search...", new Vector2(0f, -11.5f));
+                _inputText.alignment = TextAlignmentOptions.Center;
+                _inputText.fontSize = 6f;
+            }
+            else
+            {
+                _inputString = "";
+                UpdateInputText();
+            }
+
+            if (_searchButton == null)
+            {
+                _searchButton = UIBuilder.CreateUIButton(rectTransform, "ApplyButton");
+                UIBuilder.SetButtonText(ref _searchButton, "Search");
+                (_searchButton.transform as RectTransform).sizeDelta = new Vector2(30f, 10f);
+                (_searchButton.transform as RectTransform).anchoredPosition = new Vector2(-15f, 1.5f);
+                _searchButton.onClick.RemoveAllListeners();
+                _searchButton.onClick.AddListener(delegate ()
+                {
+                    searchButtonPressed?.Invoke(_inputString);
+                    DismissModalViewController(null, false);
+                });
+            }
+
+            if (_backButton == null)
+            {
+                _backButton = UIBuilder.CreateBackButton(rectTransform);
+
+                _backButton.onClick.AddListener(delegate ()
+                {
+                    _inputString = "";
+                    backButtonPressed?.Invoke();
+                    DismissModalViewController(null, false);
+                });
+            }
+
+        }
+
+        void UpdateInputText()
+        {
+            if (_inputText != null)
+            {
+                _inputText.text = _inputString.ToUpper();
+            }
+        }
+
+        void ClearInput()
+        {
+            _inputString = "";
+        }
+
+        /// <summary>
+        /// Emulate keyboard support.
+        /// </summary>
+        private void LateUpdate()
+        {
+            if (!this.isInViewControllerHierarchy) return;
+
+            if (Input.GetKeyDown(KeyCode.KeypadEnter) || Input.GetKeyDown(KeyCode.Return))
+            {
+                _searchButton.onClick.Invoke();
+            }
+            else if (Input.GetKeyDown(KeyCode.Backspace))
+            {
+                this._searchKeyboard.DeleteButtonWasPressed();
+            }
+            else if (Input.GetKeyDown(KeyCode.Space))
+            {
+                this._searchKeyboard.SpaceButtonWasPressed();
+            }
+
+            IEnumerable<KeyCode> keycodeIterator = Enum.GetValues(typeof(KeyCode)).Cast<KeyCode>();
+            foreach (KeyCode keycode in keycodeIterator)
+            {
+                if (!((keycode >= KeyCode.A && keycode <= KeyCode.Z) || (keycode >= KeyCode.Alpha0 && keycode <= KeyCode.Alpha9))) continue;
+                if (Input.GetKeyDown(keycode))
+                {
+                    this._searchKeyboard.KeyButtonWasPressed(keycode.ToString());
+                }
+            }            
+        }
+    }
+}

+ 269 - 74
SongBrowserPlugin/UI/SongBrowserUI.cs

@@ -8,6 +8,8 @@ using VRUI;
 using SongBrowserPlugin.DataAccess;
 using System.IO;
 using SongLoaderPlugin;
+using System.Security.Cryptography;
+using System.Text;
 
 namespace SongBrowserPlugin.UI
 {
@@ -18,6 +20,9 @@ namespace SongBrowserPlugin.UI
     {
         // Logging
         public const String Name = "SongBrowserUI";
+
+        private const float SEGMENT_PERCENT = 0.1f;
+
         private Logger _log = new Logger(Name);
 
         // Beat Saber UI Elements
@@ -28,6 +33,8 @@ namespace SongBrowserPlugin.UI
         private StandardLevelSelectionNavigationController _levelSelectionNavigationController;
         private StandardLevelListTableView _levelListTableView;
         private RectTransform _tableViewRectTransform;
+        private Button _tableViewPageUpButton;
+        private Button _tableViewPageDownButton;
 
         // New UI Elements
         private List<SongSortButton> _sortButtonGroup;
@@ -35,7 +42,11 @@ namespace SongBrowserPlugin.UI
         private String _addFavoriteButtonText = null;
         private SimpleDialogPromptViewController _simpleDialogPromptViewControllerPrefab;
         private SimpleDialogPromptViewController _deleteDialog;
-        private Button _deleteButton;
+        private Button _deleteButton;        
+        private Button _pageUpTenPercent;
+        private Button _pageDownTenPercent;
+        private SearchKeyboardViewController _searchViewController;
+
 
         // Debug
         private int _sortButtonLastPushedIndex = 0;
@@ -119,74 +130,63 @@ namespace SongBrowserPlugin.UI
 
             try
             {
+                // Gather some transforms and templates to use.
+                RectTransform sortButtonTransform = this._levelSelectionNavigationController.transform as RectTransform;
+                RectTransform otherButtonTransform = this._levelDetailViewController.transform as RectTransform;
+                Button sortButtonTemplate = Resources.FindObjectsOfTypeAll<Button>().First(x => (x.name == "PlayButton"));
+                Button otherButtonTemplate = Resources.FindObjectsOfTypeAll<Button>().First(x => (x.name == "QuitButton"));
+
                 // Resize some of the UI
                 _tableViewRectTransform = _levelListViewController.GetComponentsInChildren<RectTransform>().First(x => x.name == "TableViewContainer");
                 _tableViewRectTransform.sizeDelta = new Vector2(0f, -20f);
                 _tableViewRectTransform.anchoredPosition = new Vector2(0f, -2.5f);
 
-                RectTransform _pageUp = _tableViewRectTransform.GetComponentsInChildren<RectTransform>().First(x => x.name == "PageUpButton");
-                _pageUp.anchoredPosition = new Vector2(0f, -1f);
+                _tableViewPageUpButton = _tableViewRectTransform.GetComponentsInChildren<Button>().First(x => x.name == "PageUpButton");
+                (_tableViewPageUpButton.transform as RectTransform).anchoredPosition = new Vector2(0f, -1f);
+
+                _tableViewPageDownButton = _tableViewRectTransform.GetComponentsInChildren<Button>().First(x => x.name == "PageDownButton");
+                (_tableViewPageDownButton.transform as RectTransform).anchoredPosition = new Vector2(0f, 1f);
 
-                RectTransform _pageDown = _tableViewRectTransform.GetComponentsInChildren<RectTransform>().First(x => x.name == "PageDownButton");
-                _pageDown.anchoredPosition = new Vector2(0f, 1f);
-                
                 // Create Sorting Songs By-Buttons
                 _log.Debug("Creating sort by buttons...");
-
-                RectTransform rect = this._levelSelectionNavigationController.transform as RectTransform;
+                
                 Sprite arrowIcon = SongBrowserApplication.Instance.CachedIcons["ArrowIcon"];
 
-                System.Action<SongSortMode> onSortButtonClickEvent = delegate (SongSortMode sortMode) {
-                    _log.Debug("Sort button - {0} - pressed.", sortMode.ToString());
-                    SongBrowserModel.LastSelectedLevelId = null;
-
-                    if (_model.Settings.sortMode == sortMode)
-                    {
-                        _model.ToggleInverting();
-                    }
-
-                    _model.Settings.sortMode = sortMode;
-                    _model.Settings.Save();
-
-                    UpdateSongList();
-                    RefreshSongList();
-                };
-
-                Button sortButtonTemplate = Resources.FindObjectsOfTypeAll<Button>().First(x => (x.name == "PlayButton"));
-                Button otherButtonTemplate = Resources.FindObjectsOfTypeAll<Button>().First(x => (x.name == "QuitButton"));
-
-                float fontSize = 2.75f;
-                float buttonWidth = 17.0f;
+                float fontSize = 2.5f;
+                float buttonWidth = 14.0f;
                 float buttonHeight = 5.0f;
                 float buttonX = 68.0f;
                 float buttonY = 74.5f;
 
                 string[] buttonNames = new string[]
                 {
-                    "Favorite", "Song", "Author", "Original", "Newest", "PlayCount", "Random"
+                    "Favorite", "Song", "Author", "Original", "Newest", "Plays", "Random", "Search"
                 };
 
                 SongSortMode[] sortModes = new SongSortMode[]
                 {
-                    SongSortMode.Favorites, SongSortMode.Default, SongSortMode.Author, SongSortMode.Original, SongSortMode.Newest, SongSortMode.PlayCount, SongSortMode.Random
+                    SongSortMode.Favorites, SongSortMode.Default, SongSortMode.Author, SongSortMode.Original, SongSortMode.Newest, SongSortMode.PlayCount, SongSortMode.Random, SongSortMode.Search
+                };
+
+                System.Action<SongSortMode>[] onClickEvents = new Action<SongSortMode>[]
+                {
+                    onSortButtonClickEvent, onSortButtonClickEvent, onSortButtonClickEvent, onSortButtonClickEvent, onSortButtonClickEvent, onSortButtonClickEvent, onSortButtonClickEvent, onSearchButtonClickEvent
                 };
 
                 _sortButtonGroup = new List<SongSortButton>();
                 for (int i = 0; i < buttonNames.Length; i++)
                 {
-                    _sortButtonGroup.Add(UIBuilder.CreateSortButton(rect, sortButtonTemplate, arrowIcon, buttonNames[i], fontSize, buttonX, buttonY, buttonWidth, buttonHeight, sortModes[i], onSortButtonClickEvent));
+                    _sortButtonGroup.Add(UIBuilder.CreateSortButton(sortButtonTransform, sortButtonTemplate, arrowIcon, buttonNames[i], fontSize, buttonX, buttonY, buttonWidth, buttonHeight, sortModes[i], onClickEvents[i]));
                     buttonX -= buttonWidth;
                 }
 
                 // Creaate Add to Favorites Button
-                _log.Debug("Creating add to favorites button...");
-
-                RectTransform transform = this._levelDetailViewController.transform as RectTransform;
-                _addFavoriteButton = UIBuilder.CreateUIButton(transform, otherButtonTemplate);
+                _log.Debug("Creating add to favorites button...");                
+                _addFavoriteButton = UIBuilder.CreateUIButton(otherButtonTransform, otherButtonTemplate);
                 (_addFavoriteButton.transform as RectTransform).anchoredPosition = new Vector2(40f, 5.75f);
                 (_addFavoriteButton.transform as RectTransform).sizeDelta = new Vector2(10f, 10f);
                 UIBuilder.SetButtonText(ref _addFavoriteButton, _addFavoriteButtonText);
-                UIBuilder.SetButtonTextSize(ref _addFavoriteButton, 3);
+                UIBuilder.SetButtonTextSize(ref _addFavoriteButton, fontSize);
                 UIBuilder.SetButtonIconEnabled(ref _addFavoriteButton, false);
                 _addFavoriteButton.onClick.RemoveAllListeners();
                 _addFavoriteButton.onClick.AddListener(delegate () {
@@ -204,20 +204,29 @@ namespace SongBrowserPlugin.UI
 
                 // Create delete button
                 _log.Debug("Creating delete button...");
-
-                transform = this._levelDetailViewController.transform as RectTransform;
-                _deleteButton = UIBuilder.CreateUIButton(transform, otherButtonTemplate);
+                _deleteButton = UIBuilder.CreateUIButton(otherButtonTransform, otherButtonTemplate);
                 (_deleteButton.transform as RectTransform).anchoredPosition = new Vector2(46f, 0f);
                 (_deleteButton.transform as RectTransform).sizeDelta = new Vector2(15f, 5f);
                 UIBuilder.SetButtonText(ref _deleteButton, "Delete");
-                UIBuilder.SetButtonTextSize(ref _deleteButton, 3);
+                UIBuilder.SetButtonTextSize(ref _deleteButton, fontSize);
                 UIBuilder.SetButtonIconEnabled(ref _deleteButton, false);
                 _deleteButton.onClick.RemoveAllListeners();
                 _deleteButton.onClick.AddListener(delegate () {
                     HandleDeleteSelectedLevel();
                 });
 
-                RefreshUI();
+                // Create fast scroll buttons
+                _pageUpTenPercent = UIBuilder.CreatePageButton(sortButtonTransform, otherButtonTemplate, arrowIcon, 15, 67.5f, 6.0f, 5.5f, 1.5f, 1.5f, 180);
+                _pageUpTenPercent.onClick.AddListener(delegate () {
+                    this.JumpSongList(-1, SEGMENT_PERCENT);
+                });
+
+                _pageDownTenPercent = UIBuilder.CreatePageButton(sortButtonTransform, otherButtonTemplate, arrowIcon, 15, 0.5f, 6.0f, 5.5f, 1.5f, 1.5f, 0);
+                _pageDownTenPercent.onClick.AddListener(delegate () {
+                    this.JumpSongList(1, SEGMENT_PERCENT);
+                });
+
+                RefreshSortButtonUI();
             }
             catch (Exception e)
             {
@@ -226,6 +235,38 @@ namespace SongBrowserPlugin.UI
         }
 
         /// <summary>
+        /// 
+        /// </summary>
+        private void onSortButtonClickEvent(SongSortMode sortMode)
+        {
+            _log.Debug("Sort button - {0} - pressed.", sortMode.ToString());
+            SongBrowserModel.LastSelectedLevelId = null;
+
+            if (_model.Settings.sortMode == sortMode)
+            {
+                _model.ToggleInverting();
+            }
+
+            _model.Settings.sortMode = sortMode;
+            _model.Settings.Save();
+
+            UpdateSongList();
+            RefreshSongList();
+        }
+
+        /// <summary>
+        /// Saerch button clicked.  
+        /// </summary>
+        /// <param name="sortMode"></param>
+        private void onSearchButtonClickEvent(SongSortMode sortMode)
+        {
+            _model.Settings.sortMode = sortMode;
+            _model.Settings.Save();
+
+            this.ShowSearchKeyboard();
+        }
+
+        /// <summary>
         /// Adjust UI based on level selected.
         /// Various ways of detecting if a level is not properly selected.  Seems most hit the first one.
         /// </summary>
@@ -247,6 +288,7 @@ namespace SongBrowserPlugin.UI
             SongBrowserModel.LastSelectedLevelId = level.levelID;
 
             RefreshAddFavoriteButton(level.levelID);
+            RefreshQuickScrollButtons();
         }
 
         /// <summary>
@@ -289,23 +331,146 @@ namespace SongBrowserPlugin.UI
             }
             else
             {
-                IStandardLevel level = this._levelListViewController.selectedLevel;
+                string customSongsPath = Path.Combine(Environment.CurrentDirectory, "CustomSongs");
+                IStandardLevel level = this._levelListViewController.selectedLevel;                
                 SongLoaderPlugin.OverrideClasses.CustomLevel customLevel = _model.LevelIdToCustomSongInfos[level.levelID];
+                string songPath = customLevel.customSongInfo.path;
+                bool isZippedSong = false;
 
                 viewController.DismissModalViewController(null, false);
-                _log.Debug("Deleting: {0}", customLevel.customSongInfo.path);
-                
-                FileAttributes attr = File.GetAttributes(customLevel.customSongInfo.path);
-                if (attr.HasFlag(FileAttributes.Directory))
-                    Directory.Delete(customLevel.customSongInfo.path);
+                _log.Debug("Deleting: {0}", songPath);
+
+                if (!string.IsNullOrEmpty(songPath) && songPath.Contains("/.cache/"))
+                {
+                    isZippedSong = true;
+                }
+
+                if (isZippedSong)
+                {
+                    DirectoryInfo songHashDir = Directory.GetParent(songPath);
+                    _log.Debug("Deleting zipped song cache: {0}", songHashDir.FullName);
+                    Directory.Delete(songHashDir.FullName, true);
+
+                    foreach (string file in Directory.GetFiles(customSongsPath, "*.zip"))
+                    {
+                        string hash = CreateMD5FromFile(file);
+                        if (hash != null)
+                        {
+                            if (hash == songHashDir.Name)
+                            {
+                                _log.Debug("Deleting zipped song: {0}", file);
+                                File.Delete(file);
+                                break;
+                            }
+                        }
+                    }
+                }
                 else
-                    File.Delete(customLevel.customSongInfo.path);
+                {
+                    FileAttributes attr = File.GetAttributes(songPath);
+                    if (attr.HasFlag(FileAttributes.Directory))
+                    {
+                        _log.Debug("Deleting song: {0}", songPath);
+                        Directory.Delete(songPath, true);
+                    }
+                }
+
+                SongLoaderPlugin.SongLoader.Instance.RemoveSongWithPath(songPath);
+                this.UpdateSongList();
+                this.RefreshSongList();
+            }
+        }
+
+        /// <summary>
+        /// Create MD5 of a file.
+        /// </summary>
+        /// <param name="path"></param>
+        /// <returns></returns>
+        public static string CreateMD5FromFile(string path)
+        {
+            string hash = "";
+            if (!File.Exists(path)) return null;
+            using (MD5 md5 = MD5.Create())
+            {
+                using (var stream = File.OpenRead(path))
+                {
+                    byte[] hashBytes = md5.ComputeHash(stream);
 
-                SongLoaderPlugin.SongLoader.Instance.RemoveSongWithPath(customLevel.customSongInfo.path);
+                    StringBuilder sb = new StringBuilder();
+                    foreach (byte hashByte in hashBytes)
+                    {
+                        sb.Append(hashByte.ToString("X2"));
+                    }
+
+                    hash = sb.ToString();
+                    return hash;
+                }
             }
         }
 
         /// <summary>
+        /// Display the search keyboard
+        /// </summary>
+        void ShowSearchKeyboard()
+        {
+            if (_searchViewController == null)
+            {
+                _searchViewController = UIBuilder.CreateViewController<SearchKeyboardViewController>("SearchKeyboardViewController");
+                _searchViewController.searchButtonPressed += SearchViewControllerSearchButtonPressed;
+                _searchViewController.backButtonPressed += SearchViewControllerbackButtonPressed;
+            }
+
+            _levelListViewController.navigationController.PresentModalViewController(_searchViewController, null, false);
+        }
+
+        /// <summary>
+        /// Handle back button event from search keyboard.
+        /// </summary>
+        private void SearchViewControllerbackButtonPressed()
+        {
+ 
+        }
+
+        /// <summary>
+        /// Handle search.
+        /// </summary>
+        /// <param name="searchFor"></param>
+        private void SearchViewControllerSearchButtonPressed(string searchFor)
+        {
+            _log.Debug("Searching for \"{0}\"...", searchFor);
+
+            _model.Settings.searchTerms.Insert(0, searchFor);
+            _model.Settings.Save();
+            SongBrowserModel.LastSelectedLevelId = null;
+            this.UpdateSongList();
+            this.RefreshSongList();
+        }
+
+        /// <summary>
+        /// Make big jumps in the song list.
+        /// </summary>
+        /// <param name="numJumps"></param>
+        private void JumpSongList(int numJumps, float segmentPercent)
+        {
+            int totalSize = _model.SortedSongList.Count;
+            int segmentSize = (int)(totalSize * segmentPercent);
+
+            TableView tableView = ReflectionUtil.GetPrivateField<TableView>(_levelListTableView, "_tableView");
+            HashSet<int> rows = tableView.GetPrivateField<HashSet<int>>("_selectedRows");
+            int listSegment = (rows.First() / segmentSize);
+            int newSegment = listSegment + numJumps;
+            int newRow = 0;
+            if (newSegment > 0)
+            {
+                newRow = Math.Min(newSegment * segmentSize, totalSize - 1);
+            }                       
+
+            _log.Debug("ListSegment: {0}, newRow: {1}", listSegment, newRow);
+
+            this.SelectAndScrollToLevel(_levelListTableView, _model.SortedSongList[newRow].levelID);
+        }
+
+        /// <summary>
         /// Add/Remove song from favorites depending on if it already exists.
         /// </summary>
         private void ToggleSongInFavorites()
@@ -330,6 +495,18 @@ namespace SongBrowserPlugin.UI
         }
 
         /// <summary>
+        /// Update interactive state of the quick scroll buttons.
+        /// </summary>
+        private void RefreshQuickScrollButtons()
+        {
+            // Refresh the fast scroll buttons
+            _pageUpTenPercent.interactable = _tableViewPageUpButton.interactable;
+            _pageUpTenPercent.gameObject.SetActive(_tableViewPageUpButton.IsActive());
+            _pageDownTenPercent.interactable = _tableViewPageDownButton.interactable;
+            _pageDownTenPercent.gameObject.SetActive(_tableViewPageDownButton.IsActive());
+        }
+
+        /// <summary>
         /// Helper to quickly refresh add to favorites button
         /// </summary>
         /// <param name="levelId"></param>
@@ -356,18 +533,14 @@ namespace SongBrowserPlugin.UI
         /// <summary>
         /// Adjust the UI colors.
         /// </summary>
-        public void RefreshUI()
+        public void RefreshSortButtonUI()
         {
             // So far all we need to refresh is the sort buttons.
             foreach (SongSortButton sortButton in _sortButtonGroup)
             {
                 UIBuilder.SetButtonBorder(ref sortButton.Button, Color.black);
-                //UIBuilder.SetButtonIconEnabled(ref sortButton.Button, false);
                 if (sortButton.SortMode == _model.Settings.sortMode)
                 {
-                    //UIBuilder.SetButtonIcon(ref sortButton.Button, SongBrowserApplication.Instance.CachedIcons["ArrowIcon"]);
-                    //UIBuilder.SetButtonIconEnabled(ref sortButton.Button, true);
-
                     if (_model.InvertingResults)
                     {
                         UIBuilder.SetButtonBorder(ref sortButton.Button, Color.red);
@@ -395,12 +568,13 @@ namespace SongBrowserPlugin.UI
                 }
 
                 StandardLevelSO[] levels = _model.SortedSongList.ToArray();
+                foreach (StandardLevelSO level in levels)
+                    _log.Debug(level.levelID);
                 StandardLevelListViewController songListViewController = this._levelSelectionFlowCoordinator.GetPrivateField<StandardLevelListViewController>("_levelListViewController");
-                StandardLevelListTableView _songListTableView = songListViewController.GetComponentInChildren<StandardLevelListTableView>();
-                ReflectionUtil.SetPrivateField(_songListTableView, "_levels", levels);
+                ReflectionUtil.SetPrivateField(_levelListTableView, "_levels", levels);
                 ReflectionUtil.SetPrivateField(songListViewController, "_levels", levels);            
-                TableView tableView = ReflectionUtil.GetPrivateField<TableView>(_songListTableView, "_tableView");
-                tableView.ReloadData();
+                TableView tableView = ReflectionUtil.GetPrivateField<TableView>(_levelListTableView, "_tableView");
+                tableView.ReloadData();                
 
                 String selectedLevelID = null;
                 if (SongBrowserModel.LastSelectedLevelId != null)
@@ -410,15 +584,19 @@ namespace SongBrowserPlugin.UI
                 }
                 else
                 {
-                    selectedLevelID = levels.FirstOrDefault().levelID;
+                    if (levels.Length > 0)
+                    {
+                        selectedLevelID = levels.FirstOrDefault().levelID;
+                    }
                 }
 
-                if (levels.Any(x => x.levelID == selectedLevelID))
+                // HACK, seems like if 6 or less items scrolling to row causes the song list to disappear.
+                if (levels.Length > 6 && !String.IsNullOrEmpty(selectedLevelID) && levels.Any(x => x.levelID == selectedLevelID))
                 {
-                    SelectAndScrollToLevel(_songListTableView, selectedLevelID);
+                    SelectAndScrollToLevel(_levelListTableView, selectedLevelID);
                 }
-                
-                RefreshUI();                
+
+                RefreshSortButtonUI();
             }
             catch (Exception e)
             {
@@ -455,10 +633,8 @@ namespace SongBrowserPlugin.UI
         /// </summary>
         public void LateUpdate()
         {
-            if (this._levelListViewController.isInViewControllerHierarchy)
-            {
-                CheckDebugUserInput();
-            }
+            if (!this._levelListViewController.isActiveAndEnabled) return;
+            CheckDebugUserInput();
         }
 
         /// <summary>
@@ -467,7 +643,9 @@ namespace SongBrowserPlugin.UI
         private void CheckDebugUserInput()
         {
             try
-            {                
+            {
+                bool isShiftKeyDown = Input.GetKey(KeyCode.LeftShift) || Input.GetKey(KeyCode.RightShift);
+
                 // back
                 if (Input.GetKeyDown(KeyCode.Escape))
                 {
@@ -481,6 +659,11 @@ namespace SongBrowserPlugin.UI
                     _sortButtonGroup[_sortButtonLastPushedIndex].Button.onClick.Invoke();
                 }
 
+                if (Input.GetKeyDown(KeyCode.S))
+                {
+                    onSortButtonClickEvent(SongSortMode.Search);
+                }
+
                 // select current sort mode again (toggle inverting)
                 if (Input.GetKeyDown(KeyCode.Y))
                 {
@@ -496,7 +679,13 @@ namespace SongBrowserPlugin.UI
                     }
                     _deleteButton.onClick.Invoke();
                 }
-                
+
+                // accept delete
+                if (Input.GetKeyDown(KeyCode.B) && _deleteDialog.isInViewControllerHierarchy)
+                {
+                    _deleteDialog.GetPrivateField<TextMeshProButton>("_okButton").button.onClick.Invoke();
+                }
+
                 // c,v can be used to get into a song
                 if (Input.GetKeyDown(KeyCode.C))
                 {
@@ -511,17 +700,23 @@ namespace SongBrowserPlugin.UI
                 }
 
                 // change song index
-                if (Input.GetKeyDown(KeyCode.N))
+                if (isShiftKeyDown && Input.GetKeyDown(KeyCode.N))
+                {
+                    _pageUpTenPercent.onClick.Invoke();
+                }
+                else if (Input.GetKeyDown(KeyCode.N))
                 {
                     _lastRow = (_lastRow - 1) != -1 ? (_lastRow - 1) % this._model.SortedSongList.Count : 0;
-
                     this.SelectAndScrollToLevel(_levelListTableView, _model.SortedSongList[_lastRow].levelID);
                 }
 
-                if (Input.GetKeyDown(KeyCode.M))
+                if (isShiftKeyDown && Input.GetKeyDown(KeyCode.M))
+                {
+                    _pageDownTenPercent.onClick.Invoke();
+                }
+                else if (Input.GetKeyDown(KeyCode.M))
                 {
                     _lastRow = (_lastRow + 1) % this._model.SortedSongList.Count;
-
                     this.SelectAndScrollToLevel(_levelListTableView, _model.SortedSongList[_lastRow].levelID);
                 }
 

+ 67 - 20
SongBrowserPlugin/UI/UIBuilder.cs

@@ -45,11 +45,22 @@ namespace SongBrowserPlugin.UI
         public static T CreateFlowCoordinator<T>(string name) where T : FlowCoordinator
         {
             T vc = new GameObject(name).AddComponent<T>();
-
             return vc;
         }
 
         /// <summary>
+        /// Helper, create a UI button from template name.
+        /// </summary>
+        /// <param name="parent"></param>
+        /// <param name="buttonTemplateName"></param>
+        /// <returns></returns>
+        static public Button CreateUIButton(RectTransform parent, String buttonTemplateName)
+        {
+            Button b = Resources.FindObjectsOfTypeAll<Button>().First(x => (x.name == buttonTemplateName));
+            return CreateUIButton(parent, b);
+        }
+
+        /// <summary>
         /// Clone a Unity Button into a Button we control.
         /// </summary>
         /// <param name="parent"></param>
@@ -78,26 +89,15 @@ namespace SongBrowserPlugin.UI
         /// <param name="w"></param>
         /// <param name="h"></param>
         /// <param name="action"></param>
-        public static SongSortButton CreateSortButton(RectTransform rect, Button buttonTemplate, Sprite iconSprite, string buttonText, float fontSize, float x, float y, float w, float h, SongSortMode sortMode, System.Action<SongSortMode> onClickEvent)
+        public static SongSortButton CreateSortButton(RectTransform parent, Button buttonTemplate, Sprite iconSprite, string buttonText, float fontSize, float x, float y, float w, float h, SongSortMode sortMode, System.Action<SongSortMode> onClickEvent)
         {
             SongSortButton sortButton = new SongSortButton();
-            Button newButton = UIBuilder.CreateUIButton(rect, buttonTemplate);
+            Button newButton = UIBuilder.CreateUIButton(parent, buttonTemplate);
 
             newButton.interactable = true;
             (newButton.transform as RectTransform).anchoredPosition = new Vector2(x, y);
             (newButton.transform as RectTransform).sizeDelta = new Vector2(w, h);
 
-            /*RectTransform iconTransform = newButton.GetComponentsInChildren<RectTransform>(true).First(c => c.name == "Icon");
-            iconTransform.gameObject.SetActive(false);
-
-            HorizontalLayoutGroup hgroup = iconTransform.parent.GetComponent<HorizontalLayoutGroup>();
-            hgroup.padding = new RectOffset();
-            hgroup.childForceExpandWidth = true;
-            hgroup.childForceExpandHeight = true;
-            iconTransform.sizeDelta = new Vector2(5f, 5f);
-            iconTransform.localScale = new Vector2(1f, 1f);
-            iconTransform.anchoredPosition = new Vector2(x, y);*/
-
             UIBuilder.SetButtonText(ref newButton, buttonText);
             UIBuilder.SetButtonIconEnabled(ref newButton, false);
             UIBuilder.SetButtonIcon(ref newButton, iconSprite);
@@ -116,6 +116,58 @@ namespace SongBrowserPlugin.UI
         }
 
         /// <summary>
+        /// Create a page up/down button.
+        /// </summary>
+        /// <param name="parent"></param>
+        /// <param name="buttonTemplate"></param>
+        /// <param name="iconSprite"></param>
+        /// <param name="x"></param>
+        /// <param name="y"></param>
+        /// <param name="w"></param>
+        /// <param name="h"></param>
+        /// <param name="iconWidth"></param>
+        /// <param name="iconHeight"></param>
+        /// <param name="iconRotation"></param>
+        /// <returns></returns>
+        public static Button CreatePageButton(RectTransform parent, Button buttonTemplate, Sprite iconSprite, float x, float y, float w, float h, float iconWidth, float iconHeight, float iconRotation)
+        {
+            Button newButton = UIBuilder.CreateUIButton(parent, buttonTemplate);
+
+            newButton.interactable = true;
+            (newButton.transform as RectTransform).anchoredPosition = new Vector2(x, y);
+            (newButton.transform as RectTransform).sizeDelta = new Vector2(w, h);
+
+            RectTransform iconTransform = newButton.GetComponentsInChildren<RectTransform>(true).First(c => c.name == "Icon");
+            iconTransform.gameObject.SetActive(true);
+
+            HorizontalLayoutGroup hgroup = iconTransform.parent.GetComponent<HorizontalLayoutGroup>();
+            UnityEngine.Object.Destroy(hgroup);
+
+            iconTransform.sizeDelta = new Vector2(iconWidth, iconHeight);
+            iconTransform.localScale = new Vector2(2f, 2f);
+            iconTransform.anchoredPosition = new Vector2(0, 0);
+            iconTransform.Rotate(0, 0, iconRotation);
+
+            UnityEngine.Object.Destroy(newButton.GetComponentsInChildren<RectTransform>(true).First(c => c.name == "Text").gameObject);
+
+            UIBuilder.SetButtonIcon(ref newButton, iconSprite);
+
+            return newButton;
+        }
+
+        /// <summary>
+        /// Create a beat saber dismiss button.
+        /// </summary>
+        /// <param name="parent"></param>
+        /// <returns></returns>
+        public static Button CreateBackButton(RectTransform parent)
+        {
+            Button dismissButton = CreateUIButton(parent, "BackArrowButton");  //UnityEngine.Object.Instantiate(Resources.FindObjectsOfTypeAll<Button>().First(x => (x.name == "BackArrowButton")), parent, false);
+            dismissButton.onClick.RemoveAllListeners();            
+            return dismissButton;
+        }
+
+        /// <summary>
         /// Generate TextMesh.
         /// </summary>
         /// <param name="parent"></param>
@@ -147,10 +199,8 @@ namespace SongBrowserPlugin.UI
         {
             if (button.GetComponentInChildren<TextMeshProUGUI>() != null)
             {
-
                 button.GetComponentInChildren<TextMeshProUGUI>().text = text;
             }
-
         }
 
         /// <summary>
@@ -175,9 +225,7 @@ namespace SongBrowserPlugin.UI
         {
             if (button.GetComponentsInChildren<UnityEngine.UI.Image>().Count() > 1)
             {
-                Console.WriteLine("SETTING ICONS");
-                button.GetComponentsInChildren<Image>().First(x => x.name == "Icon").sprite = icon;
-                //button.GetComponentsInChildren<Image>().First(x => x.name == "Icon").transform.Rotate(0, 0, 90);
+                button.GetComponentsInChildren<Image>().First(x => x.name == "Icon").sprite = icon;                
             }            
         }
 
@@ -190,7 +238,6 @@ namespace SongBrowserPlugin.UI
         {
             if (button.GetComponentsInChildren<UnityEngine.UI.Image>().Count() > 1)
             {
-                //button.GetComponentsInChildren<Image>().First(x => x.name == "Icon").gameObject.SetActive(enabled);
                 button.GetComponentsInChildren<UnityEngine.UI.Image>()[1].enabled = enabled;
             }
         }